diff --git a/doc/plans/2026-10-02-pi-production-readiness.md b/doc/plans/2026-10-02-pi-production-readiness.md index 945ba80e2fc..4c0492c72a6 100644 --- a/doc/plans/2026-10-02-pi-production-readiness.md +++ b/doc/plans/2026-10-02-pi-production-readiness.md @@ -42,6 +42,26 @@ prices are estimates. Unpriced usage must stay unpriced in the ledger and UI. ## Evidence so far +- At `4237bc369`, the native turn-binding correction advances the steering + journey past `steering_stale_turn`, but the provider boundary still rejects + delivery. The real patched ACPX client lacks `requestExtension`: its types + and runtime callers declare it, while its implementation omits it. A real + package regression fails with that exact TypeError before the correction, + then passes steering and follow-up during an unanswered prompt afterward. + This is a separate runtime correction and needs a fresh installed journey. +- The corrected Intel public package at `4237bc369` passes credential-free + closed startup in 37.8 seconds. Its CI typecheck, build, native tests and + browser shards pass. CI retains two failures: a resumed durability test + inherits the deliberately killed attempt's 500 ms timeout; the Linux public + Pi probe reaches `session_handshake_timeout`. The former gets an explicit + resumed-turn bound. The latter remains an admission blocker; accepting a + timeout as successful installation would weaken the release gate. +- The local Linux image build at `4237bc369` fails before a provider starts + because the committed lock does not match the source patch configuration. + The official image workflow already regenerates its private build lock; + local builds must do the same and record that resolved lock's digest. + No repository lockfile or workflow changes are required. + - `2b50801b2`: installed restart failed before native answer delivery. The durable request turn ID differs from the provider turn ID. The browser's issue-identifier route also differed from the matcher's UUID route. diff --git a/packages/paperclip-runner/src/live/live-session.test.ts b/packages/paperclip-runner/src/live/live-session.test.ts index 5a16a0967fb..7b973bb7744 100644 --- a/packages/paperclip-runner/src/live/live-session.test.ts +++ b/packages/paperclip-runner/src/live/live-session.test.ts @@ -1813,6 +1813,9 @@ describe("Capability live runnerd and Codex session", () => { sessionId: binding.sessionId, attemptId: "attempt-resumed", resumeOf: "attempt-killed", + // Only the killed attempt deliberately uses the short timeout. Allow + // durable disk writes to complete during the resumed successful turn. + turnTimeoutMs: 5_000, }); expect(resumed.snapshot().providerThreadId).toBe(state.threadId); expect(resumed.snapshot().attempts).toMatchObject([ diff --git a/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs index 103557b4973..50bfcd016a7 100644 --- a/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs @@ -25,6 +25,8 @@ readline.createInterface({input:process.stdin}).on('line',(line)=>{ send({method:'fixture/ignored',params:{sessionId:'session-1'}}); send({method:'fixture/activity',params:{sessionId:'wrong-session'}}); send({id:0,method:mode==='unknown'?'fixture/not-enabled':'fixture/question',params:{sessionId:mode==='wrong-session'?'wrong-session':'session-1',value:'private-question'}}); + } else if(message.method==='pi/steer' || message.method==='pi/follow_up') { + send({id:message.id,result:{accepted:true,sessionId:message.params.sessionId,disposition:'queued',message:message.params.message}}); } else if(message.method==='session/cancel') { send({id:promptId,result:{stopReason:'cancelled'}}); promptId=undefined; } else if(message.id===0 && !message.method) { send({method:'session/update',params:{sessionId:'session-1',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:JSON.stringify(message)}}}}); @@ -77,6 +79,29 @@ test("initialize retains mandatory capabilities while merging provider metadata" }); }); +test("real patched client sends Pi steering and follow-up while its original prompt awaits input", { timeout: 10000 }, async () => { + let observe; const started = new Promise(resolve => { observe = resolve; }); + await withClient({ onExtensionRequest: async () => { observe(); return await new Promise(() => {}); } }, async client => { + const pending = client.prompt("session-1", "question"); + await started; + try { + for (const method of ["pi/steer", "pi/follow_up"]) { + assert.deepEqual(await client.requestExtension(method, { sessionId: "session-1", message: "Keep the original turn" }), { + accepted: true, sessionId: "session-1", disposition: "queued", message: "Keep the original turn", + }); + } + for (const method of ["session/prompt", "fs/read_text_file", "initialize", "bad method/name"]) { + await assert.rejects(client.requestExtension(method, {}), /extension method/); + } + await assert.rejects(client.requestExtension("pi/steer", []), /payload must be an object/); + await assert.rejects(client.requestExtension("pi/steer", { message: "x".repeat(256 * 1024) }), /bounded size/); + } finally { + await client.cancel("session-1"); + assert.equal((await pending).stopReason, "cancelled"); + } + }); +}); + test("unknown methods and cross-session requests never reach the host callback", { timeout: 10000 }, async () => { let calls = 0; await withClient({ onExtensionRequest: async () => { calls++; return {}; } }, async (client) => { diff --git a/patches/acpx@0.13.1.patch b/patches/acpx@0.13.1.patch index 5f117548528..97a0d3257d8 100644 --- a/patches/acpx@0.13.1.patch +++ b/patches/acpx@0.13.1.patch @@ -523,7 +523,21 @@ index d454fd7c5..6eba74345 100644 })); result = claudeAcp ? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs()) : await createPromise; } catch (error) { -@@ -4864,7 +5099,7 @@ var AcpClient = class { +@@ -4596,6 +4831,13 @@ var AcpClient = class { + } + } ++ async requestExtension(method, params) { ++ closedExtensionMethods([method]); ++ const payload = boundedExtensionRecord(params); ++ const connection = this.getConnection(); ++ const result = await this.runConnectionRequest(() => connection.extMethod(method, payload)); ++ return boundedExtensionRecord(result); ++ } + async setSessionModel(sessionId, modelId, controlOverride) { + const control = this.resolveModelControl(sessionId, controlOverride); + if (!control) throw new RequestedModelUnsupportedError(`Cannot set model "${modelId}": the ACP session did not advertise a model config option or legacy session/set_model support.`, "missing-capability"); + const resolvedModelId = resolveRequestedModelId({ +@@ -4864,7 +5106,7 @@ var AcpClient = class { } selectAuthMethod(methods) { for (const method of methods) {