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:
parent
560e7e48b5
commit
51ad751e0b
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ describe("adapter routes", () => {
|
|||
disabled: false,
|
||||
capabilities: {
|
||||
supportsInstructionsBundle: false,
|
||||
supportsModelProfiles: false,
|
||||
supportsModelProfiles: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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\"");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue