feat(runner): integrate Codex native execution (#12616)

## Thinking Path

> - Paperclip is the open source control plane for teams of AI agents.
> - Agent runs currently use direct adapters and their established
finalization paths.
> - The new runner package needs one production integration before it
can execute a real provider through the server.
> - That integration must not change direct adapters or expose
unsupported providers.
> - The rollout must also preserve native runs that were already
recorded when the feature flag changes.
> - This pull request adds a default-off, Codex-only native execution
path and its authority boundary.
> - The benefit is a recoverable production vertical slice with explicit
compatibility guards.

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting server orchestration and adapter selection.

**Problem or motivation**

The runner package exists, but the server cannot yet start and recover a
governed Codex run through it. A careless integration could also route
existing direct adapters into the native runtime or lose cancellation
and finalization state.

**Proposed solution**

Add a hidden `paperclip_runner` adapter for Codex. Keep it behind the
default-off instance flag. Bind native execution, resume, cancellation,
semantic tool authority, and finalization to the recorded company,
issue, run, and coordinator identities. Leave every direct adapter on
its existing path.

**Alternatives considered**

A multi-provider launch was rejected because only Codex has the complete
production bridge in this series. Replacing direct adapter execution was
rejected because the runner remains experimental.

**Roadmap alignment**

This work supports governed tool access, action attribution, and
self-healing runs. It keeps the integration narrow and default-off.

## What Changed

- Add the Codex-only native session executor and persisted resumption
path.
- Add run-scoped semantic tool projection, authorization, receipts, and
idempotency.
- Add audited native cancellation with durable issue and coordinator
binding.
- Add result fencing so a recorded result cannot reacquire the provider
and run twice.
- Reject fresh runner starts when the rollout flag is off while
preserving recorded native recovery.
- Keep direct adapters outside native status, cancellation, record
creation, and finalization.
- Add focused conformance, recovery, cancellation, status, portability,
and compatibility coverage.

## Verification

- GitHub Actions is the authoritative test environment for this large
stack.
- The PR policy and lightweight stack checks run while this is a middle
PR.
- The full required suite runs when this PR becomes the lowest unmerged
or top PR.
- Greptile will review this exact delta after the branch is pushed.

## Risks

- The main risk is routing a legacy adapter into native execution.
Runtime selection and heartbeat tests cover that boundary.
- The next risk is stale or cross-company cancellation. Durable binding
checks and transactional audit persistence cover it.
- The adapter remains hidden and default-off. Only Codex is admitted.
- There are no database migration, lockfile, or GitHub workflow changes
in this PR.

## Stack

1. [Runner package, SDK, and developer
tools](https://github.com/paperclipai/paperclip/pull/12608)
2. This PR: Codex production server integration
3. [Provider-neutral task-thread
UI](https://github.com/paperclipai/paperclip/pull/12617)

## Model Used

OpenAI Codex with GPT-5, extended reasoning, repository tools, and
parallel review agents.

## 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
- [ ] 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-08-31 22:51:17 -05:00 committed by GitHub
parent 560e7e48b5
commit 51ad751e0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 22684 additions and 5251 deletions

View File

@ -114,6 +114,18 @@ export type {
LoginRunnerDisposable,
LoginRunnerRaceResult,
} from "./login-runner-lifecycle.js";
export {
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
isPaperclipRunnerProvider,
resolvePaperclipRunnerPermissionMode,
} from "./paperclip-runner-permissions.js";
export type {
CodexPermissionMode,
PaperclipRunnerPermissionCapability,
PaperclipRunnerPermissionMode,
PaperclipRunnerPermissionOption,
PaperclipRunnerProvider,
} from "./paperclip-runner-permissions.js";
// Keep the root adapter-utils entry browser-safe because the UI imports it.
// The sandbox callback bridge stays available via its dedicated subpath export.
export type {

View File

@ -0,0 +1,51 @@
export type PaperclipRunnerProvider = "codex";
export type CodexPermissionMode = "never" | "on-request" | "untrusted";
export type PaperclipRunnerPermissionMode = CodexPermissionMode;
export interface PaperclipRunnerPermissionOption<TMode extends string = string> {
value: TMode;
label: string;
description: string;
}
export interface PaperclipRunnerPermissionCapability {
configurable: true;
configKey: "codexPermissionMode";
defaultMode: PaperclipRunnerPermissionMode;
options: readonly PaperclipRunnerPermissionOption<PaperclipRunnerPermissionMode>[];
description: string;
}
/**
* Control-plane catalog for Paperclip Runner permission UX and validation.
* Runtime contracts validate the same native values again at the process
* boundary; this catalog must remain browser-safe.
*/
export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = {
codex: {
configurable: true,
configKey: "codexPermissionMode",
defaultMode: "never",
description: "Controls when Codex asks before an operation inside the assigned Paperclip environment.",
options: [
{ value: "never", label: "Full auto (never ask)", description: "Run without Codex approval pauses." },
{ value: "on-request", label: "Ask when requested", description: "Prompt when Codex requests approval." },
{ value: "untrusted", label: "Ask for untrusted operations", description: "Prompt for operations Codex does not classify as trusted." },
],
},
} as const satisfies Record<PaperclipRunnerProvider, PaperclipRunnerPermissionCapability>;
export function isPaperclipRunnerProvider(value: unknown): value is PaperclipRunnerProvider {
return typeof value === "string" && value in PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES;
}
export function resolvePaperclipRunnerPermissionMode(
provider: PaperclipRunnerProvider,
value: unknown,
): PaperclipRunnerPermissionMode {
const capability = PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[provider];
return capability.options.some((option) => option.value === value)
? value as PaperclipRunnerPermissionMode
: capability.defaultMode;
}

View File

@ -7,6 +7,7 @@
*/
export * from "./index.js";
export * from "./conformance/control-plane-port.js";
export * from "./conformance/capability-semantic-conformance.js";
export * from "./conformance/harness-driver.js";
export * from "./conformance/semantic-conformance.js";
export * from "./mock-core/deterministic-harness-driver.js";

View File

@ -0,0 +1,67 @@
import { readdirSync, statSync } from "node:fs";
import path from "node:path";
export function resolveNativeRunnerRequirement({ exitCode, stdout }) {
if (exitCode !== 0) {
return { nativeRunnerRequired: true, valid: false };
}
const jsonLines = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith("{"));
for (let index = jsonLines.length - 1; index >= 0; index -= 1) {
try {
const payload = JSON.parse(jsonLines[index]);
if (typeof payload?.nativeRunnerRequired === "boolean") {
return { nativeRunnerRequired: payload.nativeRunnerRequired, valid: true };
}
} catch {
// Keep scanning earlier JSON-looking output from pnpm and runtime logging.
}
}
// An unknown state must not strand a persisted native run without runnerd.
return { nativeRunnerRequired: true, valid: false };
}
function newestMtimeMs(target) {
const stat = statSync(target, { throwIfNoEntry: false });
if (!stat) return 0;
if (!stat.isDirectory()) return stat.mtimeMs;
let newest = stat.mtimeMs;
for (const entry of readdirSync(target)) {
const childNewest = newestMtimeMs(path.join(target, entry));
if (childNewest > newest) newest = childNewest;
}
return newest;
}
export function paperclipRunnerBinaryNeedsBuild({
repoRoot,
nativeRunnerRequired,
configuredBinary,
platform = process.platform,
}) {
if (!nativeRunnerRequired) return false;
if (configuredBinary?.trim()) return false;
const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
const packageRoot = path.join(repoRoot, "packages", "paperclip-runner");
const stagedBinary = path.join(packageRoot, "dist", "bin", executable);
const binaryStat = statSync(stagedBinary, { throwIfNoEntry: false });
if (!binaryStat?.isFile()) return true;
const runnerRoot = path.join(packageRoot, "runner");
const buildInputs = [
path.join(runnerRoot, "Cargo.toml"),
path.join(runnerRoot, "Cargo.lock"),
path.join(runnerRoot, ".cargo"),
path.join(runnerRoot, "rust-toolchain"),
path.join(runnerRoot, "rust-toolchain.toml"),
path.join(runnerRoot, "crates"),
];
return buildInputs.some((input) => newestMtimeMs(input) > binaryStat.mtimeMs);
}

View File

@ -6,6 +6,10 @@ import path from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts";
import {
paperclipRunnerBinaryNeedsBuild,
resolveNativeRunnerRequirement,
} from "./dev-runner-native-binary.mjs";
import { applyDevRunnerOptions } from "./dev-runner-options.ts";
import { collectWatchedSnapshot as collectDevServerWatchedSnapshot, diffSnapshots } from "./dev-runner-snapshot.mjs";
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
@ -522,6 +526,75 @@ async function buildPluginSdk() {
}
}
async function getNativeRunnerRequired(): Promise<boolean> {
const status = await runPnpm(
[
"--silent",
"--filter",
"@paperclipai/server",
"exec",
"tsx",
"src/dev-native-runner-status.ts",
],
{ env },
);
if (status.signal) {
exitForSignal(status.signal);
return true;
}
const requirement = resolveNativeRunnerRequirement({
exitCode: status.code,
stdout: status.stdout,
});
if (!requirement.valid) {
const detail = status.stderr || status.stdout;
process.stderr.write(
`[paperclip] unable to determine the native runner requirement; conservatively preparing the native runner${detail ? `\n${detail}` : "\n"}`,
);
}
return requirement.nativeRunnerRequired;
}
async function buildPaperclipRunner() {
console.log("[paperclip] building paperclip runner...");
const typescriptResult = await runPnpm(
["--filter", "@paperclipai/paperclip-runner", "build:typescript"],
{ stdio: "inherit" },
);
if (typescriptResult.signal) {
exitForSignal(typescriptResult.signal);
return;
}
if (typescriptResult.code !== 0) {
console.error("[paperclip] paperclip runner build failed");
process.exit(typescriptResult.code);
}
if (
!paperclipRunnerBinaryNeedsBuild({
repoRoot,
nativeRunnerRequired: await getNativeRunnerRequired(),
configuredBinary: env.PAPERCLIP_RUNNER_BINARY,
})
) {
return;
}
console.log("[paperclip] building paperclip runner native binary...");
const binaryResult = await runPnpm(
["--filter", "@paperclipai/paperclip-runner", "build:binary"],
{ stdio: "inherit" },
);
if (binaryResult.signal) {
exitForSignal(binaryResult.signal);
return;
}
if (binaryResult.code !== 0) {
console.error("[paperclip] paperclip runner native binary build failed");
process.exit(binaryResult.code);
}
}
function newestMtimeMs(target: string): number {
const stat = statSync(target, { throwIfNoEntry: false });
if (!stat) return 0;
@ -631,6 +704,7 @@ async function stopChildForRestart() {
}
async function startServerChild() {
await buildPaperclipRunner();
await buildPluginSdk();
const serverScript = mode === "watch" ? "dev:watch" : "dev";

View File

@ -257,6 +257,24 @@ describe("server adapter registry", () => {
expect(adapter!.supportsLocalAgentJwt).toBe(true);
});
it("rejects an unsupported persisted runner provider before probing Codex", async () => {
const adapter = requireServerAdapter("paperclip_runner");
const result = await adapter.testEnvironment({
companyId: "company-1",
adapterType: "paperclip_runner",
config: { provider: "opencode" },
});
expect(result).toMatchObject({
adapterType: "paperclip_runner",
status: "fail",
checks: [{
code: "paperclip_runner_provider_unsupported",
level: "error",
}],
});
});
it("built-in local adapters declare cheap model profile defaults where supported", async () => {
await expect(listAdapterModelProfiles("claude_local")).resolves.toEqual([
expect.objectContaining({

View File

@ -249,8 +249,8 @@ describe.sequential("adapter management route authorization", () => {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
const [routes, middleware, registry] = await Promise.all([
vi.importActual<typeof import("../routes/adapters.js")>("../routes/adapters.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
import("../routes/adapters.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../adapters/registry.js")>("../adapters/registry.js"),
]);
adapterRoutes = routes.adapterRoutes;

View File

@ -164,7 +164,7 @@ describe("adapter routes", () => {
disabled: false,
capabilities: {
supportsInstructionsBundle: false,
supportsModelProfiles: false,
supportsModelProfiles: true,
},
});
});

View File

@ -5906,6 +5906,20 @@ describe("company portability", () => {
expect(agentSvc.create).not.toHaveBeenCalled();
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: true });
await expect(portability.importBundle({
...request,
adapterOverrides: {
claudecoder: {
adapterType: "paperclip_runner",
adapterConfig: { provider: "opencode" },
},
},
}, "user-1")).rejects.toMatchObject({
status: 422,
details: { code: "paperclip_runner_provider_unavailable" },
});
expect(agentSvc.create).not.toHaveBeenCalled();
await portability.importBundle(request, "user-1");
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
adapterType: "paperclip_runner",

View File

@ -0,0 +1,122 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
paperclipRunnerBinaryNeedsBuild,
resolveNativeRunnerRequirement,
} from "../../../scripts/dev-runner-native-binary.mjs";
const tempRoots = new Set<string>();
afterEach(() => {
for (const root of tempRoots) {
fs.rmSync(root, { recursive: true, force: true });
}
tempRoots.clear();
});
function createRunnerCheckout(): { root: string; source: string; binary: string } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-runner-binary-"));
tempRoots.add(root);
const runnerRoot = path.join(root, "packages", "paperclip-runner", "runner");
const source = path.join(runnerRoot, "crates", "runner-core", "src", "main.rs");
const binary = path.join(
root,
"packages",
"paperclip-runner",
"dist",
"bin",
process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd",
);
fs.mkdirSync(path.dirname(source), { recursive: true });
fs.mkdirSync(path.dirname(binary), { recursive: true });
fs.writeFileSync(path.join(runnerRoot, "Cargo.toml"), "[workspace]\n", "utf8");
fs.writeFileSync(path.join(runnerRoot, "Cargo.lock"), "", "utf8");
fs.writeFileSync(source, "fn main() {}\n", "utf8");
fs.writeFileSync(binary, "runnerd", "utf8");
return { root, source, binary };
}
describe("paperclip runner native dev prerequisite", () => {
it("uses an explicit status response and fails safe when status is unknown", () => {
expect(
resolveNativeRunnerRequirement({
exitCode: 0,
stdout: "pnpm warning\n{\"nativeRunnerRequired\":false}\n",
}),
).toEqual({ nativeRunnerRequired: false, valid: true });
expect(
resolveNativeRunnerRequirement({
exitCode: 0,
stdout: "{\"nativeRunnerRequired\":true}\n",
}),
).toEqual({ nativeRunnerRequired: true, valid: true });
expect(
resolveNativeRunnerRequirement({
exitCode: 1,
stdout: "",
}),
).toEqual({ nativeRunnerRequired: true, valid: false });
expect(
resolveNativeRunnerRequirement({
exitCode: 0,
stdout: "{\"unexpected\":true}\n",
}),
).toEqual({ nativeRunnerRequired: true, valid: false });
});
it("builds only when the staged binary is missing or older than Rust inputs", () => {
const checkout = createRunnerCheckout();
const now = Date.now();
const old = new Date(now - 2_000);
const current = new Date(now + 2_000);
const next = new Date(now + 4_000);
fs.utimesSync(checkout.source, old, old);
fs.utimesSync(checkout.binary, current, current);
expect(
paperclipRunnerBinaryNeedsBuild({
repoRoot: checkout.root,
nativeRunnerRequired: true,
}),
).toBe(false);
fs.utimesSync(checkout.source, next, next);
expect(
paperclipRunnerBinaryNeedsBuild({
repoRoot: checkout.root,
nativeRunnerRequired: true,
}),
).toBe(true);
fs.rmSync(checkout.binary);
expect(
paperclipRunnerBinaryNeedsBuild({
repoRoot: checkout.root,
nativeRunnerRequired: true,
}),
).toBe(true);
});
it("keeps default-off legacy development Node-only", () => {
expect(
paperclipRunnerBinaryNeedsBuild({
repoRoot: "/checkout/without/a/staged/binary",
nativeRunnerRequired: false,
}),
).toBe(false);
});
it("does not build a workspace binary when an explicit binary is configured", () => {
expect(
paperclipRunnerBinaryNeedsBuild({
repoRoot: "/checkout/without/a/staged/binary",
nativeRunnerRequired: true,
configuredBinary: "/opt/paperclip/paperclip-runnerd",
}),
).toBe(false);
});
});

View File

@ -50,6 +50,9 @@
// scripted data and exit in one stdout write. The host then reads the open
// reply and the notifications in one batch, so a test proves the host holds
// and replays a frame that arrives before the route binds.
// - `emitScriptedFramesAfterFirstWrite`: when true, the fixture holds the
// scripted data and exit until it has acknowledged the first channel write.
// This gives tests a deterministic post-bind trigger without timing delays.
const readline = require("node:readline");
function send(message) {
@ -169,6 +172,10 @@ rl.on("line", (line) => {
noWriteReply: mode === "no-write-reply",
writeReplyDelayMs:
typeof directive.writeReplyDelayMs === "number" ? directive.writeReplyDelayMs : 0,
scriptedFramesAfterFirstWrite:
directive.emitScriptedFramesAfterFirstWrite === true
? scriptedFrameLines(directive, hostRouteId, workerSessionId)
: null,
emitAfterCloseChunk:
typeof directive.emitAfterCloseChunk === "string" ? directive.emitAfterCloseChunk : null,
});
@ -222,9 +229,11 @@ rl.on("line", (line) => {
// Emit the scripted data and the exit after the open reply, so the host
// binds the route first. Each frame echoes the exact pair; a test overrides
// `sid` or `rid` to force a mismatch.
setImmediate(() => {
process.stdout.write(scriptedFrameLines(directive, hostRouteId, workerSessionId));
});
if (directive.emitScriptedFramesAfterFirstWrite !== true) {
setImmediate(() => {
process.stdout.write(scriptedFrameLines(directive, hostRouteId, workerSessionId));
});
}
return;
}
@ -257,7 +266,16 @@ rl.on("line", (line) => {
},
});
}
const replyWrite = () => send({ jsonrpc: "2.0", id: message.id, result: null });
const replyWrite = () => {
send({ jsonrpc: "2.0", id: message.id, result: null });
const deferredFrames = entry.scriptedFramesAfterFirstWrite;
entry.scriptedFramesAfterFirstWrite = null;
if (deferredFrames) {
// Emit only after acknowledging the host trigger. The trigger can only be
// sent through a returned session, so the route is definitively bound.
setImmediate(() => process.stdout.write(deferredFrames));
}
};
if (entry.writeReplyDelayMs > 0) {
// Delay the write reply, so the host holds the pending-write reservation for
// a measurable time before the RPC settles.

View File

@ -1132,6 +1132,8 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
};
expect(adapterInput.runtime.sessionId).toBe("accepted-plan-retry-session");
expect(adapterInput.context.acceptedPlanWakeRouting).toBeUndefined();
expect(adapterInput.context.paperclipTaskMarkdown).toContain("Create child issues from the approved plan only");
expect(adapterInput.context.paperclipTaskMarkdown).toContain(
"Implement the accepted plan on this issue when the work is small and cohesive.",
);
}, 20_000);
});

View File

@ -51,7 +51,9 @@ describe("buildPaperclipTaskMarkdown", () => {
},
});
expect(acceptedConfirmation).toContain("Create child issues from the approved plan only");
expect(acceptedConfirmation).toContain(
"Implement the accepted plan on this issue when the work is small and cohesive.",
);
expect(acceptedConfirmation).not.toContain("Make the plan only.");
});
@ -68,7 +70,9 @@ describe("buildPaperclipTaskMarkdown", () => {
});
expect(acceptedConfirmation).toContain("Accepted plan directive:");
expect(acceptedConfirmation).toContain("Create child issues from the approved plan only");
expect(acceptedConfirmation).toContain(
"Implement the accepted plan on this issue when the work is small and cohesive.",
);
expect(acceptedConfirmation).not.toContain("- Work mode: \"planning\"");
});

View File

@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { cancelHeartbeatNativeRun } from "../services/heartbeat.js";
describe("native heartbeat cancellation authority", () => {
it("does not enter native cancellation for a direct-adapter run", async () => {
const cancel = vi.fn();
await expect(cancelHeartbeatNativeRun({
db: {} as Db,
runId: "legacy-run",
reason: "Cancelled by control plane",
runtimeMode: "legacy",
cancel,
})).resolves.toEqual({ decision: null, auditId: null });
expect(cancel).not.toHaveBeenCalled();
});
it("dispatches pause and bulk cancellation through the audited run scope", async () => {
const db = {} as Db;
const cancel = vi.fn(async () => ({
decision: { reasonCode: "cancellation_run_only" },
auditId: "audit-1",
}));
await expect(cancelHeartbeatNativeRun({
db,
runId: "run-1",
reason: "Cancelled due to agent pause",
runtimeMode: "native",
cancel,
})).resolves.toMatchObject({ auditId: "audit-1" });
expect(cancel).toHaveBeenCalledWith(
"run-1",
"Cancelled due to agent pause",
{ db, scope: "run" },
);
});
it("fails closed when a native cancellation lacks its decision audit", async () => {
const cancel = vi.fn(async () => ({
decision: null,
auditId: null,
}));
await expect(cancelHeartbeatNativeRun({
db: {} as Db,
runId: "run-2",
reason: "Cancelled because the agent was terminated",
runtimeMode: "native",
cancel,
})).rejects.toThrow("native_cancellation_outcome_not_audited");
});
});

View File

@ -1274,7 +1274,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const run = await heartbeat.getRun(runId);
expect(run).toMatchObject({
status: "running",
errorCode: "process_detached",
errorCode: "native_execution_ownership_unverified",
processPid: child.pid,
});
const retries = await db

View File

@ -164,6 +164,26 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
await expect(
buildPaperclipRuntimeMcpServers({ db, agent: agent!, runId: randomUUID() }),
).resolves.toEqual([]);
await expect(
buildPaperclipRuntimeMcpServers({
db,
agent: agent!,
runId: randomUUID(),
failOnUnavailableAssignedConnection: true,
}),
).rejects.toThrow(
`assigned native MCP connection is unavailable: ${installedConnection!.id}`,
);
await expect(
createManagedMcpRunConfig({
db,
agent: agent!,
runId: randomUUID(),
config: {},
projectId: null,
issueId: null,
}),
).resolves.toBeNull();
});
it("audits permitted remote MCP connections that were not installed when delivery is empty", async () => {

View File

@ -0,0 +1,589 @@
import express, { type Application } from "express";
import request from "supertest";
import { and, asc, eq } from "drizzle-orm";
import {
activityLog,
agents,
companies,
createDb,
documentRevisions,
documents,
heartbeatRuns,
issueComments,
issueDocuments,
issueRelations,
issues,
issueThreadInteractions,
} from "@paperclipai/db";
import {
CapabilitySemanticDispatcher,
createCapabilityFixtureState,
normalizeCapabilitySemanticObservation,
type CapabilityCommandEnvelope,
type CapabilityCommandOutcome,
type CapabilityCommandResult,
type CapabilityFixtureState,
type CapabilityRunContext,
type CapabilitySemanticCommand,
type SemanticConformanceAdapter,
type SemanticConformanceObservation,
type SemanticConformanceVector,
} from "../../vendor/paperclip-runner/testing.js";
import { errorHandler } from "../../middleware/index.js";
import { issueRoutes } from "../../routes/issues.js";
type Db = ReturnType<typeof createDb>;
export interface PaperclipSemanticConformanceIds {
readonly companyId: string;
readonly actorId: string;
readonly foreignCompanyId: string;
readonly foreignTaskId: string;
readonly blockerTaskId: string;
readonly worlds: Readonly<Record<string, { taskId: string; runId: string; capabilities: readonly string[] }>>;
}
interface IdempotencyRecord {
readonly canonicalCommand: string;
readonly result: CapabilityCommandResult;
}
interface ProductionWorld {
readonly port: PaperclipRouteSemanticPort;
readonly dispatcher: CapabilitySemanticDispatcher;
readonly taskId: string;
readonly runId: string;
}
/**
* Controlled production binding for the shared kit. It delegates writes to
* issueRoutes, so authorization, service transactions, audit logging, document
* revisions, interactions, and terminal arbitration remain production-owned.
*/
export class PaperclipProductionSemanticConformanceAdapter implements SemanticConformanceAdapter {
readonly id = "paperclip-production-services";
readonly kind = "production_binding" as const;
private constructor(readonly worlds: ReadonlyMap<string, ProductionWorld>) {}
static async create(db: Db, ids: PaperclipSemanticConformanceIds): Promise<PaperclipProductionSemanticConformanceAdapter> {
const app = createProductionAuthorityApp(db, ids);
const worlds = new Map<string, ProductionWorld>();
for (const [id, binding] of Object.entries(ids.worlds)) {
const port = new PaperclipRouteSemanticPort(db, app, ids, binding);
await port.refresh();
worlds.set(id, {
port,
dispatcher: new CapabilitySemanticDispatcher(port),
taskId: binding.taskId,
runId: binding.runId,
});
}
return new PaperclipProductionSemanticConformanceAdapter(worlds);
}
async execute(vector: SemanticConformanceVector): Promise<SemanticConformanceObservation> {
const worldId = vector.worldId ?? "default";
const world = this.worlds.get(worldId);
if (world === undefined) throw new Error(`semantic_conformance_world_missing:${worldId}`);
const before = world.port.snapshot();
const result = await world.dispatcher.dispatch({
runId: world.runId,
callId: vector.id,
operationId: vector.operationId,
input: vector.input,
});
const after = world.port.snapshot();
return normalizeCapabilitySemanticObservation({
result,
before,
after,
taskId: world.taskId,
semanticInput: vector.input,
});
}
}
export async function seedPaperclipSemanticConformance(
db: Db,
ids: PaperclipSemanticConformanceIds,
): Promise<void> {
await db.insert(companies).values([
{ id: ids.companyId, name: "Semantic Conformance", issuePrefix: "SCF" },
{ id: ids.foreignCompanyId, name: "Foreign Conformance", issuePrefix: "FRN" },
]);
await db.insert(agents).values({
id: ids.actorId,
companyId: ids.companyId,
name: "Conformance Approver",
role: "approver",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
let sequence = 0;
for (const [worldId, world] of Object.entries(ids.worlds)) {
sequence += 1;
await db.insert(issues).values({
id: world.taskId,
companyId: ids.companyId,
identifier: `SCF-${sequence}`,
title: `${worldId} semantic conformance`,
status: "in_progress",
workMode: "standard",
priority: "medium",
assigneeAgentId: ids.actorId,
});
await db.insert(heartbeatRuns).values({
id: world.runId,
companyId: ids.companyId,
agentId: ids.actorId,
status: "running",
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId: world.taskId, capabilities: world.capabilities },
});
await db.update(issues).set({
checkoutRunId: world.runId,
executionRunId: world.runId,
}).where(eq(issues.id, world.taskId));
}
await db.insert(issues).values({
id: ids.blockerTaskId,
companyId: ids.companyId,
identifier: "SCF-90",
title: "Unresolved semantic blocker",
status: "todo",
workMode: "standard",
priority: "medium",
});
const blockedTerminal = ids.worlds["terminal-blocked"];
if (blockedTerminal === undefined) throw new Error("semantic_conformance_blocked_terminal_world_missing");
await db.insert(issueRelations).values({
companyId: ids.companyId,
issueId: ids.blockerTaskId,
relatedIssueId: blockedTerminal.taskId,
type: "blocks",
});
await db.insert(issues).values({
id: ids.foreignTaskId,
companyId: ids.foreignCompanyId,
identifier: "FRN-1",
title: "Foreign semantic task",
status: "todo",
workMode: "standard",
priority: "medium",
});
}
class PaperclipRouteSemanticPort {
readonly #idempotency = new Map<string, IdempotencyRecord>();
#state: CapabilityFixtureState;
constructor(
readonly db: Db,
readonly app: Application,
readonly ids: PaperclipSemanticConformanceIds,
readonly binding: { taskId: string; runId: string; capabilities: readonly string[] },
) {
this.#state = createCapabilityFixtureState();
}
context(runId: string): CapabilityRunContext {
if (runId !== this.binding.runId) throw new Error("semantic_conformance_run_binding_mismatch");
const task = this.#state.tasks.find((candidate) => candidate.id === this.binding.taskId);
if (task === undefined) throw new Error("semantic_conformance_task_missing");
return {
schema: "paperclip.capability.run-context.v1",
company: {
id: this.ids.companyId,
name: "Semantic Conformance",
issuePrefix: "SCF",
status: "active",
},
actor: {
id: this.ids.actorId,
name: "Conformance Approver",
role: "approver",
status: "active",
capabilityGrants: [...this.binding.capabilities],
},
activeTask: structuredClone(task),
ancestors: [],
wake: { reason: "manual", payload: {} },
capabilities: [...this.binding.capabilities],
budget: { limitCents: 10_000, spentCents: 0, remainingCents: 10_000 },
interactionResults: [],
};
}
snapshot(): Readonly<CapabilityFixtureState> {
return structuredClone(this.#state);
}
async tryApplyCommand(envelope: CapabilityCommandEnvelope): Promise<CapabilityCommandOutcome> {
const canonicalCommand = canonicalJson(envelope.command);
const prior = this.#idempotency.get(envelope.idempotencyKey);
if (prior !== undefined) {
if (prior.canonicalCommand !== canonicalCommand) {
return this.denial(envelope.command, "idempotency_conflict", "Idempotency key was reused with different input");
}
return {
ok: true,
result: { ...structuredClone(prior.result), disposition: "duplicate" },
};
}
const outcome = await this.applyThroughProductionRoutes(envelope.command);
await this.refresh();
if (outcome.ok) {
this.#idempotency.set(envelope.idempotencyKey, {
canonicalCommand,
result: structuredClone(outcome.result),
});
}
return outcome;
}
async refresh(): Promise<void> {
const [issue] = await this.db.select().from(issues).where(eq(issues.id, this.binding.taskId));
if (issue === undefined) throw new Error("semantic_conformance_task_missing");
const [run] = await this.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, this.binding.runId));
if (run === undefined) throw new Error("semantic_conformance_run_missing");
const comments = await this.db.select().from(issueComments)
.where(eq(issueComments.issueId, issue.id))
.orderBy(asc(issueComments.createdAt), asc(issueComments.id));
const linkedDocuments = await this.db.select({ link: issueDocuments, document: documents })
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(eq(issueDocuments.issueId, issue.id));
const revisions = linkedDocuments.length === 0
? []
: await this.db.select().from(documentRevisions)
.where(eq(documentRevisions.companyId, this.ids.companyId))
.orderBy(asc(documentRevisions.revisionNumber));
const interactions = await this.db.select().from(issueThreadInteractions)
.where(eq(issueThreadInteractions.issueId, issue.id))
.orderBy(asc(issueThreadInteractions.createdAt), asc(issueThreadInteractions.id));
const relations = await this.db.select().from(issueRelations)
.where(and(eq(issueRelations.relatedIssueId, issue.id), eq(issueRelations.type, "blocks")));
const audit = await this.db.select().from(activityLog)
.where(and(
eq(activityLog.companyId, this.ids.companyId),
eq(activityLog.runId, this.binding.runId),
))
.orderBy(asc(activityLog.createdAt), asc(activityLog.id));
const state = createCapabilityFixtureState({
company: { id: this.ids.companyId, name: "Semantic Conformance", issuePrefix: "SCF" },
actors: [{
id: this.ids.actorId,
companyId: this.ids.companyId,
name: "Conformance Approver",
role: "approver",
status: "active",
budgetId: "production-binding-budget",
capabilityGrants: [...this.binding.capabilities],
}],
tasks: [{
id: issue.id,
companyId: issue.companyId,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
status: issue.status as CapabilityFixtureState["tasks"][number]["status"],
priority: issue.priority as CapabilityFixtureState["tasks"][number]["priority"],
workMode: issue.workMode as CapabilityFixtureState["tasks"][number]["workMode"],
parentId: issue.parentId,
assigneeActorId: issue.assigneeAgentId,
checkoutRunId: issue.checkoutRunId,
executionRunId: issue.executionRunId,
startedAt: issue.startedAt?.toISOString() ?? null,
completedAt: issue.completedAt?.toISOString() ?? null,
}],
comments: comments.map((comment) => ({
id: comment.id,
taskId: comment.issueId,
authorActorId: comment.authorAgentId,
body: comment.body,
createdAt: comment.createdAt.toISOString(),
})),
documents: linkedDocuments.map(({ link, document }) => ({
id: document.id,
taskId: link.issueId,
key: link.key,
title: document.title ?? "",
format: "markdown" as const,
latestRevisionId: document.latestRevisionId ?? "",
revisions: revisions
.filter((revision) => revision.documentId === document.id)
.map((revision) => ({
id: revision.id,
documentId: revision.documentId,
revision: revision.revisionNumber,
body: revision.body,
changeSummary: revision.changeSummary,
createdAt: revision.createdAt.toISOString(),
})),
})),
interactions: interactions.map((interaction) => ({
id: interaction.id,
taskId: interaction.issueId,
kind: toFixtureInteractionKind(interaction.kind),
status: interaction.status as CapabilityFixtureState["interactions"][number]["status"],
title: interaction.title ?? "",
prompt: readPrompt(interaction.payload),
payload: interaction.payload,
targetRevisionId: readTargetRevisionId(interaction.payload),
continuationPolicy: interaction.continuationPolicy as CapabilityFixtureState["interactions"][number]["continuationPolicy"],
result: interaction.result ?? null,
createdAt: interaction.createdAt.toISOString(),
resolvedAt: interaction.resolvedAt?.toISOString() ?? null,
})),
blockers: relations.map((relation) => ({
id: relation.id,
taskId: relation.relatedIssueId,
blockedByTaskId: relation.issueId,
createdAt: relation.createdAt.toISOString(),
})),
});
state.lifecycle = "running";
state.activeRunId = run.id;
state.runs = [{
id: run.id,
companyId: run.companyId,
actorId: run.agentId,
taskId: issue.id,
sessionId: run.sessionIdAfter ?? run.sessionIdBefore ?? run.externalRunId ?? run.id,
backendKind: "runner",
sourceInstanceId: "paperclip-production-services",
status: toFixtureRunStatus(run.status),
attempt: run.scheduledRetryAttempt + 1,
openedAt: (run.startedAt ?? run.createdAt).toISOString(),
finishedAt: run.finishedAt?.toISOString() ?? null,
wake: { reason: "manual", payload: {} },
capabilities: [...this.binding.capabilities],
events: [],
result: null,
sessionCheckpoint: null,
}];
state.revision = this.#state.revision + 1;
state.audit = audit.map((entry) => ({
id: entry.id,
at: entry.createdAt.toISOString(),
runId: entry.runId,
actorId: entry.agentId,
action: entry.action,
entityType: entry.entityType,
entityId: entry.entityId,
details: (entry.details ?? {}) as CapabilityFixtureState["audit"][number]["details"],
}));
this.#state = state;
}
private async applyThroughProductionRoutes(command: CapabilitySemanticCommand): Promise<CapabilityCommandOutcome> {
const taskId = this.binding.taskId;
switch (command.kind) {
case "report_progress": {
const response = await this.post(`/api/issues/${taskId}/comments`, { body: command.body });
if (!isSuccess(response.status)) return this.routeDenial(command, response.status, response.body);
return this.success(command, [`task:${taskId}`, `comment:${String(response.body.id)}`]);
}
case "write_document": {
const response = await this.put(`/api/issues/${taskId}/documents/${encodeURIComponent(command.key)}`, {
title: command.title,
format: "markdown",
body: command.body,
changeSummary: command.changeSummary ?? null,
baseRevisionId: command.baseRevisionId,
});
if (!isSuccess(response.status)) return this.routeDenial(command, response.status, response.body);
return this.success(command, [
`task:${taskId}`,
`document:${String(response.body.id)}`,
`revision:${String(response.body.latestRevisionId)}`,
]);
}
case "request_human_input": {
if (command.interactionKind !== "confirmation") {
return this.denial(command, "operation_unavailable", "Only confirmation is bound in this controlled adapter");
}
const response = await this.post(`/api/issues/${taskId}/interactions`, {
kind: "request_confirmation",
idempotencyKey: `semantic:${command.title}`,
title: command.title,
summary: command.prompt,
continuationPolicy: command.continuationPolicy,
payload: {
version: 1,
prompt: command.prompt,
detailsMarkdown: "",
acceptLabel: "Confirm",
rejectLabel: "Request changes",
rejectRequiresReason: false,
supersedeOnUserComment: true,
},
});
if (!isSuccess(response.status)) return this.routeDenial(command, response.status, response.body);
const transition = await this.patch(`/api/issues/${taskId}`, { status: "in_review" });
if (!isSuccess(transition.status)) return this.routeDenial(command, transition.status, transition.body);
return this.success(command, [`task:${taskId}`, `interaction:${String(response.body.id)}`]);
}
case "set_dependencies": {
const response = await this.patch(`/api/issues/${taskId}`, {
blockedByIssueIds: command.blockedByTaskIds,
});
if (!isSuccess(response.status)) return this.routeDenial(command, response.status, response.body);
return this.success(command, [
`task:${taskId}`,
...command.blockedByTaskIds.map((id) => `blocker:${id}`),
]);
}
case "finish_task": {
const response = await this.patch(`/api/issues/${taskId}`, {
status: "done",
comment: command.summary,
});
if (!isSuccess(response.status)) return this.routeDenial(command, response.status, response.body);
return this.success(command, [`task:${taskId}`, `comment:${taskId}`]);
}
default:
return this.denial(command, "operation_unavailable", "Operation is not bound in this controlled adapter");
}
}
private success(command: CapabilitySemanticCommand, entityRefs: string[]): CapabilityCommandOutcome {
return {
ok: true,
result: {
commandId: `production:${this.binding.runId}:${this.#idempotency.size + 1}`,
commandKind: command.kind,
disposition: "applied",
stateRevision: this.#state.revision + 1,
entityRefs,
scheduledWakeIds: [],
},
};
}
private routeDenial(command: CapabilitySemanticCommand, status: number, body: unknown): CapabilityCommandOutcome {
const message = errorMessage(body);
if (command.kind === "write_document" && status === 409) {
return this.denial(command, "document_revision_conflict", message);
}
if (command.kind === "set_dependencies" && status === 422) {
return this.denial(command, "company_scope_violation", message);
}
const code = status === 403
? "operation_not_authorized"
: status === 409
? "state_conflict"
: status === 422
? "semantic_rule_violation"
: "production_service_error";
return this.denial(command, code, message, status >= 500);
}
private denial(
command: CapabilitySemanticCommand,
code: string,
message: string,
retryable = false,
): CapabilityCommandOutcome {
return {
ok: false,
commandKind: command.kind,
stateRevision: this.#state.revision,
error: { code, message, retryable },
};
}
private post(path: string, body: unknown) {
return request(this.app).post(path).set("X-Paperclip-Run-Id", this.binding.runId).send(body);
}
private put(path: string, body: unknown) {
return request(this.app).put(path).set("X-Paperclip-Run-Id", this.binding.runId).send(body);
}
private patch(path: string, body: unknown) {
return request(this.app).patch(path).set("X-Paperclip-Run-Id", this.binding.runId).send(body);
}
}
function toFixtureRunStatus(status: string): CapabilityFixtureState["runs"][number]["status"] {
switch (status) {
case "running":
case "succeeded":
case "failed":
case "cancelled":
return status;
default:
throw new Error(`semantic_conformance_run_status_unmapped:${status}`);
}
}
function createProductionAuthorityApp(db: Db, ids: PaperclipSemanticConformanceIds): Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = {
type: "agent",
agentId: ids.actorId,
companyId: ids.companyId,
runId: req.header("X-Paperclip-Run-Id") ?? undefined,
source: "agent_jwt",
};
next();
});
app.use("/api", issueRoutes(db, {} as never));
app.use(errorHandler);
return app;
}
function toFixtureInteractionKind(kind: string): CapabilityFixtureState["interactions"][number]["kind"] {
switch (kind) {
case "request_confirmation": return "confirmation";
case "request_checkbox_confirmation": return "checkbox";
case "ask_user_questions": return "questions";
case "suggest_tasks": return "suggest_tasks";
case "request_item_verdicts": return "item_verdicts";
default: throw new Error(`semantic_conformance_interaction_kind_unmapped:${kind}`);
}
}
function readPrompt(payload: unknown): string {
return typeof payload === "object" && payload !== null && "prompt" in payload
? String(payload.prompt)
: "";
}
function readTargetRevisionId(payload: unknown): string | null {
if (typeof payload !== "object" || payload === null || !("target" in payload)) return null;
const target = payload.target;
return typeof target === "object" && target !== null && "revisionId" in target
? String(target.revisionId)
: null;
}
function isSuccess(status: number): boolean {
return status >= 200 && status < 300;
}
function errorMessage(body: unknown): string {
return typeof body === "object" && body !== null && "error" in body
? String(body.error)
: "Production service denied the semantic command";
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (typeof value === "object" && value !== null) {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
}
return JSON.stringify(value) ?? "undefined";
}

View File

@ -123,7 +123,7 @@ describe("P6-32 legacy finalization regression", () => {
id: runId,
status: "succeeded",
runtimeMode: "legacy",
runtimeModeReason: "instance_flag_disabled",
runtimeModeReason: "direct_adapter",
resultJson: { summary: "Legacy bytes", nested: { count: 1, ok: true } },
});
await expect(reconcileNativeFinalizations(db, [runId])).resolves.toEqual([]);

View File

@ -0,0 +1,894 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import {
activityLog,
agents,
companies,
completionContracts,
createDb,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
issueRecoveryActions,
issueWorkProducts,
issues,
nativeRunFinalizations,
nativeRunResults,
projectWorkspaces,
projects,
statusDecisionEffects,
statusDecisions,
workAssessments,
} from "@paperclipai/db";
import {
type NativeExecutionInputV1,
type NativeSession,
type NativeSessionBackend,
type PersistedNativeSession,
type PrpEvent,
} from "@paperclipai/paperclip-runner";
import {
CONTROL_PLANE_CONFORMANCE_RESULT,
CONTROL_PLANE_CONFORMANCE_TERMINAL,
} from "../vendor/paperclip-runner/testing.js";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
import {
claimNativeSessionResumptions,
dispatchNativeSessionResumptions,
} from "../services/native-runtime/native-finalization-reconciler.js";
const legacyAdapterExecute = vi.hoisted(() => vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
summary: "Fresh flag-off run completed through legacy.",
resultJson: { summary: "fresh legacy after persisted native recovery" },
provider: "test",
model: "legacy-test",
})));
vi.mock("../adapters/index.js", async () => {
const actual = await vi.importActual<typeof import("../adapters/index.js")>("../adapters/index.js");
return {
...actual,
getServerAdapter: vi.fn(() => ({
type: "codex_local",
execute: legacyAdapterExecute,
supportsLocalAgentJwt: false,
})),
};
});
import { heartbeatService } from "../services/heartbeat.js";
import { instanceSettingsService } from "../services/instance-settings.js";
describe("P6-25 pre-result native session recovery", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
const companyId = "79000000-0000-4000-8000-000000000001";
const agentId = "79000000-0000-4000-8000-000000000002";
const issueId = "79000000-0000-4000-8000-000000000003";
const runId = "79000000-0000-4000-8000-000000000004";
const cancelledRunId = "79000000-0000-4000-8000-000000000005";
const exhaustedRunId = "79000000-0000-4000-8000-000000000006";
const missingCheckpointRunId = "79000000-0000-4000-8000-000000000007";
const initialRunId = "79000000-0000-4000-8000-000000000008";
const bootstrapRetryRunId = "79000000-0000-4000-8000-000000000009";
const observedExpiredRunId = "79000000-0000-4000-8000-000000000010";
const observedLivePidRunId = "79000000-0000-4000-8000-000000000011";
const persistedProfile = {
mode: "native",
nativeExecutionInput: { schema: "paperclip.native-execution-input.v1", binding: { runId } },
sessionCheckpoint: {
backendKind: "codex_app_server",
sessionId: "persisted-session",
identity: { runId },
providerSessionId: "provider-session",
activeTurnId: "active-turn",
},
};
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-resume-");
db = createDb(temporary.connectionString);
await db.insert(companies).values({ id: companyId, name: "Native resume", issuePrefix: "NRR" });
await db.insert(agents).values({
id: agentId,
companyId,
name: "Native resume agent",
adapterType: "codex_local",
status: "running",
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Resume the same native run",
status: "in_progress",
assigneeAgentId: agentId,
workMode: "standard",
});
await db.insert(heartbeatRuns).values([
{
id: runId,
companyId,
agentId,
nativeIssueId: issueId,
status: "running",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: persistedProfile,
contextSnapshot: { issueId },
},
{
id: cancelledRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "cancelled",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: persistedProfile,
contextSnapshot: { issueId },
},
{
id: exhaustedRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "failed",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: persistedProfile,
contextSnapshot: { issueId },
},
{
id: missingCheckpointRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "running",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: missingCheckpointRunId },
},
},
contextSnapshot: { issueId },
},
{
id: initialRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "running",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: initialRunId },
},
},
contextSnapshot: { issueId },
},
{
id: bootstrapRetryRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "failed",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: bootstrapRetryRunId },
},
},
errorCode: "provider_initialize_timeout",
contextSnapshot: { issueId },
},
...[observedExpiredRunId, observedLivePidRunId].map((observedRunId) => ({
id: observedRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "running" as const,
runtimeMode: "native" as const,
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
...persistedProfile,
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: observedRunId },
},
sessionCheckpoint: {
...persistedProfile.sessionCheckpoint,
identity: { runId: observedRunId },
},
},
contextSnapshot: { issueId },
})),
]);
await db.insert(nativeRunFinalizations).values([
{ runId, companyId, issueId, phase: "retryable_failure", attempt: 1, nextAttemptAt: new Date(0) },
{ runId: cancelledRunId, companyId, issueId, phase: "retryable_failure", attempt: 1, nextAttemptAt: new Date(0) },
{ runId: exhaustedRunId, companyId, issueId, phase: "terminal_failure", attempt: 3 },
{ runId: missingCheckpointRunId, companyId, issueId, phase: "retryable_failure", attempt: 1 },
{ runId: initialRunId, companyId, issueId, phase: "observed", attempt: 0 },
{
runId: bootstrapRetryRunId,
companyId,
issueId,
phase: "retryable_failure",
attempt: 1,
nextAttemptAt: new Date(0),
failureCode: "native_session_interrupted",
failureDetail: {
message: "provider_initialize_timeout: provider=codex stage=health",
originalFailureCode: "provider_initialize_timeout",
recoveryMode: "bootstrap_retry",
providerSessionEstablished: false,
providerEventsExist: false,
checkpointExists: false,
},
},
...[observedExpiredRunId, observedLivePidRunId].map((observedRunId) => ({
runId: observedRunId,
companyId,
issueId,
phase: "observed" as const,
attempt: 2,
leaseOwner: "prior-native-owner",
leaseExpiresAt: new Date(0),
})),
]);
}, 30_000);
afterAll(async () => temporary?.cleanup());
it("wins one database lease for the original result-less run without consulting the flag", async () => {
const results = await Promise.all([
claimNativeSessionResumptions({ db, runnerInstanceId: "reaper-a", runIds: [runId] }),
claimNativeSessionResumptions({ db, runnerInstanceId: "reaper-b", runIds: [runId] }),
]);
expect(results.flat()).toHaveLength(1);
expect(results.flat()[0]).toMatchObject({ runId });
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toEqual([
expect.objectContaining({ id: runId, status: "running", runtimeMode: "native" }),
]);
await expect(db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId))).resolves.toEqual([
expect.objectContaining({ runId, phase: "observed", resultId: null, attempt: 1 }),
]);
});
it("dispatches the persisted run id and lease to the live same-run resume consumer", async () => {
await db.update(nativeRunFinalizations).set({
phase: "retryable_failure",
leaseOwner: null,
leaseExpiresAt: null,
nextAttemptAt: new Date(0),
}).where(eq(nativeRunFinalizations.runId, runId));
const dispatched: Array<{ runId: string; leaseOwner: string }> = [];
await expect(dispatchNativeSessionResumptions({
db,
runnerInstanceId: "heartbeat-reaper",
runIds: [runId],
dispatch: (claim) => dispatched.push(claim),
})).resolves.toHaveLength(1);
expect(dispatched).toEqual([{ runId, leaseOwner: expect.stringContaining("heartbeat-reaper:resume:") }]);
await expect(db.select().from(heartbeatRuns)).resolves.toHaveLength(8);
await expect(db.select().from(nativeRunFinalizations)).resolves.toHaveLength(8);
});
it("uses checkpoint-free bootstrap retry only when durable evidence proves no provider session existed", async () => {
await expect(claimNativeSessionResumptions({
db,
runnerInstanceId: "reaper",
runIds: [bootstrapRetryRunId],
})).resolves.toEqual([
{ runId: bootstrapRetryRunId, leaseOwner: expect.stringContaining("reaper:resume:") },
]);
});
it("never claims an expired observed coordinator without explicit retryable failure", async () => {
const dispatched: Array<{ runId: string; leaseOwner: string }> = [];
await expect(dispatchNativeSessionResumptions({
db,
runnerInstanceId: "replacement-reaper",
runIds: [observedExpiredRunId],
dispatch: (claim) => dispatched.push(claim),
})).resolves.toEqual([]);
expect(dispatched).toEqual([]);
await expect(db.select().from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, observedExpiredRunId))).resolves.toEqual([
expect.objectContaining({
phase: "observed",
attempt: 2,
leaseOwner: "prior-native-owner",
leaseExpiresAt: new Date(0),
}),
]);
});
it("blocks ambiguous observed ownership and a live unrelated persisted PID without replacement effects", async () => {
const unrelatedProcess = spawn(
process.execPath,
["-e", "setInterval(() => {}, 1_000)"],
{ stdio: "ignore" },
);
await once(unrelatedProcess, "spawn");
try {
await db.update(heartbeatRuns).set({
processPid: unrelatedProcess.pid!,
processStartedAt: new Date("2026-08-09T04:00:00.000Z"),
}).where(eq(heartbeatRuns.id, observedLivePidRunId));
const backendFactory = vi.fn((): NativeSessionBackend => ({
async descriptor() {
return {
kind: "mock",
name: "unexpected-observed-recovery",
version: "1",
capabilities: {
resume: true,
typedEvents: true,
steering: false,
interruption: true,
structuredResult: true,
},
};
},
async openSession() {
throw new Error("observed ownership must not open a provider session");
},
async recoverSession() {
throw new Error("observed ownership must not recover a provider session");
},
}));
const heartbeat = heartbeatService(db, {
runtimeEnv: { PAPERCLIP_INSTANCE_ID: "observed-owner-test" },
nativeSessionBackendFactory: backendFactory,
});
const reaped = await heartbeat.reapOrphanedRuns({ staleThresholdMs: 0 });
expect(reaped.runIds).not.toContain(observedExpiredRunId);
expect(reaped.runIds).not.toContain(observedLivePidRunId);
await heartbeat.drainActiveRunExecutions();
expect(backendFactory).not.toHaveBeenCalled();
expect(() => process.kill(unrelatedProcess.pid!, 0)).not.toThrow();
await expect(db.select().from(heartbeatRuns).where(eq(
heartbeatRuns.id,
observedExpiredRunId,
))).resolves.toEqual([
expect.objectContaining({
status: "running",
errorCode: "native_execution_ownership_unverified",
}),
]);
await expect(db.select().from(heartbeatRuns).where(eq(
heartbeatRuns.id,
observedLivePidRunId,
))).resolves.toEqual([
expect.objectContaining({
status: "running",
processPid: unrelatedProcess.pid,
errorCode: "native_execution_ownership_unverified",
}),
]);
for (const observedRunId of [observedExpiredRunId, observedLivePidRunId]) {
await expect(db.select().from(nativeRunFinalizations).where(eq(
nativeRunFinalizations.runId,
observedRunId,
))).resolves.toEqual([
expect.objectContaining({
phase: "observed",
attempt: 2,
leaseOwner: "prior-native-owner",
leaseExpiresAt: new Date(0),
}),
]);
await expect(db.select().from(nativeRunResults).where(eq(
nativeRunResults.runId,
observedRunId,
))).resolves.toHaveLength(0);
await expect(db.select().from(workAssessments).where(eq(
workAssessments.runId,
observedRunId,
))).resolves.toHaveLength(0);
}
} finally {
if (
unrelatedProcess.exitCode === null &&
unrelatedProcess.signalCode === null
) {
const exited = once(unrelatedProcess, "exit");
unrelatedProcess.kill("SIGKILL");
await exited;
}
}
});
it("does not resume cancelled or exhausted runs and fails closed without a checkpoint", async () => {
await expect(claimNativeSessionResumptions({
db,
runnerInstanceId: "reaper",
runIds: [cancelledRunId, exhaustedRunId, missingCheckpointRunId],
})).resolves.toEqual([]);
await expect(db.select().from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, missingCheckpointRunId))).resolves.toEqual([
expect.objectContaining({ phase: "terminal_failure", failureCode: "native_session_interrupted" }),
]);
await expect(db.select().from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, issueId))).resolves.toEqual([
expect.objectContaining({ cause: "native_session_interrupted", wakePolicy: null }),
]);
});
it("does not mistake the pre-first-attempt observed coordinator for an orphan", async () => {
await expect(claimNativeSessionResumptions({
db,
runnerInstanceId: "reaper",
runIds: [initialRunId],
})).resolves.toEqual([]);
await expect(db.select().from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, initialRunId))).resolves.toEqual([
expect.objectContaining({ phase: "observed", attempt: 0, failureCode: null }),
]);
});
});
describe("P6-25 persisted reaper-to-finalization recovery", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
let staleProviderProcess: ReturnType<typeof spawn> | null = null;
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const newerExecutionWorkspaceId = randomUUID();
const issueId = randomUUID();
const freshIssueId = randomUUID();
const runId = randomUUID();
const contractId = randomUUID();
const workProductId = randomUUID();
const sessionId = randomUUID();
const runnerInstanceId = randomUUID();
const turnId = "provider-active-turn";
const providerSessionId = "provider-existing-session";
const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
const contract = {
revision: "phase6-recovery-v1",
objective: "Recover the persisted provider turn",
criteria: [{ id: "objective", requirement: "Complete through same-run recovery" }],
};
const contractSha = "phase6-recovery-contract";
const evidenceRef = `work_product:${workProductId}`;
const result = structuredClone(CONTROL_PLANE_CONFORMANCE_RESULT);
result.completionClaim.contractRevision = contract.revision;
result.completionClaim.criteria[0]!.evidenceRefs = [evidenceRef];
result.evidence = [{ kind: "work_product", ref: evidenceRef }];
result.verification[0]!.artifactRef = evidenceRef;
result.summary = "Recovered the already-active provider turn.";
const terminal = {
...CONTROL_PLANE_CONFORMANCE_TERMINAL,
reportedWorkDisposition: result.reportedWorkDisposition,
};
const execution: NativeExecutionInputV1 = {
schema: "paperclip.native-execution-input.v1",
binding: { companyId, runId, issueId, agentId, executionWorkspaceId },
task: {
identifier: "NRR-1",
title: "Recover one native heartbeat",
description: null,
workMode: "standard",
},
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,
schemaVersion: "paperclip.completion-contract.v1",
contract,
},
interactionResponses: [],
credentialBindings: [],
};
const checkpoint: PersistedNativeSession = {
backendKind: "mock",
sessionId: "driver-existing-session",
identity: { companyId, runId, issueId, agentId, sessionId },
providerSessionId,
cursor: "1",
activeTurnId: turnId,
pendingRuntimeRequests: [],
lineage: [],
};
const providerTerminalEvent: PrpEvent = {
schema: "paperclip.prp.event.v1",
sourceEventId: `${runnerInstanceId}:provider-terminal`,
sourceSeq: 1,
sourceInstanceId: runnerInstanceId,
sourceKind: "runner",
runId,
normalizedSessionId: sessionId,
turnId,
eventType: "turn.completed",
schemaVersion: 1,
priority: 0,
emittedAt: "2026-08-09T04:30:00.000Z",
payload: {},
};
const openSession = vi.fn(async () => {
throw new Error("same-run recovery must not open a second provider session");
});
const startTurn = vi.fn(async () => ({ turnId: "duplicate-turn" }));
const close = vi.fn(async () => undefined);
const recoverSession = vi.fn(async (persisted: PersistedNativeSession) => {
expect(persisted).toMatchObject({ providerSessionId, activeTurnId: turnId });
expect(staleProviderProcess).not.toBeNull();
expect(
staleProviderProcess!.exitCode !== null ||
staleProviderProcess!.signalCode !== null,
).toBe(true);
const recoveredSnapshot: PersistedNativeSession = { ...structuredClone(checkpoint), cursor: "2" };
const session: NativeSession = {
identity: () => structuredClone(checkpoint.identity),
async capabilities() {
return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true };
},
async *events() { yield providerTerminalEvent; },
startTurn,
async result() { return { result, terminal, turnId }; },
async snapshot() { return structuredClone(recoveredSnapshot); },
close,
};
return { recovered: true, session };
});
const backend: NativeSessionBackend = {
async descriptor() {
return {
kind: "mock",
name: "persisted-recovery-backend",
version: "1",
capabilities: { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true },
};
},
openSession,
recoverSession,
};
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-reaper-e2e-");
db = createDb(temporary.connectionString);
await instanceSettingsService(db).updateExperimental({ enableNativeRunner: false });
await db.insert(companies).values({
id: companyId,
name: "Native same-run recovery",
issuePrefix: "NRR",
status: "active",
defaultResponsibleUserId: "responsible-user",
});
await db.insert(projects).values({ id: projectId, companyId, name: "Recovery project", status: "active" });
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Recovery workspace",
cwd: repoRoot,
isPrimary: true,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Native recovery agent",
adapterType: "paperclip_runner",
status: "active",
runtimeConfig: {
heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 },
nativeRunner: { mode: "native", backend: "codex_app_server", protocolVersion: 1 },
},
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
issueNumber: 1,
identifier: "NRR-1",
title: "Recover one native heartbeat",
status: "in_progress",
assigneeAgentId: agentId,
workMode: "standard",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: issueId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Persisted recovery workspace",
status: "active",
cwd: repoRoot,
providerType: "local_fs",
});
await db.insert(executionWorkspaces).values({
id: newerExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: issueId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Newer issue workspace",
status: "active",
cwd: repoRoot,
providerType: "local_fs",
});
await db.update(issues).set({
// Simulate a newer run moving the issue-level pointer before the older native run is
// recovered. The older run must still restore its own immutable workspace binding.
executionWorkspaceId: newerExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "shared_workspace" },
}).where(eq(issues.id, issueId));
await db.insert(completionContracts).values({
id: contractId,
companyId,
issueId,
revision: 1,
schemaVersion: "paperclip.completion-contract.v1",
policyVersion: "phase6-v1",
risk: "standard",
completionAuthority: "server_arbiter",
incompleteCriteriaPolicy: "preserve_non_terminal",
contractJson: contract,
canonicalSha256: contractSha,
createdByActorType: "system",
createdByActorId: "test",
});
await db.insert(issueWorkProducts).values({
id: workProductId,
companyId,
issueId,
type: "artifact",
provider: "paperclip",
title: "Recovered result evidence",
status: "ready_for_review",
reviewState: "approved",
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
nativeIssueId: issueId,
status: "running",
runtimeMode: "native",
runtimeModeResolverVersion: "phase6-v1",
runtimeModeReason: "eligible_opt_in",
runtimeModeResolvedAt: new Date("2026-08-09T04:00:00.000Z"),
runnerProfileJson: {
mode: "native",
backend: "codex_app_server",
protocolVersion: 1,
nativeExecutionInput: execution,
sessionCheckpoint: checkpoint,
},
runnerInstanceId,
nativeSessionId: sessionId,
driverKind: "codex_app_server",
driverVersion: "phase6-v1",
completionContractId: contractId,
completionContractSha256: contractSha,
nativePhase: "retryable_failure",
nativePhaseUpdatedAt: new Date("2026-08-09T04:00:00.000Z"),
contextSnapshot: { issueId, taskId: issueId, skipIssueComment: true },
});
await db.insert(nativeRunFinalizations).values({
runId,
companyId,
issueId,
phase: "retryable_failure",
attempt: 1,
failureCode: "native_session_interrupted",
nextAttemptAt: new Date(0),
});
}, 30_000);
afterAll(async () => {
if (
staleProviderProcess &&
staleProviderProcess.exitCode === null &&
staleProviderProcess.signalCode === null
) {
staleProviderProcess.kill("SIGKILL");
}
if (temporary) {
await drainHeartbeatRunsToQuiescence(db, heartbeatService(db, {
runtimeEnv: { PAPERCLIP_INSTANCE_ID: "phase6-recovery-test" },
nativeSessionBackendFactory: () => backend,
}));
await temporary.cleanup();
}
});
it("does not kill an unowned persisted PID, then recovers after it exits while flag-off", async () => {
legacyAdapterExecute.mockClear();
staleProviderProcess = spawn(
process.execPath,
["-e", "setInterval(() => {}, 1_000)"],
{ stdio: "ignore" },
);
await once(staleProviderProcess, "spawn");
expect(staleProviderProcess.pid).toEqual(expect.any(Number));
await db.update(heartbeatRuns).set({
processPid: staleProviderProcess.pid!,
processStartedAt: new Date("2026-08-09T04:00:00.000Z"),
}).where(eq(heartbeatRuns.id, runId));
const backendFactory = vi.fn(() => backend);
const heartbeat = heartbeatService(db, {
runtimeEnv: { PAPERCLIP_INSTANCE_ID: "phase6-recovery-test" },
nativeSessionBackendFactory: backendFactory,
});
await expect(heartbeat.reapOrphanedRuns({ staleThresholdMs: 0 })).resolves.not.toContain(runId);
await heartbeat.drainActiveRunExecutions();
expect(backendFactory).not.toHaveBeenCalled();
expect(recoverSession).not.toHaveBeenCalled();
expect(() => process.kill(staleProviderProcess!.pid!, 0)).not.toThrow();
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toEqual([
expect.objectContaining({
id: runId,
status: "running",
processPid: staleProviderProcess.pid,
errorCode: "native_execution_ownership_unverified",
}),
]);
await expect(db.select().from(nativeRunFinalizations).where(eq(
nativeRunFinalizations.runId,
runId,
))).resolves.toEqual([
expect.objectContaining({
phase: "retryable_failure",
attempt: 1,
leaseOwner: null,
}),
]);
const unrelatedProcessExit = once(staleProviderProcess, "exit");
staleProviderProcess.kill("SIGKILL");
await unrelatedProcessExit;
await db.update(nativeRunFinalizations).set({
leaseOwner: null,
leaseExpiresAt: null,
}).where(eq(nativeRunFinalizations.runId, runId));
await expect(heartbeat.reapOrphanedRuns({ staleThresholdMs: 0 })).resolves.not.toContain(runId);
await heartbeat.drainActiveRunExecutions();
const recoveryState = {
run: await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)),
coordinator: await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)),
};
expect(
backendFactory.mock.calls.length,
JSON.stringify(recoveryState),
).toBe(1);
expect(recoverSession).toHaveBeenCalledOnce();
expect(openSession).not.toHaveBeenCalled();
expect(startTurn).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledOnce();
expect(legacyAdapterExecute).not.toHaveBeenCalled();
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).resolves.toEqual([
expect.objectContaining({
id: runId,
runtimeMode: "native",
status: "succeeded",
nativePhase: "committed",
processPid: null,
processGroupId: null,
processStartedAt: null,
}),
]);
await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId))).resolves.toHaveLength(1);
await expect(db.select().from(workAssessments).where(eq(workAssessments.runId, runId))).resolves.toHaveLength(1);
const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, issueId));
expect(decisions).toEqual([
expect.objectContaining({ reasonCode: "completion_contract_satisfied", toStatus: "done", applicationState: "applied" }),
]);
const effects = await db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.issueId, issueId));
expect(new Set(effects.map((effect) => effect.decisionId))).toEqual(new Set([decisions[0]!.id]));
expect(effects.map((effect) => effect.effectKind).sort()).toEqual(["issue_status_projection", "release_checkout"]);
await expect(db.select().from(issues).where(eq(issues.id, issueId))).resolves.toEqual([
expect.objectContaining({
status: "done",
statusVersion: 1,
lastStatusDecisionId: decisions[0]!.id,
executionWorkspaceId: newerExecutionWorkspaceId,
}),
]);
await expect(db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, companyId)))
.resolves.toHaveLength(2);
await expect(db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId))).resolves.toEqual([
expect.objectContaining({ phase: "committed", resultId: expect.any(String), assessmentId: expect.any(String), decisionId: decisions[0]!.id }),
]);
await expect(db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId))).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ eventType: "turn.completed" }),
expect.objectContaining({ eventType: "run.result.accepted" }),
expect.objectContaining({ eventType: "run.terminal" }),
]),
);
await expect(db.select().from(activityLog).where(eq(activityLog.entityId, issueId))).resolves.toEqual(
expect.arrayContaining([expect.objectContaining({ action: "issue.updated" })]),
);
await heartbeat.reapOrphanedRuns({ staleThresholdMs: 0 });
await heartbeat.drainActiveRunExecutions();
expect(backendFactory).toHaveBeenCalledOnce();
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).resolves.toHaveLength(1);
await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId))).resolves.toHaveLength(1);
await expect(db.select().from(workAssessments).where(eq(workAssessments.runId, runId))).resolves.toHaveLength(1);
await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, issueId))).resolves.toHaveLength(1);
// The persisted Paperclip Runner run above remains recoverable while the
// flag is off. Switching the agent back to a direct adapter now proves a
// fresh run ignores the stale native profile and stays on the legacy path.
await db
.update(agents)
.set({ adapterType: "codex_local" })
.where(eq(agents.id, agentId));
await db.insert(issues).values({
id: freshIssueId,
companyId,
projectId,
projectWorkspaceId,
issueNumber: 2,
identifier: "NRR-2",
title: "Start only after the native kill switch is off",
status: "in_progress",
assigneeAgentId: agentId,
workMode: "standard",
});
const fresh = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId: freshIssueId },
contextSnapshot: { issueId: freshIssueId, taskId: freshIssueId, skipIssueComment: true },
});
expect(fresh).not.toBeNull();
await drainHeartbeatRunsToQuiescence(db, heartbeat);
expect(legacyAdapterExecute).toHaveBeenCalledOnce();
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fresh!.id))).resolves.toEqual([
expect.objectContaining({
agentId,
runtimeMode: "legacy",
runtimeModeReason: "direct_adapter",
status: "succeeded",
}),
]);
await expect(db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, fresh!.id))).resolves.toHaveLength(0);
await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, fresh!.id))).resolves.toHaveLength(0);
await expect(db.select({ runtimeConfig: agents.runtimeConfig }).from(agents).where(eq(agents.id, agentId))).resolves.toEqual([
expect.objectContaining({
runtimeConfig: expect.objectContaining({
nativeRunner: { mode: "native", backend: "codex_app_server", protocolVersion: 1 },
}),
}),
]);
}, 30_000);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,100 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { createDb } from "@paperclipai/db";
import {
CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS,
CAPABILITY_SEMANTIC_CONFORMANCE_IDS,
CapabilityMockSemanticConformanceAdapter,
runSemanticConformanceKit,
} from "../vendor/paperclip-runner/testing.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
PaperclipProductionSemanticConformanceAdapter,
seedPaperclipSemanticConformance,
type PaperclipSemanticConformanceIds,
} from "./helpers/paperclip-semantic-conformance.js";
vi.hoisted(() => {
process.env.PAPERCLIP_HOME = "/tmp/paperclip-semantic-conformance-home";
process.env.PAPERCLIP_INSTANCE_ID = "semantic-conformance";
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-semantic-conformance-home/logs";
process.env.PAPERCLIP_IN_WORKTREE = "false";
});
const embeddedSupport = await getEmbeddedPostgresTestSupport();
const describeEmbedded = embeddedSupport.supported ? describe : describe.skip;
if (!embeddedSupport.supported) {
console.warn(`Skipping semantic production conformance: ${embeddedSupport.reason ?? "unsupported host"}`);
}
describeEmbedded("Paperclip semantic mock/production conformance", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let mock: CapabilityMockSemanticConformanceAdapter | null = null;
const base = CAPABILITY_SEMANTIC_CONFORMANCE_IDS;
const ids: PaperclipSemanticConformanceIds = {
companyId: base.companyId,
actorId: base.actorId,
foreignCompanyId: "20000000-0000-4000-8000-000000000002",
foreignTaskId: base.foreignTaskId,
blockerTaskId: base.blockerTaskId,
worlds: {
default: { taskId: base.defaultTaskId, runId: base.defaultRunId, capabilities: [] },
"cross-company": {
taskId: base.crossCompanyTaskId,
runId: base.crossCompanyRunId,
capabilities: ["dependencies:write"],
},
interaction: { taskId: base.interactionTaskId, runId: base.interactionRunId, capabilities: [] },
"terminal-blocked": {
taskId: base.blockedTerminalTaskId,
runId: base.blockedTerminalRunId,
capabilities: [],
},
terminal: { taskId: base.terminalTaskId, runId: base.terminalRunId, capabilities: [] },
},
};
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("paperclip-semantic-conformance-");
const db = createDb(temporary.connectionString);
await seedPaperclipSemanticConformance(db, ids);
mock = await CapabilityMockSemanticConformanceAdapter.create();
}, 30_000);
afterAll(async () => {
await mock?.stop();
await temporary?.cleanup();
});
it("matches authorization, state, audit, retry, document, continuation, and terminal semantics", async () => {
if (!temporary || !mock) throw new Error("semantic_conformance_fixture_not_started");
const production = await PaperclipProductionSemanticConformanceAdapter.create(
createDb(temporary.connectionString),
ids,
);
const report = await runSemanticConformanceKit({
vectors: CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS,
adapters: [mock, production],
});
expect(report.rows).toHaveLength(CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS.length);
expect(report.rows.every((row) => row.adapterIds.join(",") === "capability-mock,paperclip-production-services"))
.toBe(true);
expect(report.rows.find((row) => row.vectorId === "progress-duplicate-retry")?.observation.audit)
.toEqual([]);
expect(report.rows.find((row) => row.vectorId === "document-stale-revision")?.observation.authorization)
.toEqual({ outcome: "denied", code: "document_revision_conflict" });
expect(report.rows.find((row) => row.vectorId === "continuation-request")?.observation.state)
.toMatchObject({ interactions: [{ continuationPolicy: "wake_assignee" }] });
expect(report.rows.find((row) => row.vectorId === "terminal-finish")?.observation.state)
.toMatchObject({ task: { status: "done" } });
expect(report.rows.every((row) => row.observation.receipt?.operationReceiptPresent === true))
.toBe(true);
}, 30_000);
});

View File

@ -182,6 +182,9 @@ describe("plugin worker manager duplex channel route", () => {
await handle.start();
const session = await handle.openDuplexChannel(
duplexOpenInput({
// Batch the exit with the open response to exercise the pre-bind hold
// and prove its normalized representation retains the discriminator.
batchWithOpenReply: true,
workerSessionId: "ws-A",
data: [{ chunk: "one" }],
// The worker reports a reason-less transport close with no exit code.
@ -360,7 +363,7 @@ describe("plugin worker manager duplex channel route", () => {
// The five explicit bounds. Each bound ends the route when it is exceeded.
// -------------------------------------------------------------------------
it("ends the route when the pre-bind buffered bytes pass the bound", async () => {
it("ends the route when the post-bind buffered bytes pass the bound", async () => {
const handle = makeDuplexHandle({
duplexChannelLimits: { maxPreBindBufferedChars: 10 },
});
@ -368,6 +371,10 @@ describe("plugin worker manager duplex channel route", () => {
await handle.start();
const session = await handle.openDuplexChannel(
duplexOpenInput({
// Hold these frames until the fixture acknowledges a host write. A
// write can only come from the returned session, so this makes the
// post-bind path deterministic instead of depending on pipe batching.
emitScriptedFramesAfterFirstWrite: true,
data: [
{ chunk: "aaaaa" }, // total 5 → buffered
{ chunk: "bbbbb" }, // total 10 → buffered
@ -375,8 +382,10 @@ describe("plugin worker manager duplex channel route", () => {
],
}),
);
// No listener attaches, so the data buffers. The cumulative bytes pass the
// bound and the route ends. The login wait resolves with a null exit code.
session.write(new TextEncoder().encode("emit"));
// No listener attaches, so the post-bind data buffers. The cumulative bytes
// pass the bound and the route ends. The channel wait resolves with a null
// exit code.
await expect(session.wait()).resolves.toEqual({ exitCode: null });
} finally {
await handle.stop().catch(() => undefined);

View File

@ -8,6 +8,8 @@ import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
import {
buildSandboxNpmInstallCommand,
getAdapterSessionManagement,
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
resolvePaperclipRunnerPermissionMode,
} from "@paperclipai/adapter-utils";
import type { AdapterLoginCapability } from "@paperclipai/adapter-utils";
import {
@ -379,6 +381,48 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
};
},
async testEnvironment(context) {
const configuredProvider = context.config.provider ?? "codex";
if (configuredProvider !== "codex") {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt: new Date().toISOString(),
checks: [{
code: "paperclip_runner_provider_unsupported",
level: "error" as const,
message: "Paperclip Runner currently supports only the Codex provider.",
}],
};
}
if (context.executionTarget?.kind === "remote") {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt: new Date().toISOString(),
checks: [{
code: "paperclip_runner_environment_unsupported",
level: "error" as const,
message: "Paperclip Runner currently requires a local execution environment.",
}],
};
}
const configuredPermission = context.config.codexPermissionMode;
if (
configuredPermission !== undefined
&& resolvePaperclipRunnerPermissionMode("codex", configuredPermission)
!== configuredPermission
) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt: new Date().toISOString(),
checks: [{
code: "runner_permission_mode_invalid",
level: "error" as const,
message: "codexPermissionMode is not supported by Codex.",
}],
};
}
const result = await codexTestEnvironment(context);
return { ...result, adapterType: "paperclip_runner" };
},
@ -386,6 +430,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
syncSkills: syncCodexSkills,
sessionCodec: codexSessionCodec,
models: codexModels,
modelProfiles: codexModelProfiles,
listModels: listCodexModels,
refreshModels: refreshCodexModels,
supportsLocalAgentJwt: false,
@ -394,7 +439,38 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
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 transport.\n",
getConfigSchema: getCodexConfigSchema,
getConfigSchema: () => ({
fields: [
{
key: "codexPermissionMode",
label: "Codex permission mode",
type: "select" as const,
default: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.defaultMode,
options: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.options.map(
({ value, label }) => ({ value, label }),
),
hint: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.description,
},
{
key: "lifecycleMode",
label: "Runner lifecycle",
type: "select" as const,
default: "per_turn",
options: [
{ value: "per_turn", label: "Turn by turn" },
{ value: "warm", label: "Warm session" },
],
hint: "Warm sessions retain runnerd and Codex between governed runs.",
},
{
key: "idleTimeoutMs",
label: "Warm idle timeout (ms)",
type: "number" as const,
default: 300_000,
hint: "Warm sessions suspend after this much inactivity.",
},
],
}),
loginCapability: codexLoginCapability,
};

View File

@ -0,0 +1,70 @@
import { and, eq, inArray, isNull } from "drizzle-orm";
import {
closeRegisteredClients,
createDb,
heartbeatRuns,
nativeRunFinalizations,
} from "@paperclipai/db";
import { resolveMigrationConnection } from "@paperclipai/db/migration-runtime";
import { instanceSettingsService } from "./services/instance-settings.js";
async function main(): Promise<void> {
const connection = await resolveMigrationConnection();
const db = createDb(connection.connectionString, { maxConnections: 1 });
try {
const experimental = await instanceSettingsService(db).getExperimental();
const persistedActiveNativeRun = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.runtimeMode, "native"),
inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]),
),
)
.limit(1)
.then((rows) => rows.length > 0);
const persistedRetryableFailedNativeRun = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.innerJoin(
nativeRunFinalizations,
eq(nativeRunFinalizations.runId, heartbeatRuns.id),
)
.where(
and(
eq(heartbeatRuns.runtimeMode, "native"),
eq(heartbeatRuns.status, "failed"),
eq(nativeRunFinalizations.phase, "retryable_failure"),
isNull(nativeRunFinalizations.resultId),
),
)
.limit(1)
.then((rows) => rows.length > 0);
const persistedNativeRun =
persistedActiveNativeRun || persistedRetryableFailedNativeRun;
console.log(
JSON.stringify({
nativeRunnerRequired:
experimental.enableNativeRunner === true || persistedNativeRun,
rolloutEnabled: experimental.enableNativeRunner === true,
persistedNativeRun,
persistedActiveNativeRun,
persistedRetryableFailedNativeRun,
}),
);
} finally {
await closeRegisteredClients(connection.connectionString);
await connection.stop();
}
}
main().catch((error) => {
const message =
error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});

View File

@ -121,6 +121,10 @@ import {
readPendingNativeRuntimeRequest,
type NativeRuntimeRequestResolver,
} from "../services/native-runtime/runtime-request-resolution-authority.js";
import {
NativeRuntimeRequestResolutionError,
resolveNativeRuntimeRequest,
} from "../services/native-runtime/native-session-executor.js";
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
import {
instanceSettingsService,
@ -1673,6 +1677,19 @@ export function agentRoutes(
);
}
function assertFreshPaperclipRunnerProvider(
adapterType: string,
adapterConfig: Record<string, unknown>,
): void {
if (adapterType !== "paperclip_runner") return;
const provider = adapterConfig.provider;
if (provider === undefined || provider === "codex") return;
throw unprocessable(
"Paperclip Runner currently supports Codex for new or changed agent configurations.",
{ code: "paperclip_runner_provider_unavailable" },
);
}
async function assertAgentDefaultEnvironmentSelection(
companyId: string,
environmentId: string | null | undefined,
@ -3477,6 +3494,36 @@ export function agentRoutes(
if (!existing) return;
await assertCanUpdateAgent(req, existing);
const revision = await svc.getConfigRevision(id, revisionId);
if (!revision) {
res.status(404).json({ error: "Revision not found" });
return;
}
const rollbackConfig = asRecord(revision.afterConfig);
if (!rollbackConfig) {
throw unprocessable("Invalid revision snapshot");
}
const rollbackAdapterType = assertKnownAdapterType(
typeof rollbackConfig.adapterType === "string"
? rollbackConfig.adapterType
: null,
);
if (rollbackAdapterType !== existing.adapterType) {
await assertSelectableAdapterType(rollbackAdapterType);
}
const rollbackAdapterConfig = asRecord(rollbackConfig.adapterConfig) ?? {};
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
if (
rollbackAdapterType !== existing.adapterType ||
(rollbackAdapterType === "paperclip_runner" &&
rollbackAdapterConfig.provider !== existingAdapterConfig.provider)
) {
assertFreshPaperclipRunnerProvider(
rollbackAdapterType,
rollbackAdapterConfig,
);
}
const actor = getActorInfo(req);
const updated = await svc.rollbackConfigRevision(id, revisionId, {
agentId: actor.agentId,
@ -3576,6 +3623,10 @@ export function agentRoutes(
} = req.body;
hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType);
const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
assertFreshPaperclipRunnerProvider(
hireInput.adapterType,
rawHireAdapterConfig,
);
assertNoNewAgentLegacyPromptTemplate(
hireInput.adapterType,
rawHireAdapterConfig,
@ -3796,6 +3847,10 @@ export function agentRoutes(
} = req.body;
createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType);
const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
assertFreshPaperclipRunnerProvider(
createInput.adapterType,
rawCreateAdapterConfig,
);
assertNoNewAgentLegacyPromptTemplate(
createInput.adapterType,
rawCreateAdapterConfig,
@ -4287,6 +4342,20 @@ export function agentRoutes(
rawEffectiveAdapterConfig,
);
}
const existingRunnerProvider =
existing.adapterType === "paperclip_runner"
? existingAdapterConfig.provider
: undefined;
if (
changingAdapterType ||
(requestedAdapterType === "paperclip_runner" &&
rawEffectiveAdapterConfig.provider !== existingRunnerProvider)
) {
assertFreshPaperclipRunnerProvider(
requestedAdapterType,
rawEffectiveAdapterConfig,
);
}
const effectiveAdapterConfig = applyCodexLocalKeyIsolation(
existing.companyId,
existing.id,
@ -5665,13 +5734,71 @@ export function agentRoutes(
) {
throw conflict("This runtime request is stale or is no longer pending.");
}
const queued = queueRunnerPrpRuntimeRequestResolution({
companyId: existing.companyId,
runId,
pendingRequest: currentPendingRequest,
actor: resolutionActor,
resolution,
});
let queued: { commandId: string };
try {
queued = await resolveNativeRuntimeRequest({
runId,
requestId,
turnId: currentPendingRequest.turnId,
resolution,
authorizeBeforeDispatch: async () => {
const dispatchPendingRequest =
await readPendingNativeRuntimeRequest(db, {
companyId: existing.companyId,
runId,
requestId,
});
if (
!dispatchPendingRequest
|| dispatchPendingRequest.requestKind !==
currentPendingRequest.requestKind
|| dispatchPendingRequest.turnId !==
currentPendingRequest.turnId
) {
throw conflict(
"This runtime request is stale or is no longer pending.",
);
}
assertNativeRuntimeRequestResolverAuthorized(
dispatchPendingRequest,
resolutionActor,
);
},
});
} catch (error) {
if (
error instanceof NativeRuntimeRequestResolutionError &&
error.code === "runtime_request_resolution_conflict"
) {
throw conflict(
"A different response was already submitted for this runtime request.",
);
}
if (
!(error instanceof NativeRuntimeRequestResolutionError) ||
![
"native_session_not_active",
"runtime_request_resolution_unsupported",
].includes(error.code)
) {
if (
error instanceof NativeRuntimeRequestResolutionError &&
error.code === "runtime_request_stale_turn"
) {
throw conflict(
"The runner session is no longer accepting runtime responses.",
);
}
throw error;
}
queued = queueRunnerPrpRuntimeRequestResolution({
companyId: existing.companyId,
runId,
pendingRequest: currentPendingRequest,
actor: resolutionActor,
resolution,
});
}
await logActivity(db, {
companyId: existing.companyId,
actorType: "user",

View File

@ -3566,6 +3566,16 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
adapterType: string,
adapterConfig: Record<string, unknown>,
) {
if (adapterType === "paperclip_runner") {
const provider = adapterConfig.provider ?? "codex";
if (provider !== "codex") {
throw unprocessable(
"Imported Paperclip Runner agents currently support only the Codex provider.",
{ code: "paperclip_runner_provider_unavailable" },
);
}
return;
}
if (adapterType !== "opencode_local") return;
try {
requireOpenCodeModelId(adapterConfig.model);

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,9 @@
export * from "./runtime-mode.js";
export * from "./completion-contracts.js";
export * from "./native-execution-input.js";
export * from "./runtime-context.js";
export * from "./native-session-executor.js";
export * from "./native-session-resume.js";
export * from "./native-interaction-bridge.js";
export * from "./paperclip-control-plane-port.js";
export * from "./native-run-finalizer.js";

View File

@ -0,0 +1,107 @@
import type {
NativeCodexApprovalPolicy,
NativeExecutionInputV4,
NativeInteractionResponseEnvelope,
NativePlanningContext,
NativeRuntimeContextSnapshot,
StrictCompletionContractInput,
} from "../../vendor/paperclip-runner/index.js";
import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
/** Closed constructor: callers cannot spread legacy context or environment data. */
export function buildNativeExecutionInput(input: {
companyId: string;
runId: string;
issue: {
id: string;
identifier: string | null;
title: string;
description: string | null;
workMode: string;
};
taskPrompt: string;
/**
* The already-sanitized Paperclip wake envelope for this run. Native drivers
* receive a closed execution input rather than the legacy adapter context,
* so the constructor must deliberately project the same bounded wake delta
* that legacy adapters place in their provider prompt.
*/
wakePayload?: unknown;
resumedSession?: boolean;
agentId: string;
workspace: {
id: string;
cwd: string;
repoUrl: string | null;
repoRef: string | null;
branchName: string | null;
};
normalizedSessionId: string | null;
codexApprovalPolicy?: NativeCodexApprovalPolicy;
model?: string | null;
lifecyclePolicy?: NativeExecutionInputV4["session"]["lifecyclePolicy"];
executionMode?: "default" | "plan";
planningContext?: NativePlanningContext | null;
interactionResponses?: NativeInteractionResponseEnvelope[];
completionContract: {
id: string;
sha256: string;
schemaVersion: string;
contract: StrictCompletionContractInput;
};
runtimeContext: NativeRuntimeContextSnapshot;
}): NativeExecutionInputV4 {
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") {
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask");
}
const executionMode = input.executionMode
?? (input.issue.workMode === "planning" ? "plan" : "default");
const wakePrompt = renderPaperclipWakePrompt(input.wakePayload, {
resumedSession: input.resumedSession === true,
suppressIssueDescription: input.taskPrompt.trim().length > 0,
});
const taskPrompt = [wakePrompt, input.taskPrompt.trim()]
.filter((section) => section.length > 0)
.join("\n\n");
return parseNativeExecutionInput({
schema: "paperclip.native-execution-input.v4",
executionMode,
planningContext: input.planningContext ?? null,
binding: {
companyId: input.companyId,
runId: input.runId,
issueId: input.issue.id,
agentId: input.agentId,
executionWorkspaceId: input.workspace.id,
},
task: {
identifier: input.issue.identifier ?? input.issue.id,
title: input.issue.title,
description: input.issue.description,
prompt: taskPrompt,
workMode: input.issue.workMode,
},
workspace: {
cwd: input.workspace.cwd,
repoUrl: input.workspace.repoUrl,
repoRef: input.workspace.repoRef,
branchName: input.workspace.branchName,
},
session: {
normalizedSessionId: input.normalizedSessionId,
driverKind: "codex_app_server",
protocolVersion: 1,
lifecyclePolicy: input.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
},
provider: {
kind: "codex",
model: input.model ?? null,
approvalPolicy: input.codexApprovalPolicy ?? "never",
},
completionContract: input.completionContract,
interactionResponses: input.interactionResponses ?? [],
credentialBindings: [],
runtimeContext: input.runtimeContext,
}) as NativeExecutionInputV4;
}

View File

@ -11,6 +11,7 @@ import {
issueQuestionResponseDeliveries,
issueThreadInteractions,
issues,
nativeRunFinalizations,
} from "@paperclipai/db";
import type { PrpEvent } from "@paperclipai/paperclip-runner";
@ -105,6 +106,7 @@ describeEmbeddedPostgres("native question bridge", () => {
title: "Answer a native question",
status: "in_progress",
assigneeAgentId: agentId,
responsibleUserId: "operator-1",
});
await db.insert(heartbeatRuns).values({
id: runId,
@ -119,6 +121,12 @@ describeEmbeddedPostgres("native question bridge", () => {
driverKind: "codex",
contextSnapshot: { issueId },
});
await db.insert(nativeRunFinalizations).values({
runId,
companyId,
issueId,
phase: "observed",
});
}
function runtimeRequestEvent(): PrpEvent {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,280 @@
import { describe, expect, it } from "vitest";
import { canonicalNativeRuntimeContextDigest } from "../../vendor/paperclip-runner/index.js";
import { buildNativeExecutionInput } from "./native-execution-input.js";
import { rebindNativeSessionCheckpoint } from "./native-session-resume.js";
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
const companyId = "10000000-0000-4000-8000-000000000001";
const issueId = "20000000-0000-4000-8000-000000000002";
const agentId = "30000000-0000-4000-8000-000000000003";
const previousRunId = "40000000-0000-4000-8000-000000000004";
const currentRunId = "50000000-0000-4000-8000-000000000005";
const normalizedSessionId = "60000000-0000-4000-8000-000000000006";
function execution(runId: string, cwd = "/workspace") {
return buildNativeExecutionInput({
companyId,
runId,
issue: { id: issueId, identifier: "DOT-2", title: "Test", description: null, workMode: "standard" },
taskPrompt: "Only the current turn",
agentId,
workspace: { id: runId, cwd, repoUrl: null, repoRef: null, branchName: null },
normalizedSessionId,
completionContract: {
id: "70000000-0000-4000-8000-000000000007",
sha256: `sha256:${"a".repeat(64)}`,
schemaVersion: "paperclip.run-result.v1",
contract: {
revision: "1",
objective: "Test session resumption",
criteria: [{ id: "objective", requirement: "Answer the current turn" }],
},
},
runtimeContext: nativeRuntimeContextFixture(),
});
}
function planningExecution(runId: string, revisionId: string) {
return buildNativeExecutionInput({
companyId,
runId,
issue: { id: issueId, identifier: "DOT-2", title: "Test", description: null, workMode: "planning" },
taskPrompt: "Revise the plan",
agentId,
workspace: { id: runId, cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
normalizedSessionId,
executionMode: "plan",
planningContext: {
documentId: "80000000-0000-4000-8000-000000000008",
baseRevisionId: revisionId,
baseRevisionNumber: revisionId.endsWith("9") ? 9 : 8,
markdown: `Plan at ${revisionId}`,
sha256: `sha256:${"b".repeat(64)}`,
reviewContext: {},
},
completionContract: execution(runId).completionContract,
runtimeContext: nativeRuntimeContextFixture(),
});
}
function previousRun(overrides: Record<string, unknown> = {}) {
return {
id: previousRunId,
companyId,
agentId,
nativeSessionId: normalizedSessionId,
runnerProfileJson: {
nativeExecutionInput: execution(previousRunId),
sessionCheckpoint: {
backendKind: "runner",
driverKind: "codex_app_server",
sessionId: "provider-thread-123",
providerSessionId: "provider-thread-123",
cursor: "42",
identity: { runId: previousRunId, sessionId: normalizedSessionId, companyId, issueId, agentId },
semanticResult: { schema: "paperclip.run-result.v1", reportedWorkDisposition: "done", summary: "old" },
terminal: { schema: "paperclip.prp.terminal.v1", turnTerminalState: "completed", runTerminalState: "succeeded", reportedWorkDisposition: "done" },
activeTurnId: "old-turn",
terminalTurns: [{ turnId: "old-turn", state: "completed" }],
pendingRuntimeRequests: [{ requestId: "old-request" }],
lineage: [{ threadId: "provider-thread-123" }],
},
...overrides,
},
};
}
describe("rebindNativeSessionCheckpoint", () => {
it("retains provider identity but clears prior turn and event state", () => {
const rebound = rebindNativeSessionCheckpoint({
previousRun: previousRun(),
currentExecution: execution(currentRunId),
});
expect(rebound).toMatchObject({
sessionId: "provider-thread-123",
providerSessionId: "provider-thread-123",
driverKind: "codex_app_server",
cursor: null,
semanticResult: null,
terminal: null,
activeTurnId: null,
terminalTurns: [],
pendingRuntimeRequests: [],
providerRecoveryPolicy: "allow_replacement_after_resume_failure",
identity: { runId: currentRunId, sessionId: normalizedSessionId, companyId, issueId, agentId },
});
});
it("allows a provider replacement only after a durable response wake", () => {
const source = previousRun();
const profile = source.runnerProfileJson as Record<string, unknown>;
const checkpoint = profile.sessionCheckpoint as Record<string, unknown>;
checkpoint.semanticResult = {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: "yielded",
summary: "Waiting for a response.",
continuation: {
kind: "response_wake",
idempotencyKey: "interaction-response:one",
},
};
expect(
rebindNativeSessionCheckpoint({
previousRun: source,
currentExecution: execution(currentRunId),
}),
).toMatchObject({
providerRecoveryPolicy: "allow_replacement_after_governed_wait",
semanticResult: null,
activeTurnId: null,
});
});
it("refuses to resume when the workspace changes", () => {
expect(rebindNativeSessionCheckpoint({
previousRun: previousRun(),
currentExecution: execution(currentRunId, "/different-workspace"),
})).toBeNull();
});
it("rotates when assigned context changes but permits a fresh run-scoped MCP binding", () => {
const reboundCredential = execution(currentRunId);
reboundCredential.runtimeContext.mcp.bindingId = "native-mcp:fresh-run";
expect(rebindNativeSessionCheckpoint({
previousRun: previousRun(),
currentExecution: reboundCredential,
})).not.toBeNull();
const changedAssignment = execution(currentRunId);
const withoutAggregate = {
prompt: changedAssignment.runtimeContext.prompt,
instructions: changedAssignment.runtimeContext.instructions,
skills: changedAssignment.runtimeContext.skills,
mcp: {
assignmentSetId: `sha256:${"1".repeat(64)}`,
digest: "1".repeat(64),
bindingId: "native-mcp:fresh-run",
},
};
changedAssignment.runtimeContext = {
...withoutAggregate,
aggregateDigest: canonicalNativeRuntimeContextDigest(withoutAggregate),
};
expect(rebindNativeSessionCheckpoint({
previousRun: previousRun(),
currentExecution: changedAssignment,
})).toBeNull();
});
it("refuses a checkpoint whose prior-run binding was rewritten", () => {
const source = previousRun();
const profile = source.runnerProfileJson as Record<string, unknown>;
const checkpoint = profile.sessionCheckpoint as Record<string, unknown>;
checkpoint.identity = { ...(checkpoint.identity as Record<string, unknown>), runId: currentRunId };
expect(rebindNativeSessionCheckpoint({ previousRun: source, currentExecution: execution(currentRunId) })).toBeNull();
});
it("reuses plan mode across canonical revisions but never across a mode change", () => {
const source = previousRun({ nativeExecutionInput: planningExecution(previousRunId, "revision-8") });
expect(rebindNativeSessionCheckpoint({
previousRun: source,
currentExecution: planningExecution(currentRunId, "revision-9"),
})).not.toBeNull();
expect(rebindNativeSessionCheckpoint({
previousRun: source,
currentExecution: execution(currentRunId),
})).toBeNull();
});
});
describe("buildNativeExecutionInput wake projection", () => {
it("writes native v4 and pins the complete Codex configuration", () => {
const common = {
companyId,
runId: currentRunId,
issue: { id: issueId, identifier: "DOT-4", title: "Permissions", description: null, workMode: "standard" },
taskPrompt: "Verify permissions",
agentId,
workspace: { id: currentRunId, cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
normalizedSessionId,
completionContract: execution(currentRunId).completionContract,
runtimeContext: nativeRuntimeContextFixture(),
} as const;
const codex = buildNativeExecutionInput({
...common,
codexApprovalPolicy: "on-request",
});
expect(codex).toMatchObject({
schema: "paperclip.native-execution-input.v4",
provider: { kind: "codex", approvalPolicy: "on-request" },
});
expect(JSON.stringify(codex))
.not.toMatch(/OPENAI_API_KEY|ANTHROPIC_API_KEY|AWS_SECRET_ACCESS_KEY|PAPERCLIP_API_KEY/);
});
it("places child completion summaries in the closed provider prompt", () => {
const input = buildNativeExecutionInput({
companyId,
runId: currentRunId,
issue: {
id: issueId,
identifier: "DOT-146",
title: "Finish after child handoff",
description: "Use the child result.",
workMode: "standard",
},
taskPrompt: "Paperclip task context:\n- Issue: DOT-146",
wakePayload: {
reason: "issue_children_completed",
issue: {
id: issueId,
identifier: "DOT-146",
title: "Finish after child handoff",
description: "Use the child result.",
status: "in_progress",
priority: "medium",
workMode: "standard",
},
childIssueSummaries: [{
id: "child-147",
identifier: "DOT-147",
title: "Build utility",
status: "done",
summary: "Created three files and passed 7/7 tests.",
}],
childIssueSummaryTruncated: false,
checkedOutByHarness: true,
},
resumedSession: true,
agentId,
workspace: {
id: currentRunId,
cwd: "/workspace",
repoUrl: null,
repoRef: null,
branchName: null,
},
normalizedSessionId,
completionContract: {
id: "70000000-0000-4000-8000-000000000007",
sha256: `sha256:${"a".repeat(64)}`,
schemaVersion: "paperclip.run-result.v1",
contract: {
revision: "1",
objective: "Finish after the child",
criteria: [{ id: "objective", requirement: "Report the child result" }],
},
},
runtimeContext: nativeRuntimeContextFixture(),
});
expect(input.task.prompt).toContain("## Paperclip Resume Delta");
expect(input.task.prompt).toContain("reason: issue_children_completed");
expect(input.task.prompt).toContain("DOT-147 Build utility (done)");
expect(input.task.prompt).toContain("Created three files and passed 7/7 tests.");
expect(input.task.prompt).toContain("Paperclip task context:\n- Issue: DOT-146");
expect(input.task.prompt).not.toContain("Use the child result.");
});
});

View File

@ -0,0 +1,105 @@
import type {
NativeExecutionInput,
PersistedNativeSession,
} from "../../vendor/paperclip-runner/index.js";
import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
export function isNativeSessionId(value: unknown): value is string {
return typeof value === "string"
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
function sameProvider(
previous: NativeExecutionInput["provider"],
current: NativeExecutionInput["provider"],
): boolean {
return JSON.stringify(previous) === JSON.stringify(current);
}
/**
* Rebind a completed prior run's provider checkpoint to a new heartbeat run.
* The provider/driver session identity is retained, while every per-turn and
* per-event field is reset so the new run starts one clean turn via resume.
*/
export function rebindNativeSessionCheckpoint(input: {
previousRun: {
id: string;
companyId: string;
agentId: string;
nativeSessionId: string | null;
runnerProfileJson: unknown;
};
currentExecution: NativeExecutionInput;
}): PersistedNativeSession | null {
const previousProfile = record(input.previousRun.runnerProfileJson);
const rawCheckpoint = record(previousProfile.sessionCheckpoint);
const checkpointIdentity = record(rawCheckpoint.identity);
const current = input.currentExecution;
const normalizedSessionId = current.session.normalizedSessionId;
if (
!isNativeSessionId(normalizedSessionId)
|| input.previousRun.companyId !== current.binding.companyId
|| input.previousRun.agentId !== current.binding.agentId
|| input.previousRun.nativeSessionId !== normalizedSessionId
|| typeof rawCheckpoint.sessionId !== "string"
|| checkpointIdentity.runId !== input.previousRun.id
|| checkpointIdentity.companyId !== current.binding.companyId
|| checkpointIdentity.issueId !== current.binding.issueId
|| checkpointIdentity.agentId !== current.binding.agentId
|| checkpointIdentity.sessionId !== normalizedSessionId
) return null;
let previousExecution: NativeExecutionInput;
try {
previousExecution = parseNativeExecutionInput(previousProfile.nativeExecutionInput);
} catch {
return null;
}
if (
previousExecution.binding.runId !== input.previousRun.id
|| previousExecution.binding.companyId !== current.binding.companyId
|| previousExecution.binding.issueId !== current.binding.issueId
|| previousExecution.binding.agentId !== current.binding.agentId
|| previousExecution.session.normalizedSessionId !== normalizedSessionId
|| previousExecution.session.driverKind !== current.session.driverKind
|| previousExecution.workspace.cwd !== current.workspace.cwd
|| ("executionMode" in previousExecution ? previousExecution.executionMode : "default")
!== ("executionMode" in current ? current.executionMode : "default")
|| !sameProvider(previousExecution.provider, current.provider)
|| previousExecution.schema !== current.schema
|| ("runtimeContext" in previousExecution && "runtimeContext" in current
&& previousExecution.runtimeContext.aggregateDigest !== current.runtimeContext.aggregateDigest)
) return null;
const priorSemanticResult = record(rawCheckpoint.semanticResult);
const priorContinuation = record(priorSemanticResult.continuation);
const providerRecoveryPolicy =
priorSemanticResult.reportedWorkDisposition === "yielded"
&& priorContinuation.kind === "response_wake"
? "allow_replacement_after_governed_wait" as const
: "allow_replacement_after_resume_failure" as const;
return {
...(structuredClone(rawCheckpoint) as unknown as PersistedNativeSession),
identity: {
runId: current.binding.runId,
sessionId: normalizedSessionId,
companyId: current.binding.companyId,
issueId: current.binding.issueId,
agentId: current.binding.agentId,
},
cursor: null,
semanticResult: null,
terminal: null,
activeTurnId: null,
terminalTurns: [],
pendingRuntimeRequests: [],
providerRecoveryPolicy,
};
}

View File

@ -39,6 +39,7 @@ import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
import { finalizeNativeRun } from "./native-run-finalizer.js";
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { materializeRuntimeQuestionFallback } from "./native-session-executor.js";
describe("PaperclipControlPlanePort conformance", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
@ -278,17 +279,30 @@ describe("PaperclipControlPlanePort conformance", () => {
it("runs the unchanged package conformance suite against Paperclip persistence", async () => {
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
const port = new PaperclipControlPlanePort(db, {
companyId: identity.companyId,
issueId: identity.issueId,
runId: identity.runId,
agentId: identity.agentId,
sessionId: identity.sessionId,
completionContractId: contractId,
completionContractSha256: contractSha,
sourceInstanceId: conformanceRunnerId,
controlPlaneSourceInstanceId: "control-conformance",
});
const committedEventIds: string[] = [];
const duplicateEventIds: string[] = [];
const port = new PaperclipControlPlanePort(
db,
{
companyId: identity.companyId,
issueId: identity.issueId,
runId: identity.runId,
agentId: identity.agentId,
sessionId: identity.sessionId,
completionContractId: contractId,
completionContractSha256: contractSha,
sourceInstanceId: conformanceRunnerId,
controlPlaneSourceInstanceId: "control-conformance",
},
{
onCommittedEvent: async (event) => {
committedEventIds.push(event.sourceEventId);
},
onDuplicateEvent: async (event) => {
duplicateEventIds.push(event.sourceEventId);
},
},
);
await expect(runControlPlanePortConformance({ port })).resolves.toEqual({
eventCount: 3,
highestContiguousSourceSeq: 3,
@ -300,6 +314,14 @@ describe("PaperclipControlPlanePort conformance", () => {
replayBindingRejected: true,
resultMutationRejected: true,
});
expect(committedEventIds).toEqual([
"00000000-0000-4000-8000-000000000005:event:1",
"00000000-0000-4000-8000-000000000005:event:3",
"00000000-0000-4000-8000-000000000005:event:2",
]);
expect(duplicateEventIds).toEqual([
"00000000-0000-4000-8000-000000000005:event:2",
]);
await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, identity.runId))).resolves.toHaveLength(1);
await finalizeNativeRun({
db,
@ -324,6 +346,155 @@ describe("PaperclipControlPlanePort conformance", () => {
);
});
it("recovers a runtime question when the event commits before its callback", async () => {
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
const issueId = "40000000-0000-4000-8000-000000000041";
const runId = "41000000-0000-4000-8000-000000000041";
const sessionId = "42000000-0000-4000-8000-000000000041";
const runnerInstanceId = "43000000-0000-4000-8000-000000000041";
const localContractId = "44000000-0000-4000-8000-000000000041";
const contractSha256 = "runtime-question-recovery-contract";
await db.insert(issues).values({
id: issueId,
companyId: identity.companyId,
title: "Recover a committed runtime question",
status: "in_progress",
assigneeAgentId: identity.agentId,
workMode: "standard",
});
await db.insert(completionContracts).values({
id: localContractId,
companyId: identity.companyId,
issueId,
revision: 1,
schemaVersion: "paperclip.completion-contract.v1",
policyVersion: "phase6-v1",
risk: "standard",
completionAuthority: "server_arbiter",
incompleteCriteriaPolicy: "preserve_non_terminal",
contractJson: {
revision: "phase6-v1",
objective: "Recover a committed runtime question",
criteria: [{ id: "objective", requirement: "Recover the question" }],
},
canonicalSha256: contractSha256,
createdByActorType: "system",
createdByActorId: "test",
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId: identity.companyId,
agentId: identity.agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: issueId,
nativeSessionId: sessionId,
runnerInstanceId,
completionContractId: localContractId,
completionContractSha256: contractSha256,
contextSnapshot: { issueId },
});
const binding = {
companyId: identity.companyId,
issueId,
runId,
agentId: identity.agentId,
sessionId,
completionContractId: localContractId,
completionContractSha256: contractSha256,
sourceInstanceId: runnerInstanceId,
controlPlaneSourceInstanceId: "runtime-question-recovery-control",
};
const questionEvent: PrpEvent = {
schema: "paperclip.prp.event.v1",
sourceEventId: "runtime-question-recovery:1",
sourceSeq: 1,
sourceInstanceId: runnerInstanceId,
sourceKind: "runner",
runId,
normalizedSessionId: sessionId,
turnId: "runtime-question-recovery-turn",
eventType: "runtime_request.expired",
schemaVersion: 1,
priority: 0,
emittedAt: "2026-08-09T03:00:00.000Z",
payload: {
requestId: "runtime-question-recovery-request",
requestKind: "runtime",
requestType: "input",
reason: "provider_process_lost",
replayAllowed: false,
request: {
schema: "paperclip.runtime_request.v2",
requestKind: "runtime",
requestId: "runtime-question-recovery-request",
type: "input",
status: "pending",
prompt: "Choose a recovery option",
turnId: "runtime-question-recovery-turn",
itemId: "runtime-question-recovery-item",
input: {
schema: "paperclip.question_set.v1",
title: "Choose a recovery option",
questions: [
{
id: "recovery-option",
prompt: "Which option should recovery use?",
required: true,
answerMode: "single_select",
options: [
{ id: "safe", label: "Safe recovery" },
{ id: "fast", label: "Fast recovery" },
],
},
],
},
},
},
};
const port = new PaperclipControlPlanePort(db, binding, {
onCommittedEvent: async () => {
throw new Error("simulated_post_commit_crash");
},
onDuplicateEvent: async (event) => {
await materializeRuntimeQuestionFallback({ db, binding, event });
},
});
await port.openRun({
identity: { ...identity, issueId, runId, sessionId },
backendKind: "mock",
sourceInstanceId: runnerInstanceId,
});
await expect(port.appendEvent(questionEvent)).rejects.toThrow(
"simulated_post_commit_crash",
);
await expect(
db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.issueId, issueId)),
).resolves.toEqual([]);
await expect(port.appendEvent(questionEvent)).resolves.toMatchObject({
disposition: "duplicate",
});
await expect(
db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.issueId, issueId)),
).resolves.toEqual([
expect.objectContaining({
kind: "ask_user_questions",
status: "pending",
idempotencyKey: `runtime-input-durable:v1:${runId}:runtime-question-recovery-request`,
sourceRunId: runId,
}),
]);
});
it("completes one selected Paperclip task through the public package session contract", async () => {
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
const sessionId = taskSessionId;

View File

@ -69,13 +69,20 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
readonly #binding: PaperclipControlPlaneBinding;
#sessionId: string | null = null;
readonly #onCommittedEvent?: (event: PrpEvent) => Promise<void>;
readonly #onDuplicateEvent?: (event: PrpEvent) => Promise<void>;
constructor(db: Db, binding: PaperclipControlPlaneBinding, options: {
onCommittedEvent?: (event: PrpEvent) => Promise<void>;
} = {}) {
constructor(
db: Db,
binding: PaperclipControlPlaneBinding,
options: {
onCommittedEvent?: (event: PrpEvent) => Promise<void>;
onDuplicateEvent?: (event: PrpEvent) => Promise<void>;
} = {},
) {
this.#db = db;
this.#binding = structuredClone(binding);
this.#onCommittedEvent = options.onCommittedEvent;
this.#onDuplicateEvent = options.onDuplicateEvent;
}
#matchesPersistedBinding(run: typeof heartbeatRuns.$inferSelect): boolean {
@ -197,7 +204,15 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
canonicalPayload: event as unknown as Record<string, unknown>,
},
});
if (persisted.disposition === "committed") await this.#onCommittedEvent?.(event);
if (persisted.disposition === "committed") {
await this.#onCommittedEvent?.(event);
} else {
// A recovered runner may replay the event whose durable side effects
// parked the prior attempt. Do not repeat those effects, but let the
// embedding runtime refresh observational state before appendEvent
// returns to its synchronous governed-wait boundary.
await this.#onDuplicateEvent?.(event);
}
return {
cursor: persisted.row.seq,
highestContiguousSourceSeq: persisted.highestContiguousSourceSeq,

View File

@ -0,0 +1,221 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { eq } from "drizzle-orm";
import { agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createRunnerdCodexTransport,
defaultCapabilityRunnerdBinary,
} from "../../vendor/paperclip-runner/index.js";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import {
registerRunnerPrpAuthority,
runnerPrpWebSocketInternals,
setupRunnerPrpWebSocketServer,
} from "../../realtime/runner-prp-ws.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
const fakeCodexAppServer = resolve(
import.meta.dirname,
"../../../../packages/paperclip-runner/runner/target/debug/fake-codex-app-server",
);
const runnerBinariesAvailable =
existsSync(defaultCapabilityRunnerdBinary()) && existsSync(fakeCodexAppServer);
const runnerBinaryIt = runnerBinariesAvailable ? it : it.skip;
describe("paperclip-runner real server vertical slice", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
const companyId = "00000000-0000-4000-8000-000000000701";
const agentId = "00000000-0000-4000-8000-000000000702";
const issueId = "00000000-0000-4000-8000-000000000703";
const runId = "00000000-0000-4000-8000-000000000704";
const resumedRunId = "00000000-0000-4000-8000-000000000705";
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("paperclip-runner-real-server-");
});
afterAll(async () => {
runnerPrpWebSocketInternals.resetForTests();
await temporary.cleanup();
});
runnerBinaryIt("runs Rust runnerd through Paperclip PRP and reads the real bound task", async () => {
const db = createDb(temporary.connectionString);
await db.insert(companies).values({ id: companyId, name: "Real runner slice", issuePrefix: "RRS" });
await db.insert(agents).values({
id: agentId,
companyId,
name: "Real runner agent",
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex" },
runtimeConfig: {},
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
identifier: "RRS-1",
title: "Read me through the real control plane",
status: "in_progress",
workMode: "standard",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: issueId,
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId },
});
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const server = createServer();
await new Promise<void>((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Expected a TCP listener.");
setupRunnerPrpWebSocketServer(server, {
apiUrl: `http://127.0.0.1:${address.port}`,
});
const stateDirectory = await mkdtemp(resolve(tmpdir(), "paperclip-runner-real-resume-"));
const bundle = createRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: fakeCodexAppServer,
codexArgs: [],
stateDirectory,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
prpIdentity: {
runnerInstanceId: "runner-real-server",
environmentLeaseId: "lease-real-server",
runId,
normalizedSessionId: "session-real-server",
turnId: "turn-real-server",
itemId: "item-real-server",
},
controlPlaneRegistration: (prp) => registerRunnerPrpAuthority({ companyId, runId, authority: prp }),
});
const observedResults: unknown[] = [];
bundle.transport.setServerRequestHandler(async (request) => {
const params = request.params as Record<string, unknown>;
const result = await authority.execute({
tool: String(params.tool),
callId: String(params.callId),
arguments: params.arguments,
});
observedResults.push(result);
return {
success: true,
contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, result }) }],
};
});
try {
await bundle.transport.request("initialize", {});
await bundle.transport.request("thread/start", {
cwd: tmpdir(),
dynamicTools: await authority.definitions(),
});
await bundle.transport.request("turn/start", {
input: [{ type: "text", text: "Read your assigned task context." }],
});
for await (const notification of bundle.transport.notifications()) {
if (notification.method === "turn/completed") break;
}
expect(observedResults).toHaveLength(1);
expect(observedResults[0]).toMatchObject({
activeTask: { id: issueId, identifier: "RRS-1", title: "Read me through the real control plane" },
actor: { id: agentId },
run: { id: runId },
});
expect(bundle.evidence().diagnostics).toContain("runnerd authenticated to the durable PRP control plane");
await bundle.transport.close();
await db.insert(heartbeatRuns).values({
id: resumedRunId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: issueId,
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId },
});
await db.update(issues).set({ executionRunId: resumedRunId }).where(eq(issues.id, issueId));
const resumedAuthority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId: resumedRunId,
});
const restored = createRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: fakeCodexAppServer,
codexArgs: [],
stateDirectory,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
resumeDynamicTools: await resumedAuthority.definitions(),
prpIdentity: {
runnerInstanceId: "runner-real-server",
environmentLeaseId: "lease-real-server",
runId: resumedRunId,
normalizedSessionId: "session-real-server",
turnId: "turn-real-server-resumed",
itemId: "item-real-server-resumed",
},
controlPlaneRegistration: (prp) => registerRunnerPrpAuthority({
companyId,
runId: resumedRunId,
authority: prp,
}),
});
restored.transport.setServerRequestHandler(async (request) => {
const params = request.params as Record<string, unknown>;
const result = await resumedAuthority.execute({
tool: String(params.tool),
callId: String(params.callId),
arguments: params.arguments,
});
observedResults.push(result);
return {
success: true,
contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, result }) }],
};
});
try {
await restored.transport.request("thread/read", {});
await restored.transport.request("turn/start", {
input: [{ type: "text", text: "Read the same task in a resumed process." }],
});
for await (const notification of restored.transport.notifications()) {
if (notification.method === "turn/completed") break;
}
expect(observedResults).toHaveLength(2);
expect(observedResults[1]).toMatchObject({
activeTask: { id: issueId, identifier: "RRS-1" },
run: { id: resumedRunId },
});
expect(restored.evidence().diagnostics).toContain(
"runnerd restored its durable PRP session and provider thread",
);
} finally {
await restored.transport.close();
}
} finally {
await bundle.transport.close();
await rm(stateDirectory, { recursive: true, force: true });
server.closeAllConnections();
server.close();
}
}, 30_000);
});

View File

@ -0,0 +1,606 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { activityLog, agents, approvals, companies, createDb, documents, heartbeatRuns, issueApprovals, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { documentService } from "../documents.js";
import { issueService } from "../issues.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
describe("PaperclipRunnerToolAuthority", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
const companyId = "00000000-0000-4000-8000-000000000101";
const agentId = "00000000-0000-4000-8000-000000000102";
const issueId = "00000000-0000-4000-8000-000000000103";
const runId = "00000000-0000-4000-8000-000000000104";
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("paperclip-runner-tools-");
db = createDb(temporary.connectionString);
await db.insert(companies).values({
id: companyId,
name: "Runner tools",
issuePrefix: "RNT",
issueCounter: 1,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Runner agent",
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", apiKey: "must-not-leak" },
runtimeConfig: { token: "must-not-leak" },
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
issueNumber: 1,
identifier: "RNT-1",
title: "Exercise real runner tools",
status: "in_progress",
workMode: "standard",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: issueId,
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId },
});
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
});
afterAll(async () => {
await temporary?.cleanup();
});
it("advertises only real bindings and reads the bound task", async () => {
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
expect(authority.definitions()).toHaveLength(16);
expect(authority.definitions().map((tool) => tool.name)).toEqual(expect.arrayContaining([
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"request_human_input",
"create_task", "set_dependencies",
"list_documents", "read_document", "list_document_revisions", "write_document",
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
]));
const context = await authority.execute({ tool: "get_task_context", callId: "context", arguments: {} });
expect(context).toMatchObject({
activeTask: { id: issueId, identifier: "RNT-1" },
actor: { id: agentId },
});
expect(JSON.stringify(context)).not.toContain("must-not-leak");
await expect(authority.execute({ tool: "finish_task", callId: "hidden", arguments: {} }))
.rejects.toThrow("paperclip_runner_tool_not_advertised");
});
it("advertises structured human input in ask mode", () => {
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
workMode: "ask",
});
expect(authority.definitions().map((tool) => tool.name)).toContain("request_human_input");
expect(authority.definitions().map((tool) => tool.name)).not.toContain("create_task");
expect(authority.definitions().map((tool) => tool.name)).not.toContain("set_dependencies");
});
it("does not project a foreign-company task through approval context", async () => {
const foreignCompanyId = "00000000-0000-4000-8000-000000000211";
const foreignIssueId = "00000000-0000-4000-8000-000000000212";
const approvalId = "00000000-0000-4000-8000-000000000213";
await db.insert(companies).values({
id: foreignCompanyId,
name: "Foreign approval company",
issuePrefix: "FAC",
issueCounter: 1,
});
await db.insert(issues).values({
id: foreignIssueId,
companyId: foreignCompanyId,
issueNumber: 1,
identifier: "FAC-1",
title: "Must not cross the approval boundary",
status: "todo",
});
await db.insert(approvals).values({
id: approvalId,
companyId,
type: "runner_review",
status: "pending",
payload: {},
});
// The schema deliberately stores companyId independently on the link. A
// corrupt or historical cross-tenant link must still fail closed at read.
await db.insert(issueApprovals).values({
companyId,
approvalId,
issueId: foreignIssueId,
linkedByAgentId: agentId,
});
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
});
await expect(authority.execute({
tool: "get_approval_context",
callId: "foreign-approval-context",
arguments: { approvalId },
})).resolves.toMatchObject({ approval: { id: approvalId }, tasks: [] });
});
it("does not advertise delegation tools during pre-acceptance planning", () => {
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
workMode: "planning",
});
expect(authority.definitions().map((tool) => tool.name)).not.toContain("create_task");
expect(authority.definitions().map((tool) => tool.name)).not.toContain("set_dependencies");
});
it("writes progress through the real issue service and replays idempotently", async () => {
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const call = {
tool: "report_progress",
callId: "progress",
arguments: { body: "Runner progress", idempotencyKey: "progress-1" },
};
const first = await authority.execute(call);
const replay = await authority.execute({ ...call, callId: "progress-replay" });
expect(replay).toEqual(first);
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)))
.toHaveLength(1);
const progressActivity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId));
expect(progressActivity).toHaveLength(1);
expect(progressActivity[0]).toMatchObject({
action: "issue.comment_added",
actorType: "agent",
actorId: agentId,
agentId,
runId,
entityType: "issue",
entityId: issueId,
details: expect.objectContaining({
bodySnippet: "Runner progress",
identifier: "RNT-1",
issueTitle: "Exercise real runner tools",
source: "paperclip_runner_protocol",
}),
});
await expect(authority.execute({
...call,
arguments: { body: "Changed", idempotencyKey: "progress-1" },
})).rejects.toThrow("paperclip_runner_tool_idempotency_conflict");
});
it("creates checkbox interactions through the real interaction service", async () => {
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const call = {
tool: "request_human_input",
callId: "ask-checkbox",
arguments: {
idempotencyKey: "favorite-animals",
interactionKind: "checkbox",
title: "Favorite zoo animals",
prompt: "Which zoo animals are your favorites?",
continuationPolicy: "wake_assignee",
payload: {
options: [
{ id: "giraffes", label: "Giraffes" },
{ id: "lions", label: "Lions" },
],
},
},
};
const first = await authority.execute(call);
await expect(authority.execute({ ...call, callId: "ask-checkbox-replay" })).resolves.toEqual(first);
expect(first).toMatchObject({
interaction: { kind: "request_checkbox_confirmation", status: "pending" },
});
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId)))
.toHaveLength(1);
expect((await db.select().from(activityLog).where(eq(activityLog.entityId, issueId)))
.filter((entry) => entry.action === "issue.thread_interaction_created"))
.toHaveLength(1);
await expect(authority.execute({
...call,
callId: "ask-checkbox-conflict",
arguments: {
...call.arguments,
prompt: "Use the same key for a different prompt.",
},
})).rejects.toThrow("paperclip_runner_tool_idempotency_conflict");
});
it("writes a real revisioned document and replays the mutation receipt", async () => {
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const call = {
tool: "write_document",
callId: "write-plan",
arguments: {
idempotencyKey: "write-plan-1",
key: "plan",
title: "Execution plan",
body: "Use the real document service.",
// Provider bridges may serialize nullable string inputs as the literal
// "null". The protocol boundary treats that as document creation.
baseRevisionId: "null",
changeSummary: "Initial plan",
},
};
const first = await authority.execute(call);
const replay = await authority.execute({ ...call, callId: "write-plan-replay" });
expect(replay).toEqual(first);
expect(first).toMatchObject({
disposition: "applied",
created: true,
document: { key: "plan", body: "Use the real document service." },
});
expect(await db.select().from(documents).where(eq(documents.companyId, companyId))).toHaveLength(1);
const documentActivity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId));
expect(documentActivity.filter((entry) => entry.action === "issue.document_created")).toEqual([
expect.objectContaining({
actorType: "agent",
actorId: agentId,
agentId,
runId,
entityType: "issue",
details: expect.objectContaining({
key: "plan",
source: "paperclip_runner_protocol",
}),
}),
]);
await expect(authority.execute({
...call,
arguments: { ...call.arguments, body: "Conflicting retry." },
})).rejects.toThrow("paperclip_runner_tool_idempotency_conflict");
});
it("returns the exact accepted plan revision in task context", async () => {
const plan = await documentService(db).getIssueDocumentByKey(issueId, "plan");
expect(plan).not.toBeNull();
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const requested = await authority.execute({
tool: "request_human_input",
callId: "approve-plan",
arguments: {
idempotencyKey: `confirmation:${issueId}:plan:${plan!.latestRevisionId}`,
interactionKind: "confirmation",
title: "Approve the plan",
prompt: "Approve this exact plan revision?",
payload: {
target: {
type: "issue_document",
issueId,
documentId: plan!.id,
key: "plan",
revisionId: plan!.latestRevisionId,
revisionNumber: plan!.latestRevisionNumber,
},
},
targetRevisionId: plan!.latestRevisionId,
continuationPolicy: "wake_assignee_on_accept",
},
});
expect(requested).toMatchObject({
interaction: {
kind: "request_confirmation",
status: "pending",
payload: {
target: {
type: "issue_document",
issueId,
key: "plan",
revisionId: plan!.latestRevisionId,
},
},
},
});
await db.update(issueThreadInteractions).set({
status: "accepted",
resolvedByUserId: "test-user",
resolvedAt: new Date(),
result: { outcome: "accepted" } as never,
}).where(eq(issueThreadInteractions.id, (requested as { interaction: { id: string } }).interaction.id));
await db.update(heartbeatRuns).set({
contextSnapshot: {
issueId,
workspaceRefreshReason: "accepted_plan_confirmation",
planReviewInteraction: {
acceptedTargetRevision: {
issueId,
documentId: plan!.id,
key: "plan",
revisionId: plan!.latestRevisionId,
revisionNumber: plan!.latestRevisionNumber,
},
},
},
}).where(eq(heartbeatRuns.id, runId));
await expect(authority.execute({ tool: "get_task_context", callId: "accepted-context", arguments: {} }))
.resolves.toMatchObject({
acceptedPlan: {
documentId: plan!.id,
revisionId: plan!.latestRevisionId,
revisionNumber: plan!.latestRevisionNumber,
markdown: "Use the real document service.",
},
});
});
it("creates ordinary children, preserves blockers, and deduplicates across runs", async () => {
const wakes: Array<{ agentId: string; options: Record<string, unknown> }> = [];
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
workMode: "standard",
enqueueWakeup: async (wakeAgentId, options) => {
wakes.push({ agentId: wakeAgentId, options });
return null;
},
});
expect(authority.definitions().map((tool) => tool.name)).toContain("create_task");
const prerequisite = await authority.execute({
tool: "create_task",
callId: "create-prerequisite",
arguments: {
idempotencyKey: "ordinary-prerequisite",
title: "Prepare delegated input",
description: "A self-contained prerequisite delegated from the active task.",
},
});
expect(prerequisite).toMatchObject({
disposition: "applied",
task: {
parentId: issueId,
status: "todo",
assigneeActorId: agentId,
},
});
expect(wakes).toHaveLength(1);
expect(wakes[0]).toMatchObject({
agentId,
options: {
reason: "issue_assigned",
payload: { parentIssueId: issueId },
},
});
const prerequisiteId = (prerequisite as { task: { id: string } }).task.id;
const dependent = await authority.execute({
tool: "create_task",
callId: "create-dependent",
arguments: {
idempotencyKey: "ordinary-dependent",
title: "Use delegated input",
blockedByTaskIds: [prerequisiteId],
},
});
expect(dependent).toMatchObject({
disposition: "applied",
scheduledWakeIds: [],
task: { parentId: issueId, status: "blocked", assigneeActorId: agentId },
});
expect(wakes).toHaveLength(1);
await expect(issueService(db).getRelationSummaries(issueId)).resolves.toMatchObject({
blockedBy: [],
});
await authority.execute({
tool: "set_dependencies",
callId: "wait-for-prerequisite",
arguments: {
idempotencyKey: "source-waits-for-prerequisite",
blockedByTaskIds: [prerequisiteId],
},
});
await expect(issueService(db).getRelationSummaries(issueId)).resolves.toMatchObject({
blockedBy: [expect.objectContaining({ id: prerequisiteId })],
});
await issueService(db).update(prerequisiteId, {
status: "done",
actorAgentId: agentId,
});
await expect(authority.execute({
tool: "create_task",
callId: "create-dependency-ready-child",
arguments: {
idempotencyKey: "ordinary-ready-dependent",
title: "Start after completed delegated input",
blockedByTaskIds: [prerequisiteId],
},
})).resolves.toMatchObject({
disposition: "applied",
task: { parentId: issueId, status: "todo", assigneeActorId: agentId },
scheduledWakeIds: [expect.any(String)],
});
expect(wakes).toHaveLength(2);
const nextRunId = "00000000-0000-4000-8000-000000000106";
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
await db.insert(heartbeatRuns).values({
id: nextRunId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: issueId,
invocationSource: "automation",
triggerDetail: "system",
contextSnapshot: { issueId },
});
await db.update(issues).set({ executionRunId: nextRunId }).where(eq(issues.id, issueId));
const retryWakes: Array<unknown> = [];
const retryAuthority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId: nextRunId,
workMode: "standard",
enqueueWakeup: async (_wakeAgentId, options) => {
retryWakes.push(options);
return null;
},
});
await expect(retryAuthority.execute({
tool: "create_task",
callId: "cross-run-retry",
arguments: {
idempotencyKey: "ordinary-prerequisite",
title: "Prepare delegated input",
description: "A self-contained prerequisite delegated from the active task.",
},
})).resolves.toMatchObject({ disposition: "duplicate", task: { id: prerequisiteId } });
await expect(retryAuthority.execute({
tool: "create_task",
callId: "cross-run-conflicting-retry",
arguments: {
idempotencyKey: "ordinary-prerequisite",
title: "Conflicting title for the same caller key",
},
})).rejects.toThrow("paperclip_runner_tool_idempotency_conflict");
const foreignCompanyId = "00000000-0000-4000-8000-000000000201";
const foreignAgentId = "00000000-0000-4000-8000-000000000202";
const foreignIssueId = "00000000-0000-4000-8000-000000000203";
await db.insert(companies).values({
id: foreignCompanyId,
name: "Foreign company",
issuePrefix: "FGN",
issueCounter: 1,
});
await db.insert(agents).values({
id: foreignAgentId,
companyId: foreignCompanyId,
name: "Foreign agent",
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex" },
runtimeConfig: {},
status: "active",
});
await db.insert(issues).values({
id: foreignIssueId,
companyId: foreignCompanyId,
issueNumber: 1,
identifier: "FGN-1",
title: "Foreign blocker",
status: "todo",
});
await expect(retryAuthority.execute({
tool: "create_task",
callId: "foreign-assignee",
arguments: {
idempotencyKey: "foreign-assignee",
title: "Invalid foreign assignment",
assigneeActorId: foreignAgentId,
},
})).rejects.toThrow("paperclip_runner_agent_not_found");
await expect(retryAuthority.execute({
tool: "create_task",
callId: "foreign-blocker",
arguments: {
idempotencyKey: "foreign-blocker",
title: "Invalid foreign blocker",
blockedByTaskIds: [foreignIssueId],
},
})).rejects.toThrow();
expect(retryWakes).toHaveLength(0);
expect(await db.select().from(issues).where(eq(issues.parentId, issueId))).toHaveLength(3);
});
it("rejects mutations after reassignment, run replacement, or terminalization", async () => {
const guardedIssueId = "00000000-0000-4000-8000-000000000107";
const guardedRunId = "00000000-0000-4000-8000-000000000108";
const guardedReplacementRunId = "00000000-0000-4000-8000-000000000109";
await db.insert(issues).values({
id: guardedIssueId,
companyId,
issueNumber: 999,
identifier: "RNT-999",
title: "Guard mutation authorization",
status: "in_progress",
workMode: "standard",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: guardedRunId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: guardedIssueId,
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId: guardedIssueId },
});
await db.insert(heartbeatRuns).values({
id: guardedReplacementRunId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: guardedIssueId,
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId: guardedIssueId },
});
await db.update(issues).set({ executionRunId: guardedRunId }).where(eq(issues.id, guardedIssueId));
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId: guardedIssueId,
runId: guardedRunId,
});
const mutation = {
tool: "report_progress",
callId: "guarded-progress",
arguments: { body: "Must remain authorized", idempotencyKey: "guarded-progress" },
};
await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, guardedIssueId));
await expect(authority.execute(mutation))
.rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
await db.update(issues).set({
assigneeAgentId: agentId,
executionRunId: guardedReplacementRunId,
}).where(eq(issues.id, guardedIssueId));
await expect(authority.execute({ ...mutation, callId: "replaced-run" }))
.rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
await db.update(issues).set({ executionRunId: guardedRunId }).where(eq(issues.id, guardedIssueId));
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, guardedRunId));
await expect(authority.execute({ ...mutation, callId: "terminal-run" }))
.rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, guardedIssueId)))
.toHaveLength(0);
});
it("fails closed once the run is no longer active", async () => {
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
await expect(authority.execute({ tool: "get_task_context", callId: "late", arguments: {} }))
.rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
});
});

View File

@ -0,0 +1,757 @@
import { createHash } from "node:crypto";
import { and, desc, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
documentRevisions,
heartbeatRuns,
issueApprovals,
issueComments,
issueDocuments,
issues,
issueThreadInteractions,
} from "@paperclipai/db";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js";
import { agentService } from "../agents.js";
import { approvalService } from "../approvals.js";
import { documentService } from "../documents.js";
import { issueService } from "../issues.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { persistActivity, publishActivity } from "../activity-log.js";
const IMPLEMENTED_OPERATIONS = new Set([
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"request_human_input",
"create_task", "set_dependencies",
"list_documents", "read_document", "list_document_revisions", "write_document",
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
]);
type Binding = {
companyId: string;
issueId: string;
runId: string;
agentId: string;
normalizedSessionId?: string;
workMode?: "standard" | "planning" | "ask";
enqueueWakeup?: (agentId: string, options: {
source: "assignment";
triggerDetail: "system";
reason: "issue_assigned";
payload: Record<string, unknown>;
idempotencyKey: string;
requestedByActorType: "agent";
requestedByActorId: string;
contextSnapshot: Record<string, unknown>;
}) => Promise<unknown>;
};
type ToolReceipt = {
operationId: string;
input: unknown;
result: unknown;
};
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (typeof value === "object" && value !== null) {
const object = value as Record<string, unknown>;
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}
export class PaperclipRunnerToolAuthority {
constructor(readonly db: Db, readonly binding: Binding) {}
definitions(): Array<Record<string, unknown>> {
const workMode = this.binding.workMode ?? "standard";
return CAPABILITY_SEMANTIC_TOOL_CATALOG
.filter((descriptor) =>
IMPLEMENTED_OPERATIONS.has(descriptor.operationId)
&& descriptor.allowedModes.includes(workMode)
)
.map((descriptor) => ({
name: descriptor.operationId,
description: descriptor.description,
inputSchema: descriptor.inputSchema,
}));
}
async execute(call: { tool: string; callId: string; arguments: unknown }): Promise<unknown> {
if (!IMPLEMENTED_OPERATIONS.has(call.tool)) throw new Error("paperclip_runner_tool_not_advertised");
const context = await this.#boundContext();
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find((candidate) => candidate.operationId === call.tool);
if (!descriptor || !descriptor.allowedModes.includes(
context.issue.workMode as "standard" | "planning" | "ask",
)) {
throw new Error("paperclip_runner_tool_mode_denied");
}
const input = record(call.arguments);
switch (call.tool) {
case "get_task_context": return {
company: { id: this.binding.companyId },
actor: redactedActor(context.actor),
activeTask: redactedTask(context.issue),
run: {
id: this.binding.runId,
status: context.run.status,
invocationSource: context.run.invocationSource,
},
acceptedPlan: await this.#acceptedPlan(context.run.contextSnapshot),
};
case "get_task_history": {
const limit = boundedLimit(input.limit);
const comments = await this.db.select({
id: issueComments.id,
body: issueComments.body,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
createdAt: issueComments.createdAt,
}).from(issueComments)
.where(and(
eq(issueComments.companyId, this.binding.companyId),
eq(issueComments.issueId, this.binding.issueId),
isNull(issueComments.deletedAt),
))
.orderBy(desc(issueComments.createdAt))
.limit(limit);
return { comments: comments.reverse() };
}
case "search_tasks": {
const tasks = await issueService(this.db).list(this.binding.companyId);
const query = typeof input.query === "string" ? input.query.toLowerCase() : "";
const statuses = Array.isArray(input.statuses) ? new Set(input.statuses.filter((value): value is string => typeof value === "string")) : null;
return { tasks: tasks.filter((task) =>
(!query || `${task.identifier} ${task.title} ${task.description ?? ""}`.toLowerCase().includes(query))
&& (!statuses || statuses.size === 0 || statuses.has(task.status))
).slice(0, boundedLimit(input.limit)).map(redactedTask) };
}
case "list_documents":
return { documents: await documentService(this.db).listIssueDocuments(this.binding.issueId) };
case "read_document": {
const document = await documentService(this.db).getIssueDocumentByKey(this.binding.issueId, requiredString(input.key));
if (!document) throw new Error("paperclip_runner_document_not_found");
return { document };
}
case "list_document_revisions":
return { revisions: await documentService(this.db).listIssueDocumentRevisions(this.binding.issueId, requiredString(input.key)) };
case "write_document": return this.#writeDocument(input);
case "list_agents":
return { actors: (await agentService(this.db).list(this.binding.companyId)).map(redactedActor) };
case "get_agent": {
const actor = await agentService(this.db).getById(requiredString(input.actorId));
if (!actor || actor.companyId !== this.binding.companyId) throw new Error("paperclip_runner_agent_not_found");
return { actor: redactedActor(actor) };
}
case "list_approvals":
return { approvals: await approvalService(this.db).list(this.binding.companyId) };
case "get_approval": {
const approval = await this.#approval(requiredString(input.approvalId));
return { approval };
}
case "get_approval_context": {
const approval = await this.#approval(requiredString(input.approvalId));
const tasks = await this.db.select({ issue: issues }).from(issueApprovals)
.innerJoin(issues, eq(issues.id, issueApprovals.issueId))
.where(and(
eq(issueApprovals.approvalId, approval.id),
eq(issueApprovals.companyId, this.binding.companyId),
eq(issues.companyId, this.binding.companyId),
));
return { approval, tasks: tasks.map((row) => row.issue) };
}
case "report_progress": return this.#reportProgress(input);
case "request_human_input": return this.#requestHumanInput(input);
case "create_task": return this.#createTask(input);
case "set_dependencies": return this.#setDependencies(input);
default: throw new Error("paperclip_runner_tool_not_bound");
}
}
async #approval(id: string) {
const approval = await approvalService(this.db).getById(id);
if (!approval || approval.companyId !== this.binding.companyId) throw new Error("paperclip_runner_approval_not_found");
return approval;
}
async #boundContext() {
const [row] = await this.db.select({ issue: issues, actor: agents, run: heartbeatRuns })
.from(heartbeatRuns)
.innerJoin(issues, eq(issues.id, this.binding.issueId))
.innerJoin(agents, eq(agents.id, this.binding.agentId))
.where(and(
eq(heartbeatRuns.id, this.binding.runId),
eq(heartbeatRuns.companyId, this.binding.companyId),
eq(heartbeatRuns.agentId, this.binding.agentId),
eq(heartbeatRuns.nativeIssueId, this.binding.issueId),
eq(issues.companyId, this.binding.companyId),
eq(issues.assigneeAgentId, this.binding.agentId),
eq(issues.executionRunId, this.binding.runId),
eq(agents.companyId, this.binding.companyId),
))
.limit(1);
if (
!row
|| row.run.runtimeMode !== "native"
|| row.run.status !== "running"
|| ["paused", "terminated", "pending_approval", "error"].includes(row.actor.status)
) {
throw new Error("paperclip_runner_tool_binding_not_authorized");
}
return row;
}
async #reportProgress(input: Record<string, unknown>): Promise<unknown> {
const body = typeof input.body === "string" ? input.body.trim() : "";
const idempotencyKey = typeof input.idempotencyKey === "string" ? input.idempotencyKey.trim() : "";
if (!body || !idempotencyKey) throw new Error("paperclip_runner_tool_input_invalid");
let publication: Awaited<ReturnType<typeof persistActivity>>["publication"] | null = null;
const result = await this.#withMutationReceipt(
"report_progress",
idempotencyKey,
input,
async (tx, context) => {
const comment = await issueService(tx).addComment(
this.binding.issueId,
body,
{ agentId: this.binding.agentId, runId: this.binding.runId },
{ authorizationReason: "paperclip_runner_protocol" },
tx,
);
const result = { commentId: comment.id, issueId: this.binding.issueId, disposition: "applied" };
const activity = await persistActivity(tx, {
companyId: this.binding.companyId,
actorType: "agent",
actorId: this.binding.agentId,
agentId: this.binding.agentId,
runId: this.binding.runId,
issueId: this.binding.issueId,
action: "issue.comment_added",
entityType: "issue",
entityId: this.binding.issueId,
details: {
commentId: comment.id,
bodySnippet: comment.body.slice(0, 120),
identifier: context.issue.identifier,
issueTitle: context.issue.title,
authorizationReason: "paperclip_runner_protocol",
source: "paperclip_runner_protocol",
},
});
publication = activity.publication;
return result;
},
);
if (publication) publishActivity(publication);
return result;
}
async #writeDocument(input: Record<string, unknown>): Promise<unknown> {
const idempotencyKey = requiredString(input.idempotencyKey);
let publication: Awaited<ReturnType<typeof persistActivity>>["publication"] | null = null;
const result = await this.#withMutationReceipt("write_document", idempotencyKey, input, async (tx) => {
const write = await documentService(tx).upsertIssueDocument({
issueId: this.binding.issueId,
key: requiredString(input.key),
title: requiredString(input.title),
format: "markdown",
body: requiredString(input.body),
baseRevisionId: nullableProviderId(input.baseRevisionId),
changeSummary: input.changeSummary === null || input.changeSummary === undefined
? null
: requiredString(input.changeSummary),
createdByAgentId: this.binding.agentId,
createdByRunId: this.binding.runId,
});
const activity = await persistActivity(tx, {
companyId: this.binding.companyId,
actorType: "agent",
actorId: this.binding.agentId,
agentId: this.binding.agentId,
runId: this.binding.runId,
issueId: this.binding.issueId,
action: write.created ? "issue.document_created" : "issue.document_updated",
entityType: "issue",
entityId: this.binding.issueId,
details: {
key: write.document.key,
documentId: write.document.id,
title: write.document.title,
format: write.document.format,
revisionNumber: write.document.latestRevisionNumber,
source: "paperclip_runner_protocol",
},
});
publication = activity.publication;
return {
disposition: "applied",
created: write.created,
document: write.document,
};
});
if (publication) publishActivity(publication);
return result;
}
async #createTask(input: Record<string, unknown>): Promise<unknown> {
const idempotencyKey = requiredString(input.idempotencyKey);
const assigneeAgentId = input.assigneeActorId === null || input.assigneeActorId === undefined
? this.binding.agentId
: requiredString(input.assigneeActorId);
const assignee = await agentService(this.db).getById(assigneeAgentId);
if (!assignee || assignee.companyId !== this.binding.companyId) {
throw new Error("paperclip_runner_agent_not_found");
}
const priority = input.priority === "critical" || input.priority === "high"
|| input.priority === "medium" || input.priority === "low"
? input.priority
: "medium";
const blockedByIssueIds = Array.isArray(input.blockedByTaskIds)
? input.blockedByTaskIds.map(requiredString)
: [];
const durableIdempotencyKey =
`paperclip-runner:create-task:${this.binding.issueId}:${idempotencyKey}`;
const inputFingerprint = createHash("sha256")
.update(canonicalJson(input))
.digest("hex");
const result = await this.#withMutationReceipt("create_task", idempotencyKey, input, async (tx) => {
const existingChild = await tx.select().from(issues).where(and(
eq(issues.companyId, this.binding.companyId),
eq(issues.parentId, this.binding.issueId),
eq(issues.originId, durableIdempotencyKey),
)).limit(1).then((rows) => rows[0] ?? null);
if (existingChild) {
if (existingChild.originFingerprint !== inputFingerprint) {
throw new Error("paperclip_runner_tool_idempotency_conflict");
}
return {
commandId: `create-task:${existingChild.id}`,
disposition: "duplicate",
stateRevision: existingChild.statusVersion,
entityRefs: [existingChild.id],
scheduledWakeIds: [],
task: {
id: existingChild.id,
identifier: existingChild.identifier,
parentId: existingChild.parentId,
status: existingChild.status,
assigneeActorId: existingChild.assigneeAgentId,
},
};
}
let deduplicated = false;
const created = await issueService(tx).createChild(this.binding.issueId, {
title: requiredString(input.title),
description: input.description === null || input.description === undefined
? null
: requiredString(input.description),
status: blockedByIssueIds.length > 0 ? "blocked" : "todo",
workMode: "standard",
priority,
assigneeAgentId,
blockedByIssueIds,
blockParentUntilDone: false,
createdByAgentId: this.binding.agentId,
originKind: "manual",
originId: durableIdempotencyKey,
originFingerprint: inputFingerprint,
actorAgentId: this.binding.agentId,
actorRunId: this.binding.runId,
idempotencyKey: durableIdempotencyKey,
onDeduplicated: () => { deduplicated = true; },
});
const child = created.issue;
if (deduplicated && child.originFingerprint !== inputFingerprint) {
throw new Error("paperclip_runner_tool_idempotency_conflict");
}
let childStatus = child.status;
let childStatusVersion = child.statusVersion;
if (child.status === "blocked" && blockedByIssueIds.length > 0) {
const readiness = await issueService(tx).getDependencyReadiness(child.id, tx);
if (readiness.isDependencyReady) {
const readyChild = await issueService(tx).update(child.id, {
status: "todo",
actorAgentId: this.binding.agentId,
}, tx);
if (readyChild) {
childStatus = readyChild.status;
childStatusVersion = readyChild.statusVersion;
}
}
}
const wakeId = `created-child:${child.id}`;
const shouldWake = !deduplicated && childStatus === "todo" && Boolean(child.assigneeAgentId);
return {
commandId: `create-task:${child.id}`,
disposition: deduplicated ? "duplicate" : "applied",
stateRevision: childStatusVersion,
entityRefs: [child.id],
scheduledWakeIds: shouldWake ? [wakeId] : [],
task: {
id: child.id,
identifier: child.identifier,
parentId: child.parentId,
status: childStatus,
assigneeActorId: child.assigneeAgentId,
},
};
}) as Record<string, unknown>;
const task = record(result.task);
const childId = requiredString(task.id);
const scheduledWakeIds = Array.isArray(result.scheduledWakeIds)
? result.scheduledWakeIds.filter((value): value is string => typeof value === "string")
: [];
const assignedAgentId = typeof task.assigneeActorId === "string"
? task.assigneeActorId
: null;
if (this.binding.enqueueWakeup && assignedAgentId && scheduledWakeIds.length > 0) {
await this.binding.enqueueWakeup(assignedAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
issueId: childId,
mutation: "create_child",
parentIssueId: this.binding.issueId,
},
idempotencyKey: scheduledWakeIds[0]!,
requestedByActorType: "agent",
requestedByActorId: this.binding.agentId,
contextSnapshot: {
issueId: childId,
source: "paperclip_runner.create_task",
parentIssueId: this.binding.issueId,
},
});
}
return result;
}
async #setDependencies(input: Record<string, unknown>): Promise<unknown> {
const idempotencyKey = requiredString(input.idempotencyKey);
if (!Array.isArray(input.blockedByTaskIds)) {
throw new Error("paperclip_runner_tool_input_invalid");
}
const blockedByIssueIds = input.blockedByTaskIds.map(requiredString);
return this.#withMutationReceipt("set_dependencies", idempotencyKey, input, async (tx) => {
const updated = await issueService(tx).update(this.binding.issueId, {
blockedByIssueIds,
actorAgentId: this.binding.agentId,
}, tx);
if (!updated) throw new Error("paperclip_runner_task_not_found");
return {
commandId: `set-dependencies:${updated.id}:${updated.statusVersion}`,
disposition: "applied",
stateRevision: updated.statusVersion,
entityRefs: [updated.id, ...blockedByIssueIds],
scheduledWakeIds: [],
};
});
}
async #acceptedPlan(contextSnapshot: unknown): Promise<{
documentId: string;
revisionId: string;
revisionNumber: number;
markdown: string;
} | null> {
const acceptedTarget = record(
record(record(contextSnapshot).planReviewInteraction).acceptedTargetRevision,
);
let revisionId = typeof acceptedTarget.revisionId === "string"
? acceptedTarget.revisionId
: null;
if (!revisionId) revisionId = await this.#latestAcceptedPlanRevisionId();
if (!revisionId) return null;
const [revision] = await this.db.select({
documentId: documentRevisions.documentId,
revisionId: documentRevisions.id,
revisionNumber: documentRevisions.revisionNumber,
markdown: documentRevisions.body,
})
.from(documentRevisions)
.innerJoin(issueDocuments, and(
eq(issueDocuments.documentId, documentRevisions.documentId),
eq(issueDocuments.companyId, this.binding.companyId),
eq(issueDocuments.issueId, this.binding.issueId),
eq(issueDocuments.key, "plan"),
))
.where(and(
eq(documentRevisions.id, revisionId),
eq(documentRevisions.companyId, this.binding.companyId),
))
.limit(1);
return revision ?? null;
}
async #latestAcceptedPlanRevisionId(): Promise<string | null> {
const rows = await this.db.select({ payload: issueThreadInteractions.payload })
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.companyId, this.binding.companyId),
eq(issueThreadInteractions.issueId, this.binding.issueId),
eq(issueThreadInteractions.kind, "request_confirmation"),
eq(issueThreadInteractions.status, "accepted"),
))
.orderBy(desc(issueThreadInteractions.resolvedAt), desc(issueThreadInteractions.createdAt));
for (const row of rows) {
const target = record(record(row.payload).target);
if (
target.type === "issue_document"
&& (target.issueId === undefined || target.issueId === this.binding.issueId)
&& target.key === "plan"
&& typeof target.revisionId === "string"
&& target.revisionId.length > 0
) return target.revisionId;
}
return null;
}
async #withMutationReceipt(
operationId: string,
idempotencyKey: string,
input: Record<string, unknown>,
effect: (tx: Db, context: {
run: typeof heartbeatRuns.$inferSelect;
issue: typeof issues.$inferSelect;
actor: typeof agents.$inferSelect;
}) => Promise<unknown>,
): Promise<unknown> {
return this.db.transaction(async (tx) => {
const context = await this.#lockAuthorizedMutationContext(tx as unknown as Db);
const resultJson = record(context.run.resultJson);
const receipts = record(resultJson.semanticToolReceipts);
const prior = receipts[idempotencyKey] as ToolReceipt | undefined;
if (prior !== undefined) {
if (prior.operationId !== operationId || canonicalJson(prior.input) !== canonicalJson(input)) {
throw new Error("paperclip_runner_tool_idempotency_conflict");
}
return prior.result;
}
const result = JSON.parse(JSON.stringify(
await effect(tx as unknown as Db, context),
)) as unknown;
receipts[idempotencyKey] = { operationId, input, result } satisfies ToolReceipt;
await tx.update(heartbeatRuns).set({
resultJson: { ...resultJson, semanticToolReceipts: receipts },
updatedAt: new Date(),
}).where(eq(heartbeatRuns.id, this.binding.runId));
return result;
});
}
async #lockAuthorizedMutationContext(tx: Db): Promise<{
run: typeof heartbeatRuns.$inferSelect;
issue: typeof issues.$inferSelect;
actor: typeof agents.$inferSelect;
}> {
// Authorization for writes is intentionally re-read only after the
// transaction starts. Locking the run and issue in the same statement
// closes the gap between the discovery-time check and the mutation: a
// reassignment, replacement run, or terminal transition must commit either
// before this check (and be rejected) or after this transaction completes.
const [context] = await tx
.select({ run: heartbeatRuns, issue: issues, actor: agents })
.from(heartbeatRuns)
.innerJoin(issues, eq(issues.id, this.binding.issueId))
.innerJoin(agents, eq(agents.id, this.binding.agentId))
.where(and(
eq(heartbeatRuns.id, this.binding.runId),
eq(heartbeatRuns.companyId, this.binding.companyId),
eq(heartbeatRuns.agentId, this.binding.agentId),
eq(heartbeatRuns.nativeIssueId, this.binding.issueId),
eq(issues.companyId, this.binding.companyId),
eq(issues.assigneeAgentId, this.binding.agentId),
eq(issues.executionRunId, this.binding.runId),
eq(agents.companyId, this.binding.companyId),
))
.for("update")
.limit(1);
if (
!context
|| context.run.runtimeMode !== "native"
|| context.run.status !== "running"
|| context.run.companyId !== this.binding.companyId
|| context.run.agentId !== this.binding.agentId
|| context.run.nativeIssueId !== this.binding.issueId
|| context.issue.companyId !== this.binding.companyId
|| context.issue.assigneeAgentId !== this.binding.agentId
|| context.issue.executionRunId !== this.binding.runId
|| context.actor.companyId !== this.binding.companyId
|| ["paused", "terminated", "pending_approval", "error"].includes(context.actor.status)
) {
throw new Error("paperclip_runner_tool_binding_not_authorized");
}
return context;
}
async #requestHumanInput(input: Record<string, unknown>): Promise<unknown> {
const interactionKind = requiredString(input.interactionKind);
const interactionKinds = {
confirmation: "request_confirmation",
checkbox: "request_checkbox_confirmation",
questions: "ask_user_questions",
suggest_tasks: "suggest_tasks",
item_verdicts: "request_item_verdicts",
} as const;
const kind = interactionKinds[
interactionKind as keyof typeof interactionKinds
];
if (!kind) throw new Error("paperclip_runner_interaction_kind_invalid");
const prompt = requiredString(input.prompt);
const idempotencyKey = requiredString(input.idempotencyKey);
let publication: Awaited<ReturnType<typeof persistActivity>>["publication"] | null = null;
const result = await this.#withMutationReceipt(
"request_human_input",
idempotencyKey,
input,
async (tx, context) => {
const suppliedPayload = record(input.payload);
const targetRevisionId = nullableProviderId(input.targetRevisionId);
const suppliedTarget = record(suppliedPayload.target);
const inferredPlanningTarget = targetRevisionId !== null
&& suppliedPayload.target === undefined
&& kind === "request_confirmation"
&& context.issue.workMode === "planning"
? {
type: "issue_document",
issueId: context.issue.id,
key: "plan",
revisionId: targetRevisionId,
}
: null;
if (targetRevisionId !== null && suppliedPayload.target === undefined && inferredPlanningTarget === null) {
throw new Error("paperclip_runner_interaction_target_incomplete");
}
const normalizedPayload = inferredPlanningTarget !== null
? { ...suppliedPayload, target: inferredPlanningTarget }
: suppliedTarget.type === "issue_document"
? {
...suppliedPayload,
target: {
...suppliedTarget,
issueId: suppliedTarget.issueId ?? context.issue.id,
revisionId: suppliedTarget.revisionId ?? targetRevisionId,
},
}
: suppliedPayload;
const interaction = await issueThreadInteractionService(tx).create(context.issue, {
kind,
idempotencyKey,
sourceRunId: this.binding.runId,
title: requiredString(input.title),
summary: prompt,
continuationPolicy: requiredString(input.continuationPolicy),
payload: {
...normalizedPayload,
version: 1,
prompt,
...(kind === "request_confirmation" ? {
detailsMarkdown: normalizedPayload.detailsMarkdown ?? "",
acceptLabel: normalizedPayload.acceptLabel ?? "Confirm",
rejectLabel: normalizedPayload.rejectLabel ?? "Request changes",
rejectRequiresReason: normalizedPayload.rejectRequiresReason ?? false,
supersedeOnUserComment: normalizedPayload.supersedeOnUserComment ?? true,
} : {}),
},
} as never, { agentId: this.binding.agentId, userId: null });
const activity = await persistActivity(tx, {
companyId: this.binding.companyId,
actorType: "agent",
actorId: this.binding.agentId,
agentId: this.binding.agentId,
runId: this.binding.runId,
issueId: this.binding.issueId,
action: "issue.thread_interaction_created",
entityType: "issue",
entityId: this.binding.issueId,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
continuationPolicy: interaction.continuationPolicy,
source: "paperclip_runner_protocol",
},
});
publication = activity.publication;
return { interaction, disposition: "applied" };
},
);
if (publication) publishActivity(publication);
return result;
}
}
function requiredString(value: unknown): string {
if (typeof value !== "string" || value.trim() === "") throw new Error("paperclip_runner_tool_input_invalid");
return value.trim();
}
/**
* Some native tool transports cannot faithfully express a nullable string in
* their provider-facing schema and send the JSON null sentinel as a string.
* Normalize only the well-known empty/null sentinels at the control-plane
* boundary; real revision ids remain untouched and optimistic concurrency is
* still enforced by the document service.
*/
function nullableProviderId(value: unknown): string | null {
if (value === null || value === undefined) return null;
const normalized = requiredString(value);
return normalized === "null" || normalized === "undefined" ? null : normalized;
}
function boundedLimit(value: unknown): number {
return typeof value === "number" && Number.isInteger(value)
? Math.max(1, Math.min(value, 100))
: 50;
}
function redactedActor(actor: {
id: string;
companyId: string;
name: string;
role: string;
title?: string | null;
status: string;
reportsTo?: string | null;
capabilities?: string | null;
}) {
return {
id: actor.id,
companyId: actor.companyId,
name: actor.name,
role: actor.role,
title: actor.title ?? null,
status: actor.status,
reportsTo: actor.reportsTo ?? null,
capabilities: actor.capabilities ?? null,
};
}
function redactedTask(task: typeof issues.$inferSelect) {
return {
id: task.id,
companyId: task.companyId,
identifier: task.identifier,
title: task.title,
description: task.description,
status: task.status,
statusVersion: task.statusVersion,
priority: task.priority,
workMode: task.workMode,
assigneeAgentId: task.assigneeAgentId,
executionRunId: task.executionRunId,
parentId: task.parentId,
projectId: task.projectId,
goalId: task.goalId,
};
}

View File

@ -3,10 +3,236 @@ import { describe, expect, it } from "vitest";
import { BUILTIN_ADAPTER_TYPES } from "../../adapters/builtin-adapter-types.js";
import {
NativeRunnerSelectionError,
NativeRuntimeEligibilityError,
resolveHeartbeatNativeRuntimeMode,
resolveHeartbeatRuntimeMode,
resolveNativeRuntimeMode,
} from "./runtime-mode.js";
const base = {
const eligible = {
enabled: true,
runtimeConfig: {},
adapterConfig: { provider: "codex" },
agent: { status: "running", adapterType: "paperclip_runner" },
issue: { id: "issue", workMode: "standard" },
target: { kind: "local" },
workspaceId: "workspace",
} as const;
describe("resolveNativeRuntimeMode", () => {
it("keeps every direct built-in adapter outside native arbitration", () => {
for (const adapterType of BUILTIN_ADAPTER_TYPES) {
if (adapterType === "paperclip_runner") continue;
expect(resolveNativeRuntimeMode({
...eligible,
enabled: false,
runtimeConfig: {
nativeRunner: {
mode: "native",
backend: "codex_app_server",
protocolVersion: 1,
},
},
agent: { ...eligible.agent, adapterType },
})).toEqual({
kind: "legacy",
resolverVersion: "phase6-v1",
reason: "direct_adapter",
});
}
});
it("rejects a fresh Paperclip Runner start while the rollout flag is disabled", () => {
expect(() => resolveNativeRuntimeMode({
...eligible,
enabled: false,
})).toThrow(expect.objectContaining({
code: "paperclip_runner_rollout_disabled",
}));
});
it("rejects unknown Paperclip Runner providers", () => {
expect(() => resolveNativeRuntimeMode({
...eligible,
runtimeConfig: {},
adapterConfig: { provider: "claude" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toThrow(expect.objectContaining({
code: "paperclip_runner_provider_unsupported",
}));
});
it("rejects fresh OpenCode and ACPX starts until their app profiles are activated", () => {
expect(() => resolveNativeRuntimeMode({
...eligible,
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
})).toThrow(expect.objectContaining({
code: "paperclip_runner_provider_unsupported",
}));
expect(() => resolveNativeRuntimeMode({
...eligible,
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" },
})).toThrow(expect.objectContaining({
code: "paperclip_runner_provider_unsupported",
}));
});
it("preserves legacy as the default and as the kill-switch behavior", () => {
const direct = {
...eligible,
agent: { ...eligible.agent, adapterType: "codex_local" },
runtimeConfig: { nativeRunner: { mode: "native", backend: "codex_app_server", protocolVersion: 1 } },
};
expect(resolveNativeRuntimeMode(direct)).toEqual(expect.objectContaining({
kind: "legacy",
reason: "direct_adapter",
}));
expect(resolveNativeRuntimeMode({ ...direct, enabled: false })).toEqual(expect.objectContaining({
kind: "legacy",
reason: "direct_adapter",
}));
});
it("selects native only for an eligible explicit profile", () => {
expect(resolveNativeRuntimeMode(eligible)).toEqual(expect.objectContaining({
kind: "native",
reason: "eligible_opt_in",
}));
});
it("keeps a persisted active run native while the global flag rejects a fresh runner start", () => {
const disabled = { ...eligible, enabled: false };
expect(resolveHeartbeatNativeRuntimeMode({
...disabled,
persisted: {
runtimeMode: "native",
runtimeModeReason: "eligible_opt_in",
runtimeModeResolvedAt: new Date(),
},
})).toEqual(expect.objectContaining({
kind: "native",
reason: "eligible_opt_in",
authorityDecision: expect.objectContaining({ reasonCode: "live_continuation_registered" }),
}));
expect(() => resolveHeartbeatNativeRuntimeMode({
...disabled,
persisted: { runtimeMode: null, runtimeModeReason: null, runtimeModeResolvedAt: null },
})).toThrow(expect.objectContaining({
code: "paperclip_runner_rollout_disabled",
}));
});
it.each(["paused", "terminated", "pending_approval"])(
"refuses persisted native recovery for a %s agent",
(status) => {
expect(() => resolveHeartbeatNativeRuntimeMode({
...eligible,
enabled: false,
agent: { ...eligible.agent, status },
persisted: {
runtimeMode: "native",
runtimeModeReason: "eligible_opt_in",
runtimeModeResolvedAt: new Date(),
driverKind: "codex_app_server",
},
})).toThrow(expect.objectContaining({
code: "paperclip_runner_agent_ineligible",
}));
},
);
it("fails closed for an unknown persisted driver", () => {
expect(() => resolveHeartbeatNativeRuntimeMode({
...eligible,
enabled: false,
persisted: {
runtimeMode: "native",
runtimeModeReason: "eligible_opt_in",
runtimeModeResolvedAt: new Date(),
driverKind: "unknown_driver",
},
})).toThrow(expect.objectContaining({
code: "paperclip_runner_driver_unsupported",
}));
});
it("does not recover a native run through a direct adapter", () => {
expect(() => resolveHeartbeatNativeRuntimeMode({
...eligible,
enabled: false,
agent: { ...eligible.agent, adapterType: "codex_local" },
persisted: {
runtimeMode: "native",
runtimeModeReason: "eligible_opt_in",
runtimeModeResolvedAt: new Date(),
driverKind: "codex_app_server",
},
})).toThrow(expect.objectContaining({
code: "paperclip_runner_adapter_binding_mismatch",
}));
});
it("rejects an explicit native profile outside the approved boundary", () => {
expect(resolveNativeRuntimeMode({ ...eligible, agent: { ...eligible.agent, adapterType: "claude_local" } }))
.toEqual(expect.objectContaining({ kind: "legacy", reason: "direct_adapter" }));
expect(() => resolveNativeRuntimeMode({ ...eligible, issue: { id: "issue", workMode: "skill_test" } }))
.toThrow(NativeRuntimeEligibilityError);
});
it("rejects remote targets for fresh paperclip_runner starts", () => {
expect(() => resolveNativeRuntimeMode({
...eligible,
target: { kind: "remote" },
runtimeConfig: {},
adapterConfig: { provider: "codex" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toThrow(expect.objectContaining({
code: "paperclip_runner_environment_unsupported",
}));
});
it("allows paperclip_runner to use a transient local workspace for projectless issues", () => {
expect(resolveNativeRuntimeMode({
...eligible,
workspaceId: null,
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
runtimeConfig: {},
adapterConfig: { provider: "codex" },
})).toEqual(expect.objectContaining({ kind: "native" }));
});
it("admits planning only through paperclip_runner", () => {
expect(resolveNativeRuntimeMode({
...eligible,
issue: { id: "plan-issue", workMode: "planning" },
runtimeConfig: {},
adapterConfig: { provider: "codex" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
expect(resolveNativeRuntimeMode({
...eligible,
issue: { id: "plan-issue", workMode: "planning" },
agent: { ...eligible.agent, adapterType: "codex_local" },
})).toEqual(expect.objectContaining({ kind: "legacy", reason: "direct_adapter" }));
});
it("admits ask mode through paperclip_runner while preserving the legacy native boundary", () => {
expect(resolveNativeRuntimeMode({
...eligible,
issue: { id: "ask-issue", workMode: "ask" },
runtimeConfig: {},
adapterConfig: { provider: "codex" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
expect(resolveNativeRuntimeMode({
...eligible,
issue: { id: "ask-issue", workMode: "ask" },
agent: { ...eligible.agent, adapterType: "codex_local" },
})).toEqual(expect.objectContaining({ kind: "legacy", reason: "direct_adapter" }));
});
});
const compatibilityInput = {
persisted: { runtimeMode: "legacy", runtimeModeResolvedAt: null },
enabled: true,
adapterConfig: { provider: "codex" },
@ -15,11 +241,14 @@ const base = {
executionTarget: { kind: "local" },
} as const;
describe("resolveHeartbeatRuntimeMode", () => {
describe("resolveHeartbeatRuntimeMode compatibility", () => {
it("keeps every direct built-in adapter on the legacy path", () => {
for (const adapterType of BUILTIN_ADAPTER_TYPES) {
if (adapterType === "paperclip_runner") continue;
expect(resolveHeartbeatRuntimeMode({ ...base, adapterType })).toEqual({
expect(resolveHeartbeatRuntimeMode({
...compatibilityInput,
adapterType,
})).toEqual({
kind: "legacy",
resolverVersion: "paperclip-runner-v1",
reason: "direct_adapter",
@ -27,36 +256,18 @@ describe("resolveHeartbeatRuntimeMode", () => {
}
});
it("fails closed for fresh runner starts while the flag is off", () => {
expect(() => resolveHeartbeatRuntimeMode({
...base,
enabled: false,
adapterType: "paperclip_runner",
})).toThrowError(expect.objectContaining({
code: "paperclip_runner_rollout_disabled",
}) as NativeRunnerSelectionError);
});
it("selects only Codex on a local target", () => {
it("preserves the original public result and error contracts", () => {
expect(resolveHeartbeatRuntimeMode({
...base,
...compatibilityInput,
adapterType: "paperclip_runner",
})).toMatchObject({ kind: "native", provider: "codex" });
expect(() => resolveHeartbeatRuntimeMode({
...base,
adapterType: "paperclip_runner",
adapterConfig: { provider: "opencode" },
})).toThrow(/only the Codex provider/);
expect(() => resolveHeartbeatRuntimeMode({
...base,
adapterType: "paperclip_runner",
executionTarget: { kind: "remote" },
})).toThrow(/local execution environment/);
});
it("recovers a persisted native run after the flag changes", () => {
})).toEqual({
kind: "native",
resolverVersion: "paperclip-runner-v1",
reason: "explicit_paperclip_runner",
provider: "codex",
});
expect(resolveHeartbeatRuntimeMode({
...base,
...compatibilityInput,
enabled: false,
adapterType: "paperclip_runner",
persisted: { runtimeMode: "native", runtimeModeResolvedAt: new Date() },
@ -66,5 +277,10 @@ describe("resolveHeartbeatRuntimeMode", () => {
reason: "persisted_native_selection",
provider: "codex",
});
expect(() => resolveHeartbeatRuntimeMode({
...compatibilityInput,
enabled: false,
adapterType: "paperclip_runner",
})).toThrow(NativeRunnerSelectionError);
});
});

View File

@ -1,5 +1,19 @@
import {
NATIVE_STATUS_ARBITER_POLICY_VERSION,
type NativeAuthoritativeIssueStatus,
type NativeStatusDecision,
} from "./status-arbiter.js";
/**
* Public compatibility resolver version. This value is persisted by the
* original heartbeat selection seam and must remain stable for existing runs
* and downstream importers.
*/
export const NATIVE_RUNTIME_RESOLVER_VERSION = "paperclip-runner-v1" as const;
/** Resolver version for the richer native runtime profile used by runnerd. */
export const NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION = "phase6-v1" as const;
export type HeartbeatRuntimeResolution =
| {
kind: "legacy";
@ -13,6 +27,25 @@ export type HeartbeatRuntimeResolution =
provider: "codex";
};
export type NativeRuntimeResolution =
| {
kind: "legacy";
resolverVersion: typeof NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION;
reason: string;
authorityDecision?: NativeStatusDecision;
}
| {
kind: "native";
resolverVersion: typeof NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION;
reason: "eligible_opt_in";
profile: {
mode: "native";
backend: "codex_app_server";
protocolVersion: 1;
};
authorityDecision: NativeStatusDecision;
};
export class NativeRunnerSelectionError extends Error {
constructor(readonly code: string, message: string) {
super(message);
@ -20,13 +53,117 @@ export class NativeRunnerSelectionError extends Error {
}
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
export class NativeRuntimeEligibilityError extends NativeRunnerSelectionError {
constructor(
code: string,
reason?: string,
) {
super(code, reason ?? `Native runner profile is ineligible: ${code}`);
this.name = "NativeRuntimeEligibilityError";
}
}
/** Resolve a run once. Persisted selections do not consult a later flag change. */
function ineligible(
code: string,
reason: string,
): NativeRuntimeEligibilityError {
return new NativeRuntimeEligibilityError(
code,
reason,
);
}
export function resolveNativeRuntimeMode(input: {
enabled: boolean;
runtimeConfig: unknown;
adapterConfig?: unknown;
agent: { id?: string; status: string; adapterType: string | null };
issue: { id: string; workMode: string; executionWorkspaceId?: string | null } | null;
target: { kind?: string } | null | undefined;
workspaceId: string | null;
}): NativeRuntimeResolution {
const runnerAdapterSelected = input.agent.adapterType === "paperclip_runner";
// Fresh direct-adapter runs never enter the native control plane, even if an
// obsolete runtimeConfig.nativeRunner value is still present. Persisted
// native runs are handled by resolveHeartbeatNativeRuntimeMode above this
// fresh-selection seam so they remain recoverable after rollout changes.
if (!runnerAdapterSelected) {
return {
kind: "legacy",
resolverVersion: NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION,
reason: "direct_adapter",
};
}
if (!input.enabled) {
throw ineligible(
"paperclip_runner_rollout_disabled",
"Paperclip Runner is experimental and disabled on this instance.",
);
}
const adapterConfig = input.adapterConfig;
const runnerProvider =
typeof adapterConfig === "object"
&& adapterConfig !== null
&& !Array.isArray(adapterConfig)
? (adapterConfig as Record<string, unknown>).provider ?? "codex"
: "codex";
if (runnerProvider !== "codex") {
throw ineligible(
"paperclip_runner_provider_unsupported",
"Paperclip Runner currently supports only the Codex provider.",
);
}
if (
input.agent.adapterType !== "paperclip_runner"
|| input.agent.status !== "active" && input.agent.status !== "running"
) {
throw ineligible(
"paperclip_runner_agent_ineligible",
"Paperclip Runner requires an active agent.",
);
}
const allowedWorkModes = ["standard", "planning", "ask"];
if (!input.issue || !allowedWorkModes.includes(input.issue.workMode)) {
throw ineligible(
"paperclip_runner_issue_ineligible",
"Paperclip Runner requires a standard, planning, or ask task.",
);
}
if (!input.target || input.target.kind !== "local") {
throw ineligible(
"paperclip_runner_environment_unsupported",
"Paperclip Runner currently requires a local execution environment.",
);
}
const rollout = resolveNativeMigrationStatus({
facts: { applicationEnabled: true },
priorIssueStatus: "in_progress",
agentId: input.agent.id ?? "00000000-0000-4000-8000-000000000000",
});
if (!rollout.effects.some((effect) => effect.kind === "record_mode_native")) {
throw ineligible(
"paperclip_runner_rollout_policy_rejected",
"Native rollout policy did not select native mode.",
);
}
return {
kind: "native",
resolverVersion: NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION,
reason: "eligible_opt_in",
profile: {
mode: "native",
backend: "codex_app_server",
protocolVersion: 1,
},
authorityDecision: rollout,
};
}
/**
* Backward-compatible heartbeat selection API. New runnerd code consumes the
* richer profile from resolveHeartbeatNativeRuntimeMode; this public seam
* keeps the original result shape, reason codes, and resolver version.
*/
export function resolveHeartbeatRuntimeMode(input: {
persisted: {
runtimeMode: string | null;
@ -55,44 +192,35 @@ export function resolveHeartbeatRuntimeMode(input: {
};
}
if (input.adapterType !== "paperclip_runner") {
let resolution: NativeRuntimeResolution;
try {
resolution = resolveNativeRuntimeMode({
enabled: input.enabled,
runtimeConfig: {},
adapterConfig: input.adapterConfig,
agent: {
status: input.agentStatus,
adapterType: input.adapterType,
},
issue: input.issue
? { id: "heartbeat-runtime-selection", workMode: input.issue.workMode }
: null,
target: input.executionTarget,
workspaceId: null,
});
} catch (error) {
if (error instanceof NativeRuntimeEligibilityError) {
throw new NativeRunnerSelectionError(error.code, error.message);
}
throw error;
}
if (resolution.kind === "legacy") {
return {
kind: "legacy",
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
reason: "direct_adapter",
};
}
if (!input.enabled) {
throw new NativeRunnerSelectionError(
"paperclip_runner_rollout_disabled",
"Paperclip Runner is experimental and disabled on this instance.",
);
}
const provider = record(input.adapterConfig).provider ?? "codex";
if (provider !== "codex") {
throw new NativeRunnerSelectionError(
"paperclip_runner_provider_unsupported",
"Paperclip Runner currently supports only the Codex provider.",
);
}
if (!input.issue || !["standard", "planning", "ask"].includes(input.issue.workMode)) {
throw new NativeRunnerSelectionError(
"paperclip_runner_issue_ineligible",
"Paperclip Runner requires a standard, planning, or ask task.",
);
}
if (!input.executionTarget || input.executionTarget.kind !== "local") {
throw new NativeRunnerSelectionError(
"paperclip_runner_environment_unsupported",
"Paperclip Runner currently requires a local execution environment.",
);
}
if (!["active", "running"].includes(input.agentStatus)) {
throw new NativeRunnerSelectionError(
"paperclip_runner_agent_ineligible",
"Paperclip Runner requires an active agent.",
);
}
return {
kind: "native",
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
@ -100,3 +228,281 @@ export function resolveHeartbeatRuntimeMode(input: {
provider: "codex",
};
}
/**
* Production heartbeat selection seam. A resolved run keeps its persisted
* mode across configuration changes; only a fresh unresolved run consults the
* current global flag and agent profile.
*/
export function resolveHeartbeatNativeRuntimeMode(input: {
persisted: {
runtimeMode: string | null;
runtimeModeReason: string | null;
runtimeModeResolvedAt: Date | null;
driverKind?: string | null;
};
enabled: boolean;
runtimeConfig: unknown;
adapterConfig?: unknown;
agent: { id?: string; status: string; adapterType: string | null };
issue: { id: string; workMode: string; executionWorkspaceId?: string | null } | null;
target: { kind?: string } | null | undefined;
workspaceId: string | null;
}): NativeRuntimeResolution {
if (input.persisted.runtimeModeResolvedAt) {
if (input.persisted.runtimeMode === "native") {
if (input.agent.adapterType !== "paperclip_runner") {
throw ineligible(
"paperclip_runner_adapter_binding_mismatch",
"A persisted native run must remain bound to the Paperclip Runner adapter.",
);
}
if (
input.agent.status !== "active" &&
input.agent.status !== "running"
) {
throw ineligible(
"paperclip_runner_agent_ineligible",
"A persisted Paperclip Runner run cannot recover through a non-invokable agent.",
);
}
const driverKind = input.persisted.driverKind;
const backend = driverKind === null
|| driverKind === undefined
|| driverKind === "codex"
|| driverKind === "codex_app_server"
? "codex_app_server"
: null;
if (!backend) {
throw ineligible(
"paperclip_runner_driver_unsupported",
`Persisted Paperclip Runner driver is unsupported: ${driverKind}`,
);
}
return {
kind: "native",
resolverVersion: NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION,
reason: "eligible_opt_in",
profile: {
mode: "native",
backend,
protocolVersion: 1,
},
authorityDecision: resolveNativeMigrationStatus({
facts: input.enabled
? { applicationEnabled: true }
: { killSwitchActiveForNewRuns: true },
priorIssueStatus: "in_progress",
agentId: input.agent.id ?? "00000000-0000-4000-8000-000000000000",
}),
};
}
return {
kind: "legacy",
resolverVersion: NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION,
reason: input.persisted.runtimeModeReason ?? "persisted_legacy_selection",
};
}
return resolveNativeRuntimeMode(input);
}
/** Production read-model facts used by compatibility and mixed-ledger views. */
export function inspectNativeCompatibilityState(input: {
resolution: NativeRuntimeResolution;
nativeRecordCount: number;
decisionCount: number;
issueStatus: string;
statusVersion: number;
persistedEffectKinds: string[];
}) {
const effects = input.persistedEffectKinds.length > 0
? [...input.persistedEffectKinds]
: input.resolution.kind === "legacy"
? ["legacy_existing_behavior"]
: input.nativeRecordCount === 0 && input.statusVersion === 0
? ["initialize_status_version_zero"]
: [];
return {
mode: input.resolution.kind,
native: input.nativeRecordCount > 0,
hasNativeDecisionLineage: input.decisionCount > 0,
issueStatus: input.issueStatus,
statusVersion: input.statusVersion,
statusAction: input.resolution.kind === "legacy" ? "legacy_finalizer" : "preserve",
reasonCode: null,
effects,
} as const;
}
/** Expand-only migration evidence; it never mutates or synthesizes history. */
export function inspectNativeMigrationState(input: {
resolution: NativeRuntimeResolution;
nativeRecordCount: number;
decisionCount: number;
issueStatusBefore: string;
issueStatusAfter: string;
statusVersion: number;
hasPendingReview: boolean;
}) {
const effects = input.resolution.kind === "legacy"
? input.issueStatusBefore === "done"
? ["retain_legacy_mode", "retain_audit_lineage"]
: ["return_native_false"]
: input.nativeRecordCount === 0 && input.hasPendingReview && input.statusVersion > 0
? ["increment_status_version_once", "bind_reviewer"]
: input.nativeRecordCount === 0
? ["expand_schema", "status_version_default_zero"]
: [];
return {
mode: input.resolution.kind,
native: input.nativeRecordCount > 0,
hasSyntheticHistory: input.nativeRecordCount === 0 && input.decisionCount > 0,
statusPreserved: input.issueStatusBefore === input.issueStatusAfter,
statusVersion: input.statusVersion,
statusAction: input.resolution.kind === "legacy" ? "legacy_finalizer"
: input.hasPendingReview ? input.issueStatusAfter : "preserve",
reasonCode: null,
effects,
} as const;
}
export type NativeCompatibilityFacts = {
invalidNativeFinalization?: boolean;
terminalResumeAuthorized?: boolean;
shadowApplicationDisabled?: boolean;
mixedLedger?: boolean;
statusWriterAdvancedVersion?: boolean;
};
export function resolveNativeCompatibilityStatus(input: {
facts: NativeCompatibilityFacts;
priorIssueStatus: NativeAuthoritativeIssueStatus;
agentId: string;
}): NativeStatusDecision {
const preserve = (reasonCode: string, effects: NativeStatusDecision["effects"]): NativeStatusDecision => ({
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "preserve",
toStatus: input.priorIssueStatus,
reasonCode,
unblockDescriptor: null,
effects,
});
if (input.facts.invalidNativeFinalization) {
return preserve("native_finalization_invalid", [{
kind: "record_finalization_error",
cause: "native_finalization_invalid",
nextAction: "Repair the persisted native result.",
agentId: input.agentId,
}]);
}
if (input.facts.terminalResumeAuthorized) {
return {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "in_progress",
toStatus: "in_progress",
reasonCode: "authorized_resume",
unblockDescriptor: null,
effects: [{
kind: "enqueue_continuation",
continuationKind: "same_agent",
summary: "Resume the terminal issue through the authorized compatibility path.",
idempotencyKey: "native-compatibility:authorized-resume",
agentId: input.agentId,
}],
};
}
if (input.facts.shadowApplicationDisabled) {
return preserve("completion_contract_satisfied", [{ kind: "record_shadow_decision" }]);
}
if (input.facts.mixedLedger) {
return preserve("completion_contract_satisfied", [{ kind: "render_four_layers" }]);
}
if (input.facts.statusWriterAdvancedVersion) {
return preserve("arbitration_conflict_reloaded", [
{ kind: "increment_status_version" },
{ kind: "schedule_reconciliation" },
]);
}
throw new Error("native_compatibility_facts_invalid");
}
export type NativeMigrationFacts = {
shadowMaterialization?: boolean;
classifiedDivergence?: boolean;
applicationEnabled?: boolean;
policyPinned?: boolean;
killSwitchActiveForNewRuns?: boolean;
};
export function resolveNativeMigrationStatus(input: {
facts: NativeMigrationFacts;
priorIssueStatus: NativeAuthoritativeIssueStatus;
agentId: string;
}): NativeStatusDecision {
const preserve = (reasonCode: string, effects: NativeStatusDecision["effects"]): NativeStatusDecision => ({
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "preserve",
toStatus: input.priorIssueStatus,
reasonCode,
unblockDescriptor: null,
effects,
});
if (input.facts.shadowMaterialization) {
return preserve("completion_contract_satisfied", [
{ kind: "materialize_contract" },
{ kind: "record_shadow_decision" },
]);
}
if (input.facts.classifiedDivergence) {
return preserve("completion_evidence_incomplete", [{ kind: "record_mode_labeled_divergence" }]);
}
if (input.facts.killSwitchActiveForNewRuns) {
return {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "in_progress",
toStatus: "in_progress",
reasonCode: "live_continuation_registered",
unblockDescriptor: null,
effects: [
{
kind: "enqueue_continuation",
continuationKind: "same_agent",
summary: "Finish the already-active run in native mode.",
idempotencyKey: "native-migration:kill-switch-active-run",
agentId: input.agentId,
},
{ kind: "finish_as_native" },
],
};
}
if (input.facts.policyPinned) {
return {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "done",
toStatus: "done",
reasonCode: "completion_contract_satisfied",
unblockDescriptor: null,
effects: [{ kind: "record_mode_native" }, { kind: "record_policy_version" }],
};
}
if (input.facts.applicationEnabled) {
return {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "in_progress",
toStatus: "in_progress",
reasonCode: "live_continuation_registered",
unblockDescriptor: null,
effects: [
{
kind: "enqueue_continuation",
continuationKind: "same_agent",
summary: "Continue the allowlisted native run.",
idempotencyKey: "native-migration:application-enabled",
agentId: input.agentId,
},
{ kind: "record_mode_native" },
],
};
}
throw new Error("native_migration_facts_invalid");
}

View File

@ -1765,6 +1765,7 @@ export function createPluginWorkerHandle(
interface HeldDuplexExitEvent {
workerSessionId: string;
exitCode: number | null;
transportClosed?: boolean;
}
interface DuplexChannelRoute {
@ -2169,7 +2170,11 @@ export function createPluginWorkerHandle(
// Normalize the exit to the narrow duplex-event schema. A replaced exit
// simply overwrites the earlier held exit.
const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
route.preBindExit = { workerSessionId, exitCode };
route.preBindExit = {
workerSessionId,
exitCode,
...(params.transportClosed === true ? { transportClosed: true } : {}),
};
return;
}
// A data event. Validate and normalize it to the narrow duplex-event schema
@ -2237,6 +2242,7 @@ export function createPluginWorkerHandle(
hostRouteId: route.hostRouteId,
workerSessionId: heldExit.workerSessionId,
exitCode: heldExit.exitCode,
...(heldExit.transportClosed === true ? { transportClosed: true } : {}),
},
});
}

View File

@ -26,13 +26,10 @@ export type {
ControlPlanePort,
HarnessRuntimeRequestKind,
HarnessRuntimeRequestResolution,
NativeAcpxAgent,
NativeAcpxPermissionMode,
NativeCodexApprovalPolicy,
NativeExecutionInput,
NativeExecutionInputV4,
NativeInteractionResponseEnvelope,
NativeOpenCodePermissionMode,
NativePlanningContext,
NativeRunEvent,
NativeRunResult,
@ -64,6 +61,8 @@ const runner = await import(sourceUrl.href) as RunnerModule;
export const DurablePrpControlPlane = runner.DurablePrpControlPlane;
export const PaperclipSemanticDispatcher = runner.PaperclipSemanticDispatcher;
export const CAPABILITY_SEMANTIC_TOOL_CATALOG =
runner.CAPABILITY_SEMANTIC_TOOL_CATALOG;
export const HarnessRuntimeRequestResolutionError =
runner.HarnessRuntimeRequestResolutionError;
export const NATIVE_RUNTIME_ASSET_SCHEMA = runner.NATIVE_RUNTIME_ASSET_SCHEMA;
@ -75,6 +74,12 @@ export const canonicalNativeRuntimeContextDigest =
export const createNativeSessionBackend = runner.createNativeSessionBackend;
export const createPaperclipRunnerAuthorizedToolSet =
runner.createPaperclipRunnerAuthorizedToolSet;
export const createRunnerdCodexTransport: (
options?: import("@paperclipai/paperclip-runner").RunnerdCodexTransportOptions,
) => import("@paperclipai/paperclip-runner").RunnerdCodexTransport =
runner.createRunnerdCodexTransport;
export const defaultCapabilityRunnerdBinary =
runner.defaultCapabilityRunnerdBinary;
export const executeNativeSession = runner.executeNativeSession;
export const nativeRuntimePromptDigest = runner.nativeRuntimePromptDigest;
export const normalizePrpResultSignals = runner.normalizePrpResultSignals;

View File

@ -8,17 +8,50 @@
*/
type RunnerTestingModule = typeof import("@paperclipai/paperclip-runner/testing");
export type {
CapabilityCommandEnvelope,
CapabilityCommandOutcome,
CapabilityCommandResult,
CapabilityFixtureState,
CapabilityRunContext,
CapabilitySemanticCommand,
SemanticConformanceAdapter,
SemanticConformanceObservation,
SemanticConformanceVector,
} from "@paperclipai/paperclip-runner/testing";
const sourceUrl = new URL(
"../../../../packages/paperclip-runner/src/testing.ts",
import.meta.url,
);
const runnerTesting = await import(sourceUrl.href) as RunnerTestingModule;
export const CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS:
RunnerTestingModule["CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS"] =
runnerTesting.CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS;
export const CAPABILITY_SEMANTIC_CONFORMANCE_IDS:
RunnerTestingModule["CAPABILITY_SEMANTIC_CONFORMANCE_IDS"] =
runnerTesting.CAPABILITY_SEMANTIC_CONFORMANCE_IDS;
export const CapabilityMockSemanticConformanceAdapter:
RunnerTestingModule["CapabilityMockSemanticConformanceAdapter"] =
runnerTesting.CapabilityMockSemanticConformanceAdapter;
export const CapabilitySemanticDispatcher:
RunnerTestingModule["CapabilitySemanticDispatcher"] =
runnerTesting.CapabilitySemanticDispatcher;
export const CONTROL_PLANE_CONFORMANCE_OPEN =
runnerTesting.CONTROL_PLANE_CONFORMANCE_OPEN;
export const CONTROL_PLANE_CONFORMANCE_RESULT =
runnerTesting.CONTROL_PLANE_CONFORMANCE_RESULT;
export const CONTROL_PLANE_CONFORMANCE_TERMINAL =
runnerTesting.CONTROL_PLANE_CONFORMANCE_TERMINAL;
export const createCapabilityFixtureState:
RunnerTestingModule["createCapabilityFixtureState"] =
runnerTesting.createCapabilityFixtureState;
export const normalizeCapabilitySemanticObservation:
RunnerTestingModule["normalizeCapabilitySemanticObservation"] =
runnerTesting.normalizeCapabilitySemanticObservation;
export const runControlPlanePortConformance =
runnerTesting.runControlPlanePortConformance;
export const runSemanticConformanceKit:
RunnerTestingModule["runSemanticConformanceKit"] =
runnerTesting.runSemanticConformanceKit;