test(pi): hold packaged rpc confirmation past timeout

This commit is contained in:
2026-08-26 07:40:55 +08:00
parent 4b75d201b8
commit 26d616c9b6
8 changed files with 105 additions and 0 deletions

View File

@@ -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`.

View File

@@ -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 };
}

View File

@@ -78,6 +78,7 @@ export class PiRpcClient {
private readonly pending = new Map<string, PendingRequest>();
private readonly retiredIds = new Set<string>();
private readonly listeners = new Set<(event: PiRpcEvent) => void>();
private readonly proofResponseDelays = new Map<string, number>();
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<T = unknown>(
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) {

View File

@@ -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<PiWorkerPool['getResilienceProofDiagnostics']>;
subagents: ReturnType<PiSubagentScheduler['getDiagnostics']> | null;

View File

@@ -38,6 +38,7 @@ export interface PiConversationWorker {
subscribeInvalidation(listener: (error: PiProcessError) => void): () => void;
stop(reason: PiWorkerStopReason): Promise<PiWorkerStopResult>;
injectFailureForProof?(failure: PiWorkerProofFailure): Promise<void>;
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<void> {
this.shuttingDown = true;
this.shutdownController.abort();

View File

@@ -412,6 +412,11 @@ export class PiWorkerProcess {
return this.rpc.request<T>(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<void> {
if (!this.rpc) {
return Promise.reject(new PiProcessError(

View File

@@ -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')");

View File

@@ -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 = '';