From 4bbe3f8b1eb383f4b49caa9ad6d3577a955c619d Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 23 Aug 2026 08:40:58 +0800 Subject: [PATCH] fix: preserve Pi projection continuity --- .../20260823-pi-event-projector-91c6a7e4.md | 18 ++- electron/coding-runtime/pi/event-projector.ts | 106 +++++++++++++++++- .../coding-runtime/pi/session-projector.ts | 4 +- .../fixtures/pi-0.84.2-projector-fixtures.ts | 42 +++++++ tests/unit/pi-conversation-runtime.test.ts | 9 ++ tests/unit/pi-event-projector.test.ts | 65 +++++++++++ tests/unit/pi-session-projector.test.ts | 13 +++ 7 files changed, 248 insertions(+), 9 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20260823-pi-event-projector-91c6a7e4.md b/.project-docs/30-worklog/tasks/20260823-pi-event-projector-91c6a7e4.md index be175e8..8aff7a9 100644 --- a/.project-docs/30-worklog/tasks/20260823-pi-event-projector-91c6a7e4.md +++ b/.project-docs/30-worklog/tasks/20260823-pi-event-projector-91c6a7e4.md @@ -8,7 +8,7 @@ - Worktree: D:\Datas\OthersProjects\makelore-pi-event-projector-91c6a7e4 - Base commit: e79aeffffab16c4c5570971e24d1a20997be681d - Owner: codex -- Status: Completed +- Status: Completed — pending planner re-review ## Scope @@ -72,6 +72,16 @@ ## Outcome +- Planner review of candidate `3599064` returned **Needs Fix** with two local, + deterministic P1 gaps: same-generation hydration regressed `cursor.seq` to + zero, and retry failure/summary-retry Pi 0.84.2 events were not completely + projected. PI-060 was reopened; PI-070 remains locked until re-review. +- The follow-up closes both findings: same-generation hydration preserves the + current cursor while a genuinely new generation starts at zero; listener + envelopes remain strictly monotonic across checkpoint/settle. Exact Pi + 0.84.2 failed auto-retry and all three summarization-retry events now project + bounded retry traces and redacted failure state, with `agent_settled` + preserving `terminalReason: failed`. - Added a Main-private `PiEventProjector` for exact Pi 0.84.2 live RPC shapes. It projects stable message/content-index streams, cumulative tool output, authoritative `toolResult`, retry/turn/compaction boundaries, queue, @@ -100,7 +110,7 @@ - `corepack pnpm run lint:check` — passed with 0 errors; the repository's 6 pre-existing React warnings remain outside this task. - `corepack pnpm run typecheck` — passed. -- `corepack pnpm test` — passed, 195 files / 2181 tests. +- `corepack pnpm test` — passed after the review fixes, 195 files / 2184 tests. - `corepack pnpm run build:vite` — passed for Renderer, Electron Main, Preload, and release utility outputs; existing chunk-size/dynamic-import warnings remain. @@ -111,12 +121,14 @@ `realTurnVerified=false`; real Provider validation is Explicitly Waived / Accepted Risk. macOS execution remains deferred to PI-150 and is not Pass. - Focused projector/runtime suite — passed, including all 15 `pi-*.test.ts` - files / 78 tests after an isolated Windows temporary-file `rename EPERM` + files / 81 tests after an isolated Windows temporary-file `rename EPERM` was rerun successfully; the later full suite passed without recurrence. - `git diff --check` — passed. ## Follow-ups +- Re-submit the cumulative PI-060 commits for planner review. Do not start + PI-070 until the planner marks the two review findings closed and PI-060 Done. - PI-070 owns registered extension tool-detail schemas and interaction response behavior; PI-060 deliberately keeps unknown/custom payloads hidden or in bounded metadata-only diagnostics. diff --git a/electron/coding-runtime/pi/event-projector.ts b/electron/coding-runtime/pi/event-projector.ts index 72bf2ba..fee40d8 100644 --- a/electron/coding-runtime/pi/event-projector.ts +++ b/electron/coding-runtime/pi/event-projector.ts @@ -31,6 +31,14 @@ export interface PiProjectionDiagnostic { reason: 'unsupported-event'; } +function retryFailure(message: string) { + return { + code: 'CODING_RUNTIME_START_FAILED' as const, + message, + recoverable: true, + }; +} + function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record @@ -142,6 +150,7 @@ export class PiEventProjector { private assistantMessageId: string | null = null; private readonly toolDrafts = new Map(); private compactionId: string | null = null; + private summarizationRetryBaseRun: ConversationSnapshot['run'] | null = null; private readonly diagnostics: PiProjectionDiagnostic[] = []; constructor(options: PiEventProjectorOptions) { @@ -211,9 +220,77 @@ export class PiEventProjector { ]; } - if (event.type === 'auto_retry_end' && event.success === true) { + if (event.type === 'auto_retry_end' && typeof event.success === 'boolean') { const { retry: _retry, ...run } = snapshot.run; - return [{ op: 'run.state', run: { ...run, status: 'running' } }]; + if (event.success) { + return [{ op: 'run.state', run: { ...run, status: 'running' } }]; + } + return [{ + op: 'run.state', + run: { + ...run, + status: 'error', + terminalReason: 'failed', + error: retryFailure('The local Agent retry failed'), + }, + }]; + } + + if (event.type === 'summarization_retry_scheduled' + && typeof event.attempt === 'number' + && typeof event.delayMs === 'number') { + const runId = snapshot.run.runId ?? this.createId(); + this.summarizationRetryBaseRun ??= { ...snapshot.run, runId }; + return [ + { + op: 'boundary.upsert', + node: { + kind: 'boundary', + id: this.createId(), + runId, + boundary: 'retry', + attempt: event.attempt, + delayMs: event.delayMs, + }, + }, + { + op: 'run.state', + run: { + ...snapshot.run, + status: 'retrying', + runId, + retry: { attempt: event.attempt, delayMs: event.delayMs }, + }, + }, + ]; + } + + if (event.type === 'summarization_retry_attempt_start' + && (event.source === 'branchSummary' || event.source === 'compaction')) { + const base = this.summarizationRetryBaseRun ?? snapshot.run; + const { retry: _retry, ...run } = base; + return [ + ...(event.source === 'compaction' + ? [{ + op: 'context.replace' as const, + context: { ...snapshot.context, compaction: 'running' as const }, + }] + : []), + { + op: 'run.state', + run: { + ...run, + status: event.source === 'compaction' ? 'compacting' : run.status, + }, + }, + ]; + } + + if (event.type === 'summarization_retry_finished') { + const base = this.summarizationRetryBaseRun ?? snapshot.run; + this.summarizationRetryBaseRun = null; + const { retry: _retry, ...run } = base; + return [{ op: 'run.state', run }]; } if (event.type === 'compaction_start') { @@ -244,6 +321,8 @@ export class PiEventProjector { const id = this.compactionId ?? this.createId(); const runId = snapshot.run.runId ?? this.createId(); this.compactionId = null; + const compactionFailed = typeof event.errorMessage === 'string'; + const { retry: _retry, ...run } = snapshot.run; return [ { op: 'compaction.upsert', @@ -265,9 +344,17 @@ export class PiEventProjector { { op: 'run.state', run: { - ...snapshot.run, - status: event.willRetry === true ? 'running' : snapshot.run.status, + ...run, + status: event.willRetry === true + ? 'running' + : compactionFailed ? 'error' : snapshot.run.status, runId, + ...(compactionFailed + ? { + terminalReason: 'failed' as const, + error: retryFailure('The local Agent summarization failed'), + } + : {}), }, }, ]; @@ -298,6 +385,12 @@ export class PiEventProjector { } if (event.type === 'agent_settled') { + const terminalReason = snapshot.run.status === 'aborting' + ? 'aborted' as const + : snapshot.run.status === 'error' || snapshot.run.terminalReason === 'failed' + ? 'failed' as const + : 'completed' as const; + this.summarizationRetryBaseRun = null; return [ { op: 'run.state', @@ -305,7 +398,10 @@ export class PiEventProjector { status: 'idle', ...(snapshot.run.runId ? { runId: snapshot.run.runId } : {}), settledAt: this.now(), - terminalReason: snapshot.run.status === 'aborting' ? 'aborted' : 'completed', + terminalReason, + ...(terminalReason === 'failed' && snapshot.run.error + ? { error: snapshot.run.error } + : {}), }, }, { op: 'queue.replace', queue: { items: [] } }, diff --git a/electron/coding-runtime/pi/session-projector.ts b/electron/coding-runtime/pi/session-projector.ts index 7f2b7b5..f71143a 100644 --- a/electron/coding-runtime/pi/session-projector.ts +++ b/electron/coding-runtime/pi/session-projector.ts @@ -361,7 +361,9 @@ export async function projectPiSessionSnapshot( worker: { status: 'ready', generation: input.workerGeneration }, cursor: { workerGeneration: input.workerGeneration, - seq: 0, + seq: input.snapshot.cursor.workerGeneration === input.workerGeneration + ? input.snapshot.cursor.seq + : 0, ...(typeof response.leafId === 'string' ? { leafEntryId: response.leafId } : {}), }, }; diff --git a/tests/fixtures/pi-0.84.2-projector-fixtures.ts b/tests/fixtures/pi-0.84.2-projector-fixtures.ts index f9a5bd0..506f5ab 100644 --- a/tests/fixtures/pi-0.84.2-projector-fixtures.ts +++ b/tests/fixtures/pi-0.84.2-projector-fixtures.ts @@ -121,3 +121,45 @@ export const PI_084_TEXT_TURN = { tokens: { input: 12, output: 4, cacheRead: 2, cacheWrite: 0, total: 18 }, }, } as const; + +export const PI_084_AUTO_RETRY_FAILURE_EVENTS = [ + { + type: 'auto_retry_start', + attempt: 3, + maxAttempts: 3, + delayMs: 1000, + errorMessage: 'sensitive provider retry detail', + }, + { + type: 'auto_retry_end', + success: false, + attempt: 3, + finalError: 'sensitive final provider detail', + }, + { type: 'agent_settled' }, +] as const; + +export const PI_084_SUMMARIZATION_RETRY_FAILURE_EVENTS = [ + { type: 'compaction_start', reason: 'threshold' }, + { + type: 'summarization_retry_scheduled', + attempt: 1, + maxAttempts: 2, + delayMs: 500, + errorMessage: 'sensitive summary provider detail', + }, + { + type: 'summarization_retry_attempt_start', + source: 'compaction', + reason: 'threshold', + }, + { type: 'summarization_retry_finished' }, + { + type: 'compaction_end', + reason: 'threshold', + aborted: false, + willRetry: false, + errorMessage: 'sensitive exhausted summary detail', + }, + { type: 'agent_settled' }, +] as const; diff --git a/tests/unit/pi-conversation-runtime.test.ts b/tests/unit/pi-conversation-runtime.test.ts index 794e32d..d13d22d 100644 --- a/tests/unit/pi-conversation-runtime.test.ts +++ b/tests/unit/pi-conversation-runtime.test.ts @@ -203,6 +203,12 @@ describe('Pi Conversation runtime', () => { }, }); await Promise.all(inputs.slice(0, 2).map((input) => runtime.prepare(input))); + const leftGenerationOneSeqs: number[] = []; + const unsubscribe = runtime.subscribe((envelope) => { + if (envelope.conversationId === left.id && envelope.workerGeneration === 1) { + leftGenerationOneSeqs.push(envelope.seq); + } + }); const releasePromptAcceptance = workers.get(left.id)!.holdNext('prompt'); let acceptanceResolved = false; @@ -280,6 +286,8 @@ describe('Pi Conversation runtime', () => { expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle'); const settledNodes = (await runtime.getSnapshot(left.id)).nodes; expect(settledNodes).toEqual(checkpoint.nodes); + expect(leftGenerationOneSeqs).toEqual(leftGenerationOneSeqs.map((_, index) => index + 1)); + expect((await runtime.getSnapshot(left.id)).cursor.seq).toBe(leftGenerationOneSeqs.at(-1)); const changed = await runtime.setModel({ conversationId: left.id, @@ -452,5 +460,6 @@ describe('Pi Conversation runtime', () => { await expect(runtime.getSnapshot(forkTarget.id)).rejects.toMatchObject({ publicError: { code: 'CODING_CONVERSATION_NOT_FOUND' }, }); + unsubscribe(); }); }); diff --git a/tests/unit/pi-event-projector.test.ts b/tests/unit/pi-event-projector.test.ts index 50c35a3..a8c323d 100644 --- a/tests/unit/pi-event-projector.test.ts +++ b/tests/unit/pi-event-projector.test.ts @@ -10,6 +10,10 @@ import { reduceConversationPatch, } from '../../electron/coding-runtime/conversation-reducer'; import { PiEventProjector } from '../../electron/coding-runtime/pi/event-projector'; +import { + PI_084_AUTO_RETRY_FAILURE_EVENTS, + PI_084_SUMMARIZATION_RETRY_FAILURE_EVENTS, +} from '../fixtures/pi-0.84.2-projector-fixtures'; function emptySnapshot(): ConversationSnapshot { return { @@ -312,6 +316,67 @@ describe('Pi event projector', () => { expect(snapshot.queue.items).toEqual([]); }); + it('preserves a redacted failed auto-retry terminal through agent_settled', async () => { + const ids = ['retry-boundary-a']; + const projector = new PiEventProjector({ createId: () => ids.shift() as string }); + let snapshot = emptySnapshot(); + + for (const event of PI_084_AUTO_RETRY_FAILURE_EVENTS) { + snapshot = apply(snapshot, await projector.project(snapshot, structuredClone(event))); + } + + expect(snapshot.run).toMatchObject({ + status: 'idle', + terminalReason: 'failed', + error: { + code: 'CODING_RUNTIME_START_FAILED', + recoverable: true, + }, + }); + expect(JSON.stringify(snapshot)).not.toContain('sensitive'); + expect(projector.getDiagnostics()).not.toContainEqual(expect.objectContaining({ + eventType: 'auto_retry_end', + })); + }); + + it('maps all summary-retry events and keeps exhausted compaction failure redacted', async () => { + const ids = ['compaction-a', 'summary-retry-boundary-a']; + const projector = new PiEventProjector({ createId: () => ids.shift() as string }); + let snapshot = emptySnapshot(); + + for (const event of PI_084_SUMMARIZATION_RETRY_FAILURE_EVENTS) { + snapshot = apply(snapshot, await projector.project(snapshot, structuredClone(event))); + if (event.type === 'summarization_retry_scheduled') { + expect(snapshot.run).toMatchObject({ + status: 'retrying', + retry: { attempt: 1, delayMs: 500 }, + }); + } + if (event.type === 'summarization_retry_attempt_start' + || event.type === 'summarization_retry_finished') { + expect(snapshot.run.status).toBe('compacting'); + expect(snapshot.run.retry).toBeUndefined(); + } + } + + expect(snapshot.nodes).toContainEqual(expect.objectContaining({ + kind: 'boundary', + id: 'summary-retry-boundary-a', + boundary: 'retry', + attempt: 1, + delayMs: 500, + })); + expect(snapshot.run).toMatchObject({ + status: 'idle', + terminalReason: 'failed', + error: { code: 'CODING_RUNTIME_START_FAILED', recoverable: true }, + }); + expect(JSON.stringify(snapshot)).not.toContain('sensitive'); + expect(projector.getDiagnostics()).not.toContainEqual(expect.objectContaining({ + eventType: expect.stringMatching(/^summarization_retry_/), + })); + }); + it('reconciles the optimistic user node and projects image bytes through an attachment hook', async () => { const projectedImages: unknown[] = []; const projector = new PiEventProjector({ diff --git a/tests/unit/pi-session-projector.test.ts b/tests/unit/pi-session-projector.test.ts index 72ba73c..62b52ef 100644 --- a/tests/unit/pi-session-projector.test.ts +++ b/tests/unit/pi-session-projector.test.ts @@ -130,6 +130,19 @@ describe('Pi session projector', () => { expect(JSON.stringify(snapshot)).not.toContain('Abandoned answer'); }); + it('preserves seq for same-generation checkpoint hydration', async () => { + const live = baseSnapshot(); + live.cursor.seq = 7; + const snapshot = await projectPiSessionSnapshot({ + snapshot: live, + workerGeneration: 1, + state: { sessionId: 'pi-session-a', isStreaming: false, isCompacting: false }, + entries: { entries: [], leafId: null }, + }); + + expect(snapshot.cursor).toEqual({ workerGeneration: 1, seq: 7 }); + }); + it('applies retained-tail compaction and reconciles durable entries without replacing live IDs', async () => { const live: ConversationSnapshot = { ...baseSnapshot(),