mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## 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
343 lines
14 KiB
JavaScript
343 lines
14 KiB
JavaScript
// Test worker fixture for the host-owned duplex channel route state machine. The
|
|
// fixture drives the manager route state machine through the four typed methods
|
|
// (open, write, stop, close) and the data and exit notifications.
|
|
//
|
|
// The duplex channel is generic. It carries no command allowlist. The test
|
|
// encodes a JSON directive in the forwarded `providerLeaseId`, so one fixture
|
|
// serves every route case:
|
|
// - `mode`: "normal" | "malformed-open" | "no-open-reply" | "duplicate-open-reply" |
|
|
// "no-write-reply"
|
|
// - `workerSessionId`: the worker session id the open reply returns (default "ws-1")
|
|
// - `data`: an array of `{ chunk, sid?, rid? }`. The fixture emits each as a
|
|
// data notification after the open reply. `sid` defaults to the real worker
|
|
// session id and `rid` defaults to the real host route id; a test sets a wrong
|
|
// `sid` or `rid` to prove the host drops a mismatched-pair notification and
|
|
// counts a protocol error.
|
|
// - `exitCode`: when set, the fixture emits an exit notification after the data.
|
|
// - `transportClosed`: when true, the exit notification marks a reason-less
|
|
// transport close and carries no exit code.
|
|
// - `dataAfterExit`: an array of `{ chunk, sid? }`, same shape as `data`. The
|
|
// fixture emits these as data notifications after the exit notification, so a
|
|
// test can script a batch where a data frame arrives after the exit in the
|
|
// read order — proving the host still bounds these frames on their own terms
|
|
// and never lets the exit crowd a data frame out of the pre-open hold.
|
|
// - `echoInput`: when true, the fixture echoes each `duplexChannelWrite` back as
|
|
// one data notification for the bound session, prefixed with `echo:` at the
|
|
// byte level (so the echo round-trips a non-UTF-8 payload unchanged).
|
|
//
|
|
// The JSON-RPC hop carries a duplex chunk as a base64 string (see
|
|
// `ChannelBytesWireValue` in packages/plugins/sdk/src/protocol.ts). This fixture
|
|
// is a hand-rolled worker, not the real SDK, so it encodes and decodes base64
|
|
// itself with the two helpers below. A `chunk` directive value in a test is
|
|
// always the plain-text payload; the fixture is the one place that puts it on
|
|
// the wire as base64.
|
|
// - `writeReplyDelayMs`: when a positive number, the fixture delays each
|
|
// `duplexChannelWrite` reply by that many milliseconds, so a test proves the
|
|
// host holds the pending-write reservation until the RPC settles.
|
|
// - `stopReadingStdinAfterOpen`: when true, the fixture stops reading its stdin
|
|
// right after it sends the open reply. The host writes then stay in the host
|
|
// stdin write buffer, so a test proves the host meters the transport buffer and
|
|
// never releases the transport token on the write-RPC timeout.
|
|
// - `exitAfterStopMs`: when a positive number, and the fixture stopped reading
|
|
// stdin, the fixture exits after that many milliseconds. The worker exit
|
|
// discards the stdin buffer, so a test proves the host releases every held
|
|
// transport token on the worker exit. It gives a fast exit for a worker that no
|
|
// longer reads its stdin and never sees the shutdown request.
|
|
// - `closeMode`: "ack" | "bad-ack" | "no-ack" (default "ack"). It controls the
|
|
// close reply, so a test proves the host retires the worker on an unconfirmed
|
|
// close.
|
|
// - `batchWithOpenReply`: when true, the fixture writes the open reply and the
|
|
// 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) {
|
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
}
|
|
|
|
// Encode one plain-text directive chunk to the wire-safe base64 form. The real
|
|
// worker (worker-rpc-host.ts, `encodeChannelBytes`) does the same encoding.
|
|
function toWireChunk(text) {
|
|
return Buffer.from(text, "utf8").toString("base64");
|
|
}
|
|
|
|
// Decode the wire-safe base64 form of one host→worker write back to a Buffer,
|
|
// so the echo path works on exact bytes rather than a UTF-8 string. This keeps
|
|
// the echo byte-exact for a payload that is not valid UTF-8.
|
|
function fromWireChunk(wireValue) {
|
|
return Buffer.from(wireValue, "base64");
|
|
}
|
|
|
|
// Serialize the scripted data and exit frames as newline-delimited lines. The
|
|
// batch mode writes these together with the open reply in one stdout write. Each
|
|
// frame carries the exact pair, so the host binds and routes by the pair. A test
|
|
// sets a wrong `sid` or `rid` to force a mismatch.
|
|
function scriptedFrameLines(directive, hostRouteId, workerSessionId) {
|
|
const data = Array.isArray(directive.data) ? directive.data : [];
|
|
let lines = "";
|
|
for (const entry of data) {
|
|
lines += `${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "duplexChannel.data",
|
|
params: {
|
|
hostRouteId: entry.rid ?? hostRouteId,
|
|
workerSessionId: entry.sid ?? workerSessionId,
|
|
chunk: toWireChunk(entry.chunk),
|
|
},
|
|
})}\n`;
|
|
}
|
|
if (typeof directive.exitCode === "number" || directive.transportClosed === true) {
|
|
const exitParams = {
|
|
hostRouteId,
|
|
workerSessionId,
|
|
exitCode: typeof directive.exitCode === "number" ? directive.exitCode : null,
|
|
};
|
|
// A transport-close directive marks a reason-less transport close, so the
|
|
// fixture carries the discriminator the same way a real worker does.
|
|
if (directive.transportClosed === true) exitParams.transportClosed = true;
|
|
lines += `${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "duplexChannel.exit",
|
|
params: exitParams,
|
|
})}\n`;
|
|
}
|
|
const dataAfterExit = Array.isArray(directive.dataAfterExit) ? directive.dataAfterExit : [];
|
|
for (const entry of dataAfterExit) {
|
|
lines += `${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "duplexChannel.data",
|
|
params: {
|
|
hostRouteId: entry.rid ?? hostRouteId,
|
|
workerSessionId: entry.sid ?? workerSessionId,
|
|
chunk: toWireChunk(entry.chunk),
|
|
},
|
|
})}\n`;
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
// The registered channels, keyed by the host route id. Each entry records the
|
|
// bound worker session id and the close directive.
|
|
const routes = new Map();
|
|
|
|
function parseDirective(raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && typeof parsed === "object" ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
|
|
rl.on("line", (line) => {
|
|
if (!line.trim()) return;
|
|
const message = JSON.parse(line);
|
|
const method = message && typeof message.method === "string" ? message.method : null;
|
|
const params = message.params ?? {};
|
|
|
|
if (method === "initialize") {
|
|
send({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
ok: true,
|
|
supportedMethods: [
|
|
"duplexChannelOpen",
|
|
"duplexChannelWrite",
|
|
"duplexChannelStop",
|
|
"duplexChannelClose",
|
|
],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "duplexChannelOpen") {
|
|
const directive = parseDirective(params.providerLeaseId);
|
|
const mode = directive.mode ?? "normal";
|
|
const workerSessionId = directive.workerSessionId ?? "ws-1";
|
|
const closeMode = directive.closeMode ?? "ack";
|
|
const hostRouteId = params.hostRouteId;
|
|
routes.set(hostRouteId, {
|
|
hostRouteId,
|
|
workerSessionId,
|
|
closeMode,
|
|
echoInput: directive.echoInput === true,
|
|
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,
|
|
});
|
|
|
|
if (mode === "no-open-reply") {
|
|
// Never reply, so the host open call times out.
|
|
return;
|
|
}
|
|
if (mode === "malformed-open") {
|
|
// Reply with no worker session id, so the host terminalizes the route.
|
|
send({ jsonrpc: "2.0", id: message.id, result: {} });
|
|
return;
|
|
}
|
|
|
|
// Echo the host route id on the reply, so the host binds the exact pair.
|
|
const openReplyLine = `${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: { hostRouteId, workerSessionId },
|
|
})}\n`;
|
|
|
|
if (directive.batchWithOpenReply === true) {
|
|
// Write the open reply and the scripted frames in one stdout write. The
|
|
// host reads them in one batch, so a data or exit frame arrives before the
|
|
// route binds. The host must hold and replay the frame after the bind.
|
|
process.stdout.write(openReplyLine + scriptedFrameLines(directive, hostRouteId, workerSessionId));
|
|
return;
|
|
}
|
|
|
|
const reply = () => process.stdout.write(openReplyLine);
|
|
reply();
|
|
if (mode === "duplicate-open-reply") {
|
|
// Send a second open reply for the same request id. The host drops it.
|
|
reply();
|
|
}
|
|
|
|
if (directive.stopReadingStdinAfterOpen === true) {
|
|
// Stop reading stdin, so every later host write stays in the host stdin
|
|
// write buffer. The host meters the transport buffer and holds the transport
|
|
// token until the stream flush, the stream error, or the worker exit.
|
|
rl.pause();
|
|
process.stdin.pause();
|
|
if (typeof directive.exitAfterStopMs === "number" && directive.exitAfterStopMs >= 0) {
|
|
// Exit after the delay, so a test observes the worker exit release the held
|
|
// transport tokens without a slow shutdown drain on an unread stdin.
|
|
setTimeout(() => process.exit(0), directive.exitAfterStopMs);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 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.
|
|
if (directive.emitScriptedFramesAfterFirstWrite !== true) {
|
|
setImmediate(() => {
|
|
process.stdout.write(scriptedFrameLines(directive, hostRouteId, workerSessionId));
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (method === "duplexChannelWrite") {
|
|
// Act only on the exact live pair. A write whose pair does not match a bound
|
|
// route applies no bytes.
|
|
const entry = routes.get(params.hostRouteId);
|
|
if (!entry || entry.workerSessionId !== params.workerSessionId) {
|
|
send({ jsonrpc: "2.0", id: message.id, result: null });
|
|
return;
|
|
}
|
|
if (entry.noWriteReply) {
|
|
// Never reply, so the host write call stays pending. The test proves the
|
|
// host ends the route on the pending-request bound.
|
|
return;
|
|
}
|
|
if (entry.echoInput) {
|
|
// Echo the input back as one data notification for the bound pair, so a
|
|
// test proves the input reaches the worker and the output routes back.
|
|
// Work on the decoded Buffer, not a string, so a non-UTF-8 payload (the
|
|
// full 256-value byte corpus) echoes byte-exact under the `echo:` prefix.
|
|
const echoBytes = Buffer.concat([Buffer.from("echo:", "utf8"), fromWireChunk(params.data)]);
|
|
send({
|
|
jsonrpc: "2.0",
|
|
method: "duplexChannel.data",
|
|
params: {
|
|
hostRouteId: entry.hostRouteId,
|
|
workerSessionId: entry.workerSessionId,
|
|
chunk: echoBytes.toString("base64"),
|
|
},
|
|
});
|
|
}
|
|
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.
|
|
setTimeout(replyWrite, entry.writeReplyDelayMs);
|
|
} else {
|
|
replyWrite();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (method === "duplexChannelStop") {
|
|
send({ jsonrpc: "2.0", id: message.id, result: null });
|
|
return;
|
|
}
|
|
|
|
if (method === "duplexChannelClose") {
|
|
const entry = routes.get(params.hostRouteId);
|
|
routes.delete(params.hostRouteId);
|
|
const closeMode = entry ? entry.closeMode : "ack";
|
|
if (closeMode === "no-ack") {
|
|
// Never reply, so the host close call times out and the host retires us.
|
|
return;
|
|
}
|
|
if (closeMode === "bad-ack") {
|
|
send({ jsonrpc: "2.0", id: message.id, result: { hostRouteId: "mismatched-route" } });
|
|
return;
|
|
}
|
|
// A bound close echoes both identifiers; a pre-bind close with no entry
|
|
// echoes the host route id only.
|
|
const ack = entry
|
|
? { hostRouteId: params.hostRouteId, workerSessionId: entry.workerSessionId }
|
|
: { hostRouteId: params.hostRouteId };
|
|
send({ jsonrpc: "2.0", id: message.id, result: ack });
|
|
if (entry && entry.emitAfterCloseChunk) {
|
|
// Emit one late data frame for the just-closed pair, so a test proves the
|
|
// host drops a late frame for a tombstoned pair and does not retire the
|
|
// worker.
|
|
setImmediate(() => {
|
|
send({
|
|
jsonrpc: "2.0",
|
|
method: "duplexChannel.data",
|
|
params: {
|
|
hostRouteId: entry.hostRouteId,
|
|
workerSessionId: entry.workerSessionId,
|
|
chunk: toWireChunk(entry.emitAfterCloseChunk),
|
|
},
|
|
});
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (method === "shutdown") {
|
|
send({ jsonrpc: "2.0", id: message.id, result: {} });
|
|
setImmediate(() => process.exit(0));
|
|
return;
|
|
}
|
|
|
|
send({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
error: { code: -32601, message: `Unhandled method: ${method}` },
|
|
});
|
|
});
|