mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 23:36:51 +02:00
## 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>
211 lines
5.7 KiB
JavaScript
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));
|
|
}
|
|
}
|
|
});
|