diff --git a/.project-docs/30-worklog/tasks/20260825-pi-background-run-lease-6a4e2c91.md b/.project-docs/30-worklog/tasks/20260825-pi-background-run-lease-6a4e2c91.md index 968a6df..74e2d1e 100644 --- a/.project-docs/30-worklog/tasks/20260825-pi-background-run-lease-6a4e2c91.md +++ b/.project-docs/30-worklog/tasks/20260825-pi-background-run-lease-6a4e2c91.md @@ -204,6 +204,15 @@ Follow-up plan from cumulative HEAD `621ebb17810394f6f7b97154cb01217bc9112857`: runtime failure. The proof was corrected to arm prompt and compact delay explicitly through E2E-only Main seams, removing message-shape dependence; this was a proof-wiring failure, not a product fallback or relaxed gate. +- The explicit Provider arm then proved the loopback HTTP response remained + open for 12 seconds, but locked Pi acknowledged the local prompt RPC before + the first Provider response on this controlled path. The proof therefore + separates the two facts instead of claiming causality: the Provider response + remains delayed for 12 seconds, and an E2E-only one-shot Pi RPC response hold + independently crosses the 10-second client threshold. The hold is armed only + through the existing Main fault-injection surface, is absent from Renderer + and product configuration, and preserves the real packaged Pi command, + session, Provider request, events, and cleanup path. - `pnpm install --frozen-lockfile` — passed with package-manager-pinned pnpm `10.33.4` and locked Pi `0.84.2`. diff --git a/electron/coding-runtime/pi/release-proof.ts b/electron/coding-runtime/pi/release-proof.ts index a20ca9f..14da502 100644 --- a/electron/coding-runtime/pi/release-proof.ts +++ b/electron/coding-runtime/pi/release-proof.ts @@ -1898,6 +1898,11 @@ export function armFinalAsarResilienceCompactDelay(): { delayMs: number } { const run = resilienceCompositionRun; if (!run) throw new Error('PI resilience proof is not running'); run.provider.armDelayedCompaction(); + resilienceRuntime(run).delayNextTopLevelConfirmationForProof( + run.targetConversationId, + 'compact', + PROOF_MUTATION_CONFIRMATION_DELAY_MS, + ); return { delayMs: PROOF_MUTATION_CONFIRMATION_DELAY_MS }; } @@ -1905,6 +1910,11 @@ export function armFinalAsarResiliencePromptDelay(): { delayMs: number } { const run = resilienceCompositionRun; if (!run) throw new Error('PI resilience proof is not running'); run.provider.armDelayedPrompt(); + resilienceRuntime(run).delayNextTopLevelConfirmationForProof( + run.targetConversationId, + 'prompt', + PROOF_MUTATION_CONFIRMATION_DELAY_MS, + ); return { delayMs: PROOF_MUTATION_CONFIRMATION_DELAY_MS }; } diff --git a/electron/coding-runtime/pi/rpc-client.ts b/electron/coding-runtime/pi/rpc-client.ts index ec3ce82..9f722ef 100644 --- a/electron/coding-runtime/pi/rpc-client.ts +++ b/electron/coding-runtime/pi/rpc-client.ts @@ -78,6 +78,7 @@ export class PiRpcClient { private readonly pending = new Map(); private readonly retiredIds = new Set(); private readonly listeners = new Set<(event: PiRpcEvent) => void>(); + private readonly proofResponseDelays = new Map(); private sequence = 0; private invalidated: PiProcessError | null = null; @@ -111,6 +112,13 @@ export class PiRpcClient { return () => this.listeners.delete(listener); } + delayNextResponseForProof(commandType: string, delayMs: number): void { + if (!commandType || !Number.isSafeInteger(delayMs) || delayMs <= 0) { + throw new Error('Proof response delay requires a command type and positive safe delay'); + } + this.proofResponseDelays.set(commandType, delayMs); + } + async request( command: PiRpcCommand, options: PiRpcRequestOptions = {}, @@ -161,6 +169,22 @@ export class PiRpcClient { if (!pending) { throw new PiProcessError('PI_RPC_PROTOCOL_ERROR', `Pi RPC response used unknown id ${value.id}`); } + const proofDelayMs = this.proofResponseDelays.get(pending.commandType); + if (proofDelayMs !== undefined) { + this.proofResponseDelays.delete(pending.commandType); + setTimeout(() => { + try { + this.accept(value); + } catch (error) { + try { + this.onEventListenerError?.(error); + } catch { + // Proof diagnostics must not affect transport state. + } + } + }, proofDelayMs); + return; + } pending.cancel(); this.pending.delete(value.id); if (value.success) { diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index eb0bf11..46b3a6c 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -1086,6 +1086,14 @@ export class PiConversationRuntime implements CodingConversationRuntime { return await this.pool.injectFailureForProof(conversationId, failure); } + delayNextTopLevelConfirmationForProof( + conversationId: string, + commandType: 'prompt' | 'compact', + delayMs: number, + ): void { + this.pool.delayNextTopLevelConfirmationForProof(conversationId, commandType, delayMs); + } + getResilienceProofDiagnostics(): { pool: ReturnType; subagents: ReturnType | null; diff --git a/electron/coding-runtime/pi/worker-pool.ts b/electron/coding-runtime/pi/worker-pool.ts index 0b81f51..e3c39e1 100644 --- a/electron/coding-runtime/pi/worker-pool.ts +++ b/electron/coding-runtime/pi/worker-pool.ts @@ -38,6 +38,7 @@ export interface PiConversationWorker { subscribeInvalidation(listener: (error: PiProcessError) => void): () => void; stop(reason: PiWorkerStopReason): Promise; injectFailureForProof?(failure: PiWorkerProofFailure): Promise; + delayNextResponseForProof?(commandType: string, delayMs: number): void; } export interface PiWorkerSessionBinding { @@ -677,6 +678,21 @@ export class PiWorkerPool { return true; } + delayNextTopLevelConfirmationForProof( + conversationId: string, + commandType: string, + delayMs: number, + ): void { + const record = this.workers.get(conversationId); + if (!record || record.state === 'crashed') { + throw new Error('Conversation worker is not available for proof response delay'); + } + if (!record.worker.delayNextResponseForProof) { + throw new Error('Conversation worker does not support proof response delay'); + } + record.worker.delayNextResponseForProof(commandType, delayMs); + } + private async performShutdown(): Promise { this.shuttingDown = true; this.shutdownController.abort(); diff --git a/electron/coding-runtime/pi/worker-process.ts b/electron/coding-runtime/pi/worker-process.ts index ba0b239..14abde7 100644 --- a/electron/coding-runtime/pi/worker-process.ts +++ b/electron/coding-runtime/pi/worker-process.ts @@ -412,6 +412,11 @@ export class PiWorkerProcess { return this.rpc.request(command, options); } + delayNextResponseForProof(commandType: string, delayMs: number): void { + if (!this.rpc) throw new Error('Pi worker has not started'); + this.rpc.delayNextResponseForProof(commandType, delayMs); + } + send(command: PiRpcCommand): Promise { if (!this.rpc) { return Promise.reject(new PiProcessError( diff --git a/tests/unit/pi-release-proof-wiring.test.ts b/tests/unit/pi-release-proof-wiring.test.ts index 3c06eab..74932ae 100644 --- a/tests/unit/pi-release-proof-wiring.test.ts +++ b/tests/unit/pi-release-proof-wiring.test.ts @@ -40,6 +40,7 @@ describe('Pi packaged release proof wiring', () => { expect(scriptSource).toContain('setMainWindowVisible(electronApplication, false)'); expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.dispose-target')"); expect(proofSource).toContain('const PROOF_MUTATION_CONFIRMATION_DELAY_MS = 12_000;'); + expect(proofSource).toContain('delayNextTopLevelConfirmationForProof'); expect(mainSource).toContain("'resilience.arm-compact-delay'"); expect(mainSource).toContain("'resilience.arm-prompt-delay'"); expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.arm-compact-delay')"); diff --git a/tests/unit/pi-rpc-foundation.test.ts b/tests/unit/pi-rpc-foundation.test.ts index f705a7a..d6837da 100644 --- a/tests/unit/pi-rpc-foundation.test.ts +++ b/tests/unit/pi-rpc-foundation.test.ts @@ -102,6 +102,38 @@ describe('strict Pi LF JSONL framing', () => { }); describe('Pi RPC client', () => { + it('can deterministically hold one proof response past the mutation timeout', async () => { + vi.useFakeTimers(); + let written = ''; + const writable = new Writable({ + write(chunk, _encoding, callback) { + written += chunk.toString(); + callback(); + }, + }); + const lateResults: Array<{ success: boolean }> = []; + const client = new PiRpcClient(writable, { generation: 1, defaultTimeoutMs: 10_000 }); + client.delayNextResponseForProof('prompt', 12_000); + const requested = client.request( + { type: 'prompt', message: 'proof response hold' }, + { + retainAfterTimeout: true, + onLateResult: (result) => lateResults.push({ success: result.response?.success === true }), + }, + ); + void requested.catch(() => undefined); + await Promise.resolve(); + const command = JSON.parse(written) as { id: string }; + client.accept({ type: 'response', id: command.id, success: true }); + + await vi.advanceTimersByTimeAsync(10_000); + await expect(requested).rejects.toMatchObject({ code: 'PI_RPC_TIMEOUT' }); + expect(client.pendingCount).toBe(1); + await vi.advanceTimersByTimeAsync(2_000); + expect(client.pendingCount).toBe(0); + expect(lateResults).toEqual([{ success: true }]); + }); + it('keeps a mutation correlated after the 10 second confirmation timeout', async () => { vi.useFakeTimers(); let written = '';