From 0d65753fe129e138e612eb23933ab4edde24683a Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 3 Oct 2026 01:18:03 -0500 Subject: [PATCH] Align warm Pi reopen with native cold admission Co-authored-by: Paperclip --- .../2026-10-02-pi-production-readiness.md | 28 ++++--- packages/paperclip-runner/README.md | 6 ++ .../live/fixtures/fake-pi-warm-sidecar.cjs | 80 +++++++++++++++++++ .../src/live/runnerd-codex-transport.test.ts | 69 ++++++++++++++++ .../src/live/runnerd-codex-transport.ts | 11 ++- 5 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 packages/paperclip-runner/src/live/fixtures/fake-pi-warm-sidecar.cjs diff --git a/doc/plans/2026-10-02-pi-production-readiness.md b/doc/plans/2026-10-02-pi-production-readiness.md index 72feec6e6c5..aa24d10d02f 100644 --- a/doc/plans/2026-10-02-pi-production-readiness.md +++ b/doc/plans/2026-10-02-pi-production-readiness.md @@ -332,8 +332,13 @@ prices are estimates. Unpriced usage must stay unpriced in the ledger and UI. - Normal ARM, Intel and Linux public installations pass in 8.043, 37.681 and 5.698 s respectively. Each uses the normal installed daemon, verifies profile 14, submits no prompt and observes clean Runner exit. Workspace build and - typecheck pass. The full local Vitest run remains live; embedded Postgres - integration suites report host skips, so it is not complete integration proof. + typecheck pass. The broad local Vitest attempt completes with 47 failed files, + 533 passed files and 163 skipped files. Private Postgres library symlinks are + missing, and the restricted test PATH omits macOS lsof. Both setup causes are + corrected only in the task-owned dependency environment. The focused DB and + process-owner checks pass 95 tests with three existing skips. The full suite + then reruns with the corrected environment. The failed log remains intact; it is not + a passing full local test or complete integration claim. - The local Product controller-restart cell passes with canonical evidence. The next cell, warm-three-turn, completes turn 1 but fails before turn 2 provider work: native `run.attach` reaches its 30-second command timeout. @@ -345,13 +350,18 @@ prices are estimates. Unpriced usage must stay unpriced in the ledger and UI. of the completed-turn failure and does not qualify warm continuity. Its strict cleanup receipt fails even though native state reports suspended and Runner exits cleanly; both observations are retained. No model prompt is submitted. -- Investigate authority rotation and native provider reopen before choosing the - correction. Native ACPX attachment checkpoints the old sidecar and opens a - new one. Pi's native session-open admission bound is 60 s, but warm attachment - currently uses the ordinary 30 s controller command wait. This discrepancy - is a candidate cause, not a measured resolution. Do not widen the 60 s bound, - weaken the three-turn identity checks or retry the paid failure unchanged. -- All 55 CI checks and Greptile 5/5 are terminal at `3d75626bf`, with zero +- A free completed-turn regression proves the warm-admission mismatch. It + finishes turn 1 through the native semantic bridge, checkpoints the sidecar, + and delays the replacement's exact-identity admission by 32 s. The unchanged + controller times out at `run.attach` and strict suspension fails. The corrected + controller uses Pi's existing absolute 60 s cold-process admission budget for + warm attachment and aborts admission on close. The same fixture then completes + turn 2, retains one Runner, starts exactly two sidecars without a retry, and + passes strict cleanup. TypeScript passes; the full transport/eval-session + suite is running. The native 60 s bound and profile-14 bytes are unchanged. + The paid three-turn failure remains failed. Fresh exact-source packaging and + one explicit paid retry are still required; no live resolution is claimed. +- All 55 CI checks and Greptile 5/5 are terminal at `96a0e329b`, with zero unresolved draft-PR threads. The immutable image job 37099122706 remains queued. Track it without a duplicate dispatch. Qualification, prerequisites and image/companion gates still hold production. diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index e05d85fbcc9..6c4527e39f3 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -60,6 +60,12 @@ and Daytona qualification passes. Their verified distributions are build-owned; no candidate accepts an arbitrary executable. See [the rich ACP capability report](../../doc/architecture/runner-rich-acp-capabilities.md). +Pi cold provider admission has an absolute 60-second budget. Warm run attachment +checkpoints the old ACPX sidecar and starts a new one, so it uses that same budget +while retaining the existing Runner authority. Live adoption and ordinary +commands retain their 30-second bounds. Closing the transport cancels admission; +an acknowledgement received after the admission deadline cannot revive it. + Cursor candidate configuration accepts `acpxSessionMode: "agent" | "plan" | "ask"` (default `agent`). This selects the native Cursor mode independently of `acpxPermissionMode` and Paperclip task planning or company approvals. The mode diff --git a/packages/paperclip-runner/src/live/fixtures/fake-pi-warm-sidecar.cjs b/packages/paperclip-runner/src/live/fixtures/fake-pi-warm-sidecar.cjs new file mode 100644 index 00000000000..c19b76d2cf3 --- /dev/null +++ b/packages/paperclip-runner/src/live/fixtures/fake-pi-warm-sidecar.cjs @@ -0,0 +1,80 @@ +// Protocol-only fixture: no credentials, network, or model calls. +const { appendFileSync } = require("node:fs"); +const { createInterface } = require("node:readline"); +// The test binds configuration into the verified script snapshot before launch. +const config = /* fixture-config */ null; +let identity; +let runId; +let turnId; +let sequence = 1; +const send = (value) => process.stdout.write(`${JSON.stringify({ protocolVersion: 2, ...value })}\n`); +const event = (eventType, payload) => send({ sequence: sequence++, eventType, runId, turnId, payload }); +const journal = (value) => appendFileSync(config.journal, `${JSON.stringify({ pid: process.pid, ...value })}\n`); +journal({ event: "spawn" }); + +async function handle({ id, command, params = {} }) { + journal({ command, resumed: command === "session.open" && !!params.expectedIdentity }); + let result; + switch (command) { + case "initialize": + result = { protocolVersion: 2, sidecarPid: process.pid, profile: { agent: "pi" }, capabilities: { + persistentSessions: true, exactModelVerification: true, permissions: "runner_policy", + semanticTools: "runner_bridge", structuredInput: "paperclip.question_set.v1", + } }; + break; + case "session.open": + if (params.expectedIdentity) await new Promise((resolve) => setTimeout(resolve, config.resumeDelayMs)); + identity = params.expectedIdentity ?? { + kind: "acpx", normalizedSessionId: params.normalizedSessionId, + acpxRecordId: "record-warm", backendSessionId: "backend-warm", agentSessionId: "agent-warm", + profileDigest: "sha256:f35145437eeb355ed37bc5a4fa93d7ede561d9c45daf311979b46890b808ddd4", + workspaceDigest: `sha256:${"2".repeat(64)}`, requestedModel: params.model, effectiveModel: params.model, + permissionMode: params.permissionMode, piThinkingLevel: params.piThinkingLevel, + providerLifetimeFenceCandidates: [61001, 61002, 61003], + }; + result = { sidecarPid: process.pid, identity, status: {}, turnControls: { steering: true, queuedFollowUp: true } }; + break; + case "run.attach": + runId = params.runId; + result = { runId, catalogRevision: params.catalogRevision }; + break; + case "turn.start": + turnId = params.turnId; + result = { turnId, turnControls: { steering: true, queuedFollowUp: true } }; + break; + case "tool.resolve": + if (params.error) throw new Error(`Completion rejected: ${JSON.stringify(params.error)}`); + result = { resolved: true }; + break; + case "session.snapshot": + result = { identity, runId, turnId: null, pendingRuntimeRequests: [] }; + break; + case "session.goal.get": + result = { schema: "paperclip.session_goal.snapshot.v1", goal: null, workingNow: false, sessionGoals: { + availability: "available", actions: ["set", "pause", "resume", "clear"], autonomousUpdates: true, + persistentAcrossResume: true, maxObjectiveChars: 4000, tokenBudgetControl: false, usageReporting: false, + } }; + break; + case "session.suspend": result = { suspended: true, identity }; break; + case "session.close": result = { closed: true }; break; + case "turn.cancel": result = { cancelled: true }; break; + default: throw new Error(`Unexpected fixture command ${command}`); + } + send({ id, ok: true, result }); + if (command === "turn.start") event("runtime.tool_called", { + callId: `finish-${turnId}`, operationId: "paperclip_finish", input: { + schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Fixture turn completed.", + completionClaim: { contractRevision: "warm-pi-contract", objectiveSatisfied: true, criteria: [], remainingWork: [] }, + evidence: [], verification: [], attentionRequests: [], artifacts: [], + }, + }); + if (command === "tool.resolve") event("runtime.turn_terminal", { status: "completed" }); +} + +let handling = Promise.resolve(); +createInterface({ input: process.stdin }).on("line", (line) => { + handling = handling.then(() => handle(JSON.parse(line))).catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exit(1); + }); +}); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 0af28a0356a..2a7ad71678e 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -6294,6 +6294,75 @@ it("rotates PRP authority in place for a warm cross-run attachment", async () => } }, 30_000); +it("reopens Pi after a completed turn within its cold admission budget during warm attachment", async () => { + const root = await mkdtemp(join(tmpdir(), "runnerd-pi-warm-admission-")); + const cliRoot = join(root, "dist/cli"); + await mkdir(cliRoot, { recursive: true }); + const sidecarPath = join(cliRoot, "acpx-runtime-sidecar.cjs"); + const journal = join(root, "commands.ndjson"); + const fixture = await readFile(fileURLToPath(new URL("./fixtures/fake-pi-warm-sidecar.cjs", import.meta.url)), "utf8"); + await writeFile(sidecarPath, fixture.replace("/* fixture-config */ null", JSON.stringify({ journal, resumeDelayMs: 32_000 }))); + const digest = (path: string) => `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`; + const bundle = createCapabilityRunnerdCodexTransport({ + provider: "acpx", acpxAgent: "pi", piThinkingLevel: "low", acpxPermissionMode: "deny-all", + runnerBinary: defaultCapabilityRunnerdBinary(), stateDirectory: root, runnerFilesystemRoot: root, + providerNodeCommand: process.execPath, providerNodeCommandSha256: digest(process.execPath), + acpxSidecarPath: sidecarPath, acpxSidecarSha256: digest(sidecarPath), + providerPackAuthorityDigest: `sha256:${"d".repeat(64)}`, + lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 }, + environment: { PATH: "/usr/bin:/bin" }, + }); + bundle.transport.setServerRequestHandler(async () => ({ + success: true, + contentItems: [{ type: "inputText", text: "Completion report accepted." }], + })); + let failure: unknown; + try { + await bundle.transport.request("initialize", {}); + await bundle.transport.request("thread/start", { + cwd: root, model: "openrouter/deepseek/deepseek-v4-flash-0731", + dynamicTools: codexSemanticToolSpecs(), + completionContract: { revision: "warm-pi-contract", criterionIds: [] }, + }); + const runnerPid = bundle.evidence().runnerPid; + const notifications = bundle.transport.notifications()[Symbol.asyncIterator](); + const completeTurn = async () => { + await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Complete the fixture turn." }] }); + for (let index = 0; index < 64; index += 1) { + const next = await Promise.race([ + notifications.next(), + new Promise((_, reject) => setTimeout(() => reject(new Error("Fixture completion timed out")), 5_000)), + ]); + if (next.value?.method === "turn/completed") return; + } + throw new Error("Fixture turn did not complete"); + }; + await completeTurn(); + const started = Date.now(); + await bundle.transport.attachRun!({ runId: "run-pi-warm-second", turnId: "turn-pi-warm-second", itemId: "item-pi-warm-second" }); + expect(Date.now() - started).toBeGreaterThan(30_000); + await completeTurn(); + expect(bundle.evidence()).toMatchObject({ runnerPid, runnerExited: false }); + const commands = (await readFile(journal, "utf8")).trim().split("\n").map((line) => JSON.parse(line)); + expect(commands.filter((entry) => entry.event === "spawn")).toHaveLength(2); + expect(commands.filter((entry) => entry.command === "session.open")).toHaveLength(2); + expect(commands.filter((entry) => entry.resumed)).toHaveLength(1); + expect(commands.filter((entry) => entry.command === "turn.start")).toHaveLength(2); + expect(commands.filter((entry) => entry.command === "session.close")).toHaveLength(1); + } catch (error) { + failure = error; + throw error; + } finally { + try { + await bundle.transport.close(); + } catch (cleanupError) { + if (failure) throw new AggregateError([failure, cleanupError], "Warm attachment and strict cleanup failed"); + throw cleanupError; + } + await rm(root, { recursive: true, force: true }); + } +}, 75_000); + it("waits for a warm runner to re-authenticate before probing attachment readiness", async () => { const stateDirectory = await mkdtemp( join(tmpdir(), "runnerd-warm-reattach-before-probe-"), diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 298a7dca332..4385a42b818 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -4014,7 +4014,16 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { paperclipNextAuthority: { identity: desired, connection }, }; core.queueCommand("run.attach", payload, commandId, true); - await this.#waitCommand("run.attach", commandId); + // ACPX run attachment checkpoints the old sidecar and starts a fresh + // provider process. Pi must use the same absolute cold-admission budget + // as startup/recovery even though its Runner authority remains warm. + // Other providers retain the ordinary command bound. + await this.#waitCommand( + "run.attach", + commandId, + this.#coldAdmissionDeadline(), + true, + ); const attached = core.getCommand(commandId); if (attached?.status !== "completed") { throw new Error("native_runner_prp_run_rotation_failed");