Files
PaperClipAI/packages/paperclip-runner/test/fixtures/fake-durable-codex-app-server.mjs
DottaandPaperclip 3b550c80fa fix(codex): correct startup trust, history reads, and resume usage (#13110)
## Thinking Path

> - Paperclip runs Codex locally and in remote sandboxes.
> - The runner must preserve startup configuration and session identity.
> - Missing project trust can disable repository configuration.
> - Full-history requests use deprecated provider fields.
> - Resume usage describes old work and must not become new run usage.
> - This change corrects startup trust, state reads, and usage
classification.

## Linked Issues or Issue Description

**What happened?**

Normal Codex runs could show repository-trust and history-deprecation
warnings.
Resume could report the preceding turn's token snapshot as a late-turn
warning.
The historical last-usage value could also be attributed to the new run.

**Expected behavior**

Trust the server-selected startup root in isolated configuration. Read
lightweight
provider state and paginated evidence. Use historical cumulative usage
as a
baseline without a new charge or user-facing warning.

**Steps to reproduce**

1. Start a native Codex task in a selected repository.
2. Finish the turn and resume the provider thread.
3. Inspect provider notices, history requests, and per-run usage.
4. Repeat startup and cold resume inside a Daytona sandbox.

**Paperclip version or commit**

Codex CLI 0.153.4 is the pinned runtime and reproduced baseline.
Replayed onto master at 6abeb6733. Related authority work: Refs #13092.
This PR retains its startup cleanup and protocol-integrity checks.

**Deployment mode**

Local source checkout and disposable Daytona sandbox.

## What Changed

- Classify the exact historical resume usage event before the generic
stale-turn warning.
- Persist cumulative usage baselines across recovery of the same run.
- Use excludeTurns on resume and lightweight thread reads.
- Page turn metadata and selected turn items with cursor and identity
validation.
- Reject unsupported or incomplete history instead of guessing that
execution is idle.
- Trust the startup execution root on its host, including Git worktree
trust keys.
- Start Codex in that root and retain the selected sandbox profile on
later turns.
- Keep unrelated isolated configuration and Codex's separate hook trust
policy.
- Add Rust, TypeScript, accounting, native integration, and local
run-log documentation.

## Verification

- Codex and native-transport TypeScript: 333 passed before PR replay.
- Adjacent OpenCode/ACPX driver and accounting tests: 49 passed.
- Rust library, serialized: 226 passed. Native Codex integration: 72
passed, 1 ignored, plus two pagination regressions.
- Repository typecheck and build passed. All repository test groups have
passing coverage after fixture and resource retests; the initial
monolithic command was not clean.
- Fresh real Codex native browser tasks returned correct answers without
the three targeted notices. Answers persisted after refresh and restart.
- Real same-thread TypeScript driver tests passed locally and in
Daytona, including cold resume, configuration, skills, and an approved
harmless hook.
- Local usage summed to 64,607 tokens. Daytona usage summed to 42,737
tokens. Each sum matched its final session total exactly.
- See doc/plans/2026-09-09-codex-integration-acceptance.md for the scope
and limits of the live tests.
- After replay onto current master and review fixes: 334 Codex, backend,
and live-session tests passed, including checkpoint serialization and
real-runner process restart. TypeScript checks passed.
- The native Codex integration run passed 83 tests; the large lineage
test passed separately with the release runner (its debug build exceeded
the test deadline).
- All GitHub checks passed on the final PR head. Greptile is 5/5 with no
unresolved review threads. CI regenerates the lockfile for the added
TOML dependency, per repository policy.
- The first server shard hit a timing-dependent duplicate-key failure in
the unchanged artifact-document concurrency test. Its focused 11-test
suite passed locally. One CI retry on the same head passed all 103 files
and 1,405 tests (2 skipped): [retry
result](https://github.com/paperclipai/paperclip/actions/runs/34398832930/job/102631274667).

## Risks

- Trust applies only to the server-selected startup root and isolated
configuration. Sandbox and tool permissions remain authoritative.
- Codex still requires approval of individual hook hashes. This change
does not bypass that policy.
- Providers without the required history APIs fail explicitly.
- Daytona acceptance used the production TypeScript driver. Remote
Paperclip UI and remote Rust execution were not tested.
- No new public API, database state, recovery policy, or UI control is
included.

## Model Used

OpenAI Codex, GPT-6 (`gpt-6-astra`). Used for reasoning, code edits,
tool use,
and test execution. The exact context-window limit is not exposed in
this
session. Real-provider acceptance used Codex CLI 0.153.4 with
`gpt-5.6-sol`.

## 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
#` 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
- [x] 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-09 15:35:18 -05:00

211 lines
5.7 KiB
JavaScript

#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
const statePath = process.argv[2];
if (!statePath) throw new Error("state path is required");
function load() {
try {
return JSON.parse(readFileSync(statePath, "utf8"));
} catch (error) {
if (error?.code !== "ENOENT") throw error;
return {
threadId: "thread-durable-runnerd",
sessionId: "session-durable-runnerd",
nextTurn: 0,
heldOnce: false,
turns: {},
};
}
}
let state = load();
const pendingTools = new Map();
let buffer = "";
function save() {
writeFileSync(statePath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
}
function send(value) {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
function finishTurn(turnId, toolResult) {
send({
method: "item/completed",
params: {
threadId: state.threadId,
turnId,
item: { id: `message-${turnId}`, type: "agentMessage", text: `Tool result: ${JSON.stringify(toolResult)}` },
},
});
state.turns[turnId] = "completed";
save();
send({
method: "thread/tokenUsage/updated",
params: {
threadId: state.threadId,
turnId,
tokenUsage: {
total: {
inputTokens: state.nextTurn * 10,
cachedInputTokens: 0,
outputTokens: state.nextTurn * 2,
reasoningOutputTokens: 0,
},
},
},
});
send({
method: "turn/completed",
params: { threadId: state.threadId, turn: { id: turnId, status: "completed" } },
});
}
function handleRequest(message) {
const { id, method, params = {} } = message;
if (method === "initialize") {
send({ id, result: { user: { sessionId: state.sessionId } } });
return;
}
if (method === "thread/start") {
save();
send({
id,
result: {
model: "gpt-fixture",
modelProvider: "openai-fixture",
thread: { id: state.threadId, sessionId: state.sessionId },
},
});
return;
}
if (method === "thread/turns/list") {
const turns = Object.entries(state.turns).map(([turnId, status]) => ({
id: turnId, status, items: [], itemsView: "notLoaded",
}));
if (params.sortDirection === "desc") turns.reverse();
const offset = Number(params.cursor ?? 0);
const limit = params.limit ?? 100;
send({ id, result: {
data: turns.slice(offset, offset + limit),
nextCursor: offset + limit < turns.length ? String(offset + limit) : null,
} });
return;
}
if (method === "thread/read") {
send({
id,
result: {
thread: {
id: state.threadId,
sessionId: state.sessionId,
status: { type: Object.values(state.turns).includes("inProgress") ? "active" : "idle" },
...(params.includeTurns ? {
turns: Object.entries(state.turns).map(([turnId, status]) => ({ id: turnId, status })),
} : {}),
tokenUsage: {
total: {
inputTokens: state.nextTurn * 10,
cachedInputTokens: 0,
outputTokens: state.nextTurn * 2,
reasoningOutputTokens: 0,
},
},
},
},
});
return;
}
if (method === "thread/resume") {
send({
id,
result: {
model: "gpt-fixture",
modelProvider: "openai-fixture",
thread: { id: state.threadId, sessionId: state.sessionId },
},
});
return;
}
if (method === "turn/start") {
const turnId = `turn-${++state.nextTurn}`;
state.turns[turnId] = "inProgress";
if (!state.heldOnce) {
// Persist which provider turn is intentionally held before exposing its
// tool call. If runnerd is killed immediately after delivering the tool
// result, a replacement provider must not accidentally hold the next
// turn as though the first response had never reached this process.
state.heldOnce = true;
state.heldTurnId = turnId;
}
save();
send({ id, result: { turn: { id: turnId, status: "inProgress" } } });
send({
method: "turn/started",
params: { threadId: state.threadId, turn: { id: turnId, status: "inProgress" } },
});
const toolRequestId = `tool-${turnId}`;
pendingTools.set(toolRequestId, turnId);
send({
id: toolRequestId,
method: "item/tool/call",
params: {
threadId: state.threadId,
turnId,
callId: `governed-idempotent-call-${turnId}`,
tool: "report_progress",
arguments: {
idempotencyKey: "governed-idempotency-key",
body: "One durable governed effect.",
},
},
});
return;
}
if (method === "turn/interrupt") {
const turnId = String(params.turnId);
state.turns[turnId] = "interrupted";
save();
send({ id, result: {} });
send({
method: "turn/completed",
params: { threadId: state.threadId, turn: { id: turnId, status: "interrupted" } },
});
return;
}
send({ id, error: { code: -32601, message: `unsupported method ${method}` } });
}
function handle(message) {
if (message.method) {
if (message.id !== undefined) handleRequest(message);
return;
}
const turnId = pendingTools.get(String(message.id));
if (!turnId) return;
pendingTools.delete(String(message.id));
if (state.heldTurnId === turnId) {
state.heldTurnId = null;
save();
return;
}
finishTurn(turnId, message.result);
}
process.stdin.setEncoding("utf8");
process.stdin.resume();
process.stdin.on("data", (chunk) => {
buffer += chunk;
for (;;) {
const newline = buffer.indexOf("\n");
if (newline < 0) break;
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (line) {
handle(JSON.parse(line));
}
}
});