fix: preserve Pi projection continuity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
@@ -142,6 +150,7 @@ export class PiEventProjector {
|
||||
private assistantMessageId: string | null = null;
|
||||
private readonly toolDrafts = new Map<number, { id: string; argumentsText: string }>();
|
||||
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: [] } },
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
42
tests/fixtures/pi-0.84.2-projector-fixtures.ts
vendored
42
tests/fixtures/pi-0.84.2-projector-fixtures.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user