mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +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>
351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import { createServer, type Server } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { resolve } from "node:path";
|
|
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
agents,
|
|
companies,
|
|
createDb,
|
|
heartbeatRunEvents,
|
|
heartbeatRuns,
|
|
issues,
|
|
nativeRunFinalizations,
|
|
nativeRunResults,
|
|
} from "@paperclipai/db";
|
|
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "../../__tests__/helpers/embedded-postgres.js";
|
|
import {
|
|
runnerPrpWebSocketInternals,
|
|
setupRunnerPrpWebSocketServer,
|
|
} from "../../realtime/runner-prp-ws.js";
|
|
import { executeNativeCodexRunner } from "./native-codex-runner.js";
|
|
import { prepareNativeHeartbeatRun } from "./prepare-native-run.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping native Codex vertical-slice test on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
const runnerWorkspace = resolve(
|
|
import.meta.dirname,
|
|
"../../../../packages/paperclip-runner/runner",
|
|
);
|
|
const executableSuffix = process.platform === "win32" ? ".exe" : "";
|
|
const runnerBinary = resolve(
|
|
runnerWorkspace,
|
|
"target",
|
|
"release",
|
|
`paperclip-runnerd${executableSuffix}`,
|
|
);
|
|
const fakeCodexBinary = resolve(
|
|
runnerWorkspace,
|
|
"target",
|
|
"release",
|
|
`fake-codex-app-server${executableSuffix}`,
|
|
);
|
|
|
|
function ensureRunnerTestBinaries(): void {
|
|
// Cargo's freshness check is necessary even when the files exist: an older
|
|
// fake provider can otherwise exercise a different protocol than the source.
|
|
execFileSync("cargo", [
|
|
"build",
|
|
"--release",
|
|
"--locked",
|
|
"-p",
|
|
"paperclip-runner-core",
|
|
"--bin",
|
|
"paperclip-runnerd",
|
|
"--bin",
|
|
"fake-codex-app-server",
|
|
], {
|
|
cwd: runnerWorkspace,
|
|
stdio: "inherit",
|
|
// A clean release build includes every qualified managed-provider SDK.
|
|
// Keep this below the CI job deadline while allowing that cold compile to
|
|
// finish on GitHub-hosted runners.
|
|
timeout: 600_000,
|
|
});
|
|
}
|
|
|
|
async function closeServer(server: Server | null): Promise<void> {
|
|
if (!server) return;
|
|
server.closeAllConnections();
|
|
if (!server.listening) return;
|
|
await new Promise<void>((resolveClose, rejectClose) => {
|
|
server.close((error) => error ? rejectClose(error) : resolveClose());
|
|
});
|
|
}
|
|
|
|
describeEmbeddedPostgres("native Codex server vertical slice", () => {
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
let runtimeRoot: string | null = null;
|
|
let server: Server | null = null;
|
|
|
|
beforeAll(async () => {
|
|
ensureRunnerTestBinaries();
|
|
temporary = await startEmbeddedPostgresTestDatabase("native-codex-vertical-slice-");
|
|
runtimeRoot = await mkdtemp(resolve(tmpdir(), "native-codex-runtime-"));
|
|
server = createServer();
|
|
await new Promise<void>((resolveListen) => server!.listen(0, "127.0.0.1", resolveListen));
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
|
|
setupRunnerPrpWebSocketServer(server, {
|
|
apiUrl: `http://127.0.0.1:${address.port}`,
|
|
});
|
|
}, 660_000);
|
|
|
|
afterAll(async () => {
|
|
runnerPrpWebSocketInternals.resetForTests();
|
|
await closeServer(server);
|
|
await temporary?.cleanup();
|
|
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns a durable result and resumes the provider session on the next run", async () => {
|
|
if (!temporary || !runtimeRoot) throw new Error("Vertical-slice fixture was not initialized");
|
|
const db = createDb(temporary.connectionString);
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Native Codex vertical slice",
|
|
issuePrefix: "NCV",
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Native Codex",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "paperclip_runner",
|
|
adapterConfig: { provider: "codex" },
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
identifier: "NCV-1",
|
|
title: "Complete the native Codex vertical slice",
|
|
description: "Return a bound structured completion result.",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
workMode: "standard",
|
|
assigneeAgentId: agentId,
|
|
});
|
|
const [run] = await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
contextSnapshot: { issueId },
|
|
}).returning();
|
|
if (!run) throw new Error("Failed to seed native run");
|
|
await db
|
|
.update(issues)
|
|
.set({ executionRunId: runId })
|
|
.where(eq(issues.id, issueId));
|
|
|
|
const native = await prepareNativeHeartbeatRun({
|
|
db,
|
|
run,
|
|
issue: {
|
|
id: issueId,
|
|
title: "Complete the native Codex vertical slice",
|
|
description: "Return a bound structured completion result.",
|
|
reviewPolicy: null,
|
|
},
|
|
environmentLeaseId: "lease-native-codex-e2e",
|
|
});
|
|
const logs: string[] = [];
|
|
const execute = executeNativeCodexRunner({
|
|
db,
|
|
companyId,
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
runnerInstanceId: native.runnerInstanceId,
|
|
environmentLeaseId: native.environmentLeaseId,
|
|
normalizedSessionId: native.normalizedSessionId,
|
|
turnId: native.turnId,
|
|
itemId: native.itemId,
|
|
cwd: tmpdir(),
|
|
prompt: "Complete the fake native Codex turn.",
|
|
model: "test-model",
|
|
resumeProviderSessionId: null,
|
|
completionContract: native.completionContract,
|
|
timeoutMs: 30_000,
|
|
environment: {},
|
|
runnerBinary,
|
|
runtimeRoot,
|
|
providerLaunch: {
|
|
command: fakeCodexBinary,
|
|
args: [
|
|
"--state-file",
|
|
resolve(runtimeRoot, "fake-codex-state.json"),
|
|
"--call-log",
|
|
resolve(runtimeRoot, "fake-codex-calls.log"),
|
|
"--require-dynamic-tool",
|
|
"--emit-tool-call",
|
|
"--expected-canonical-task-context",
|
|
JSON.stringify({
|
|
companyId,
|
|
actorId: agentId,
|
|
taskId: issueId,
|
|
runId,
|
|
}),
|
|
],
|
|
providerVersion: "fake-codex-v1",
|
|
},
|
|
onLog: async (_stream, chunk) => {
|
|
logs.push(chunk);
|
|
},
|
|
onSpawn: async () => undefined,
|
|
});
|
|
const result = await execute.catch((error) => {
|
|
throw new Error(
|
|
`${error instanceof Error ? error.message : String(error)}\n${logs.join("")}`,
|
|
);
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
provider: "codex",
|
|
sessionParams: { sessionId: "codex-thread-1" },
|
|
summary: "Codex completed the fake turn.",
|
|
resultJson: {
|
|
nativeRunner: {
|
|
result: {
|
|
schema: "paperclip.run_result.v1",
|
|
completionClaim: {
|
|
contractRevision: "1",
|
|
objectiveSatisfied: true,
|
|
criteria: [{ criterionId: "objective", status: "satisfied" }],
|
|
},
|
|
},
|
|
terminal: {
|
|
schema: "paperclip.prp.terminal.v1",
|
|
runTerminalState: "succeeded",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(logs.join("\n")).not.toContain("PAPERCLIP_RUNNER_BOOTSTRAP_TICKET");
|
|
|
|
const [persistedResult] = await db
|
|
.select()
|
|
.from(nativeRunResults)
|
|
.where(eq(nativeRunResults.runId, runId));
|
|
expect(persistedResult).toMatchObject({ schemaStatus: "accepted" });
|
|
const [finalization] = await db
|
|
.select()
|
|
.from(nativeRunFinalizations)
|
|
.where(eq(nativeRunFinalizations.runId, runId));
|
|
expect(finalization).toMatchObject({ phase: "workspace_finalizing" });
|
|
const eventTypes = await db
|
|
.select({ eventType: heartbeatRunEvents.eventType })
|
|
.from(heartbeatRunEvents)
|
|
.where(eq(heartbeatRunEvents.runId, runId));
|
|
expect(eventTypes.map((event) => event.eventType)).toEqual(expect.arrayContaining([
|
|
"semantic_tool.input",
|
|
"semantic_tool.result",
|
|
"turn.completed",
|
|
"run.result.proposed",
|
|
"run.terminal",
|
|
]));
|
|
|
|
const resumedRunId = randomUUID();
|
|
const [resumedRun] = await db.insert(heartbeatRuns).values({
|
|
id: resumedRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
contextSnapshot: { issueId },
|
|
}).returning();
|
|
if (!resumedRun) throw new Error("Failed to seed resumed native run");
|
|
await db
|
|
.update(issues)
|
|
.set({ executionRunId: resumedRunId })
|
|
.where(eq(issues.id, issueId));
|
|
const resumedNative = await prepareNativeHeartbeatRun({
|
|
db,
|
|
run: resumedRun,
|
|
issue: {
|
|
id: issueId,
|
|
title: "Complete the native Codex vertical slice",
|
|
description: "Return a bound structured completion result.",
|
|
reviewPolicy: null,
|
|
},
|
|
environmentLeaseId: "lease-native-codex-resume",
|
|
});
|
|
const resumed = await executeNativeCodexRunner({
|
|
db,
|
|
companyId,
|
|
issueId,
|
|
runId: resumedRunId,
|
|
agentId,
|
|
runnerInstanceId: resumedNative.runnerInstanceId,
|
|
environmentLeaseId: resumedNative.environmentLeaseId,
|
|
normalizedSessionId: resumedNative.normalizedSessionId,
|
|
turnId: resumedNative.turnId,
|
|
itemId: resumedNative.itemId,
|
|
cwd: tmpdir(),
|
|
prompt: "Continue the fake native Codex session.",
|
|
model: "test-model",
|
|
resumeProviderSessionId: "codex-thread-1",
|
|
completionContract: resumedNative.completionContract,
|
|
timeoutMs: 30_000,
|
|
environment: {},
|
|
runnerBinary,
|
|
runtimeRoot,
|
|
providerLaunch: {
|
|
command: fakeCodexBinary,
|
|
args: [
|
|
"--state-file",
|
|
resolve(runtimeRoot, "fake-codex-state.json"),
|
|
"--call-log",
|
|
resolve(runtimeRoot, "fake-codex-calls.log"),
|
|
],
|
|
providerVersion: "fake-codex-v1",
|
|
},
|
|
onLog: async (_stream, chunk) => {
|
|
logs.push(chunk);
|
|
},
|
|
onSpawn: async () => undefined,
|
|
});
|
|
expect(resumed).toMatchObject({
|
|
exitCode: 0,
|
|
sessionParams: { sessionId: "codex-thread-1" },
|
|
});
|
|
const providerCalls = await readFile(
|
|
resolve(runtimeRoot, "fake-codex-calls.log"),
|
|
"utf8",
|
|
);
|
|
expect(providerCalls.match(/^thread\/start$/gm)).toHaveLength(1);
|
|
expect(providerCalls.match(/^thread\/resume$/gm)).toHaveLength(1);
|
|
}, 60_000);
|
|
});
|