From 9f05e2d7e194b3ae3507c019de53e437aaed9458 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Wed, 26 Aug 2026 09:41:25 +0800 Subject: [PATCH] fix(pi): settle delayed compact ownership --- ...260825-pi-background-run-lease-6a4e2c91.md | 43 ++++++--- electron/coding-runtime/pi/release-proof.ts | 17 +++- electron/coding-runtime/pi/runtime.ts | 40 ++++++++- electron/coding-runtime/pi/worker-pool.ts | 24 +++++ electron/main/index.ts | 1 + scripts/run-pi-subagent-packaged-smoke.mjs | 2 +- src/pages/Chat/CodingConversationHeader.tsx | 46 +++++++++- tests/unit/coding-feature-ui.test.tsx | 90 +++++++++++++++++++ tests/unit/pi-background-lifecycle.test.ts | 44 ++++++++- tests/unit/pi-conversation-runtime.test.ts | 6 +- tests/unit/pi-release-proof-wiring.test.ts | 17 ++++ tests/unit/pi-worker-pool.test.ts | 29 ++++++ 12 files changed, 334 insertions(+), 25 deletions(-) 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 74e2d1e..cf3447a 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 @@ -8,7 +8,7 @@ - Worktree: D:\Datas\OthersProjects\makelore-pi-background-run-lease-6a4e2c91 - Base commit: 92f4c91088e79252aca76af3279b184da68e1eb7 - Owner: codex-root -- Status: Implementing follow-up +- Status: Ready for integration ## Scope @@ -133,13 +133,21 @@ Follow-up plan from cumulative HEAD `621ebb17810394f6f7b97154cb01217bc9112857`: service performs model/fork rejection before metadata persistence or fork target creation; sibling Conversations retain independent permits. - Manual compact uses the same retained ownership through - `compaction_start`, `compaction_end`, authoritative settlement, and hydrate. - The final-ASAR proof now arms a controlled 12-second compact response and - records its running/uncertain and completed/idle states. -- Renderer submission errors retain their stable backend code. An uncertain - submission keeps the draft and prevents overlap, shows the actionable delay - message instead of a runtime-outage banner, and automatically removes the - transient message when the authoritative run reaches a terminal state. + `compaction_start`, `compaction_end`, correlated RPC settlement, and + hydrate. Locked Pi `0.84.2` does not emit `agent_settled` for a manual + compact, so the pool now emits a target-scoped top-level settlement after + the correlated compact success (including a late success) and the runtime + releases its lease exactly once. If `compaction_end(error)` terminalizes the + projection before the RPC rejection arrives, the later failure cleanup also + releases the already-terminal run lease idempotently. +- The final-ASAR proof now arms controlled 12-second Provider and Pi RPC + response delays, materializes proof-only compact thresholds in the isolated + userData, and records running/uncertain followed by completed/idle states. +- Renderer submission/action errors retain their stable backend code. An + uncertain submission keeps the draft and prevents overlap, shows the + actionable delay message instead of a runtime-outage banner, and the Header + derives away a stale compact uncertainty once a newer generation/sequence + reaches a non-uncertain terminal state. Model, thinking, compact, and fork controls are disabled while the run is active; abort and recover remain available. @@ -181,21 +189,21 @@ Follow-up plan from cumulative HEAD `621ebb17810394f6f7b97154cb01217bc9112857`: old implementation retired the correlation and released active ownership at 10 seconds, returned a raw timeout, and retained the UI uncertainty after a completed run. -- Focused green gate — passed: 8 files / 115 tests, including delayed prompt +- Focused green gate — passed: 10 files / 121 tests, including delayed prompt and compact, late success/failure, settled-before-response, same-target mutation exclusion with zero fork/model persistence, sibling isolation, safe Host error mapping, Renderer automatic unlock, and final-ASAR wiring. - `pnpm run typecheck` — passed after the final runtime/Renderer/proof changes. - `pnpm run lint:check` — passed with zero errors and the same 5 pre-existing warnings in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. -- `pnpm test` — passed: 181 regular files / 1535 tests passed / 2 skipped, +- `pnpm test` — passed: 181 regular files / 1538 tests passed / 2 skipped, followed by the isolated pressure file / 1 test passed. - `pnpm run build:vite` — passed for Renderer, Main, Preload, and utility bundles; only existing dynamic-import and chunk-size warnings were emitted. - `pnpm run test:electron:windows` — passed: 2 files / 4 tests. -- Final amended-HEAD NSIS, artifact closure, 12-second packaged UI/Main proof, - Authenticode, and zero-residual-process results are completed after the task - documentation/registry commit so the returned artifact binds to a clean +- Final clean-HEAD NSIS, artifact closure, 12-second packaged UI/Main proof, + Authenticode, and zero-residual-process results are run after this task + documentation commit so the returned artifact binds to the docs-bearing cumulative HEAD. - The first candidate packaged proof intentionally required `CODING_REQUEST_UNCERTAIN` but the controlled Provider's message-shape @@ -213,6 +221,15 @@ Follow-up plan from cumulative HEAD `621ebb17810394f6f7b97154cb01217bc9112857`: 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. +- The final-ASAR candidate proof passed after the compact settlement fix: + prompt and compact each crossed the former 10-second confirmation threshold + while retaining target ownership; compact reached a durable completed node, + context/run idle, Provider active count zero, and background lease zero; + stale uncertainty disappeared from the real UI. Hidden idle eviction then + used `background_sleep`, the explicit crash/protocol/dispose matrix retained + sibling isolation and same-session recovery, and all tracked proof PIDs and + resource counters reached zero. This remains controlled loopback/fault + injection evidence with `realTurnVerified=false`. - `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 14da502..9294949 100644 --- a/electron/coding-runtime/pi/release-proof.ts +++ b/electron/coding-runtime/pi/release-proof.ts @@ -1,6 +1,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { execFile } from 'node:child_process'; -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; @@ -26,6 +26,7 @@ import type { ConversationPatchEnvelope, PrepareConversationInput } from '../con import { PiManagedExtensionHost } from './extension-host'; import { PiManagedInputRevisionCoordinator } from './managed-input-revision'; import { runPiReleasePressureCleanup } from './release-proof-cleanup'; +import { ensurePiManagedPaths } from './resource-loader'; import type { PiRpcEvent } from './rpc-client'; import { createPiManagedWorkerOpener, PiConversationRuntime } from './runtime'; import { PiSessionRegistry } from './session-registry'; @@ -510,7 +511,9 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise { + const snapshot = this.states.get(event.conversationId)?.snapshot; + const current = snapshot?.run; + if (!snapshot + || snapshot.cursor.workerGeneration !== event.generation + || current?.runId !== event.runId + || runIsTerminal(current.status)) return; + this.emit(event.conversationId, { + op: 'run.state', + run: { + status: 'idle', + runId: event.runId, + settledAt: this.now(), + terminalReason: 'completed', + }, + }, event.runId); + this.extensionUi.endRun(event.conversationId, event.runId); + try { + await Promise.allSettled([ + this.interactions.cancelRun(event.conversationId, event.runId, true), + this.extensionHost?.clearRun( + event.conversationId, + event.generation, + event.runId, + ), + ]); + } finally { + this.releaseRunBackgroundLease(event.conversationId, event.runId); + } + }).catch((error) => { + this.recordProjectionFailure(event.conversationId, event.generation, error); + }); + return; + } if (event.type === 'top-level.failed') { void this.enqueueProjection(event.conversationId, async () => { await this.failRun( @@ -1525,8 +1560,11 @@ export class PiConversationRuntime implements CodingConversationRuntime { const current = this.states.get(conversationId)?.snapshot?.run; const snapshotGeneration = this.states.get(conversationId)?.snapshot?.cursor.workerGeneration; if (current?.runId !== runId - || runIsTerminal(current.status) || (generation !== undefined && snapshotGeneration !== generation)) return; + if (runIsTerminal(current.status)) { + if (releaseBackgroundLease) this.releaseRunBackgroundLease(conversationId, runId); + return; + } const publicError = runtimeFailure(error); this.emit(conversationId, { op: 'run.state', diff --git a/electron/coding-runtime/pi/worker-pool.ts b/electron/coding-runtime/pi/worker-pool.ts index e3c39e1..6b933ba 100644 --- a/electron/coding-runtime/pi/worker-pool.ts +++ b/electron/coding-runtime/pi/worker-pool.ts @@ -117,6 +117,12 @@ export type PiWorkerPoolEvent = generation: number; runId: string; } + | { + type: 'top-level.settled'; + conversationId: string; + generation: number; + runId: string; + } | { type: 'top-level.failed'; conversationId: string; @@ -756,6 +762,15 @@ export class PiWorkerPool { onLateResult: (result) => this.handleLateTopLevelResult(current!, run, result), }); this.confirmTopLevel(current, run); + if (run.command.type === 'compact') { + this.settleTopLevel(current); + this.emit({ + type: 'top-level.settled', + conversationId: run.conversationId, + generation: current.generation, + runId: run.runId, + }); + } run.resolve(response); } catch (error) { const active = this.activeRuns.get(run.conversationId); @@ -823,6 +838,15 @@ export class PiWorkerPool { generation: record.generation, runId: run.runId, }); + if (run.command.type === 'compact') { + this.settleTopLevel(record); + this.emit({ + type: 'top-level.settled', + conversationId: run.conversationId, + generation: record.generation, + runId: run.runId, + }); + } return; } if (result.error.code === 'PI_RPC_EXITED' diff --git a/electron/main/index.ts b/electron/main/index.ts index 3a2ecc6..292c76e 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -965,6 +965,7 @@ export async function runPiReleaseProofE2E(action: PiReleaseProofAction) { resilience: await startFinalAsarResilienceProof({ composition: codingProducts, projectPath: join(app.getPath('userData'), 'pi-resilience-proof-project'), + userDataDir: app.getPath('userData'), hostProxyBaseUrl: `http://127.0.0.1:${address.port}/api/ai-proxy/v1`, hostToken: getHostApiToken(), }), diff --git a/scripts/run-pi-subagent-packaged-smoke.mjs b/scripts/run-pi-subagent-packaged-smoke.mjs index b8caee5..7661195 100644 --- a/scripts/run-pi-subagent-packaged-smoke.mjs +++ b/scripts/run-pi-subagent-packaged-smoke.mjs @@ -612,6 +612,7 @@ export async function runPackagedProductProof(options) { throw new Error(`Hidden packaged Pi run was stopped despite active leases: ${JSON.stringify(backgroundHidden?.resilience)}`); } await evaluateProof(electronApplication, 'resilience.release-parents'); + await setMainWindowVisible(electronApplication, true); await waitForResilienceProof( electronApplication, (status) => status?.target?.runStatus === 'idle' @@ -619,7 +620,6 @@ export async function runPackagedProductProof(options) { && status?.resources?.backgroundLeases?.active === 0, 'Hidden packaged Pi runs did not settle and release their background leases', ); - await setMainWindowVisible(electronApplication, true); await page.getByText('RESILIENCE_ACTIVE').last().waitFor({ state: 'visible', timeout: 30_000 }); if (!await resilienceComposer.isEnabled()) { throw new Error('Packaged composer stayed disabled after the hidden run settled'); diff --git a/src/pages/Chat/CodingConversationHeader.tsx b/src/pages/Chat/CodingConversationHeader.tsx index a9670a6..a93f34a 100644 --- a/src/pages/Chat/CodingConversationHeader.tsx +++ b/src/pages/Chat/CodingConversationHeader.tsx @@ -48,6 +48,32 @@ const THINKING_OPTIONS: Array<{ value: ConversationThinkingLevel; label: string { value: 'high', label: '高思考' }, ]; +interface LocalActionError { + message: string; + backendCode?: string; + startedGeneration: number; + startedSeq: number; +} + +function localActionError( + error: unknown, + startedGeneration: number, + startedSeq: number, +): LocalActionError { + const details = error && typeof error === 'object' + && 'details' in error + && error.details + && typeof error.details === 'object' + ? error.details as { backendCode?: unknown } + : null; + return { + message: error instanceof Error ? error.message : String(error), + ...(typeof details?.backendCode === 'string' ? { backendCode: details.backendCode } : {}), + startedGeneration, + startedSeq, + }; +} + function runLabel(snapshot: ConversationSnapshot | null): string { if (!snapshot) return '正在准备'; const { run } = snapshot; @@ -90,7 +116,7 @@ export function CodingConversationHeader({ const vendors = useProviderStore((state) => state.vendors); const options = useMemo(() => buildCodingModelOptions(accounts, vendors), [accounts, vendors]); const [busyAction, setBusyAction] = useState(null); - const [actionError, setActionError] = useState(null); + const [actionError, setActionError] = useState(null); const [renameOpen, setRenameOpen] = useState(false); const [titleDraft, setTitleDraft] = useState(conversation?.title ?? ''); @@ -110,13 +136,27 @@ export function CodingConversationHeader({ ? Math.min(100, Math.round((context.usedTokens / context.contextWindow) * 100)) : 0; const recoverable = Boolean(snapshot?.run.error?.recoverable || snapshot?.worker.error?.recoverable); + const runtimeErrorCode = snapshot?.run.error?.code ?? snapshot?.worker.error?.code; + const cursorAdvancedPastAction = Boolean(actionError && snapshot && ( + snapshot.cursor.workerGeneration > actionError.startedGeneration + || (snapshot.cursor.workerGeneration === actionError.startedGeneration + && snapshot.cursor.seq > actionError.startedSeq) + )); + const visibleActionError = actionError?.backendCode === 'CODING_REQUEST_UNCERTAIN' + && !running + && runtimeErrorCode !== 'CODING_REQUEST_UNCERTAIN' + && cursorAdvancedPastAction + ? null + : actionError; const perform = (key: string, action: () => Promise) => { if (busyAction || !conversation) return; + const startedGeneration = snapshot?.cursor.workerGeneration ?? 0; + const startedSeq = snapshot?.cursor.seq ?? 0; setBusyAction(key); setActionError(null); void action() - .catch((error) => setActionError(error instanceof Error ? error.message : String(error))) + .catch((error) => setActionError(localActionError(error, startedGeneration, startedSeq))) .finally(() => setBusyAction((current) => current === key ? null : current)); }; @@ -226,7 +266,7 @@ export function CodingConversationHeader({ )} - {actionError &&

{actionError}

} + {visibleActionError &&

{visibleActionError.message}

} diff --git a/tests/unit/coding-feature-ui.test.tsx b/tests/unit/coding-feature-ui.test.tsx index a0c6faa..3e78d6d 100644 --- a/tests/unit/coding-feature-ui.test.tsx +++ b/tests/unit/coding-feature-ui.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers'; +import type { ConversationSnapshot } from '@/types/coding-conversation'; const interactionApi = vi.hoisted(() => ({ respond: vi.fn(), @@ -275,6 +276,95 @@ describe('PI-130 feature-complete Coding UI', () => { expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument(); }); + it('clears a compact confirmation uncertainty when the authoritative run settles', async () => { + interactionApi.compact.mockRejectedValueOnce(Object.assign( + new Error('请求确认延迟,可能仍在执行。请等待结果,或中止/恢复后再重试。'), + { details: { backendCode: 'CODING_REQUEST_UNCERTAIN' } }, + )); + const conversation = { + id: 'conversation-compact-uncertain', + agentId: 'agent-1', + title: 'Compact uncertainty', + archivedAt: null, + unread: false, + createdAt: '2026-08-25T00:00:00.000Z', + updatedAt: '2026-08-25T00:00:00.000Z', + model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' as const }, + modelResolution: 'resolved' as const, + }; + const snapshot = (run: ConversationSnapshot['run'], seq = 1): ConversationSnapshot => ({ + schemaVersion: 1, + conversation: { + id: conversation.id, + projectId: 'project-1', + agentId: conversation.agentId, + title: conversation.title, + model: { model: conversation.model, modelResolution: 'resolved' }, + }, + nodes: [], + run, + queue: { items: [] }, + context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' }, + pendingInteractions: [], + worker: { status: 'ready', generation: 1 }, + cursor: { workerGeneration: 1, seq }, + }); + const callbacks = { + onRename: vi.fn(async () => undefined), + onArchive: vi.fn(async () => undefined), + onToggleUnread: vi.fn(async () => undefined), + onFork: vi.fn(async () => undefined), + onRefresh: vi.fn(async () => undefined), + onRecover: vi.fn(async () => undefined), + onOpenInspector: vi.fn(), + }; + const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader'); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '整理上下文' })); + expect(await screen.findByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument(); + rerender( + , + ); + + await waitFor(() => expect(screen.queryByText(/请求确认延迟,可能仍在执行/)) + .not.toBeInTheDocument()); + }); + it('blocks overlapping mutations but keeps abort and recover available while confirmation is uncertain', async () => { interactionApi.abort.mockResolvedValue(undefined); const recover = vi.fn(async () => undefined); diff --git a/tests/unit/pi-background-lifecycle.test.ts b/tests/unit/pi-background-lifecycle.test.ts index c2c06e5..657db9d 100644 --- a/tests/unit/pi-background-lifecycle.test.ts +++ b/tests/unit/pi-background-lifecycle.test.ts @@ -36,6 +36,8 @@ class LifecycleFakeWorker implements PiConversationWorker { private readonly events = new Set<(event: PiRpcEvent) => void>(); private readonly invalidations = new Set<(error: PiProcessError) => void>(); private timeoutType: string | null = null; + private deferredType: string | null = null; + private deferredReject: ((error: unknown) => void) | null = null; private compacted = false; private lateResult: PiRpcRequestOptions['onLateResult']; @@ -57,6 +59,12 @@ class LifecycleFakeWorker implements PiConversationWorker { )), 10_000); }); } + if (this.deferredType === command.type) { + this.deferredType = null; + await new Promise((_resolve, reject) => { + this.deferredReject = reject; + }); + } const data = command.type === 'get_state' ? { sessionId: `session-${this.id}`, @@ -132,6 +140,15 @@ class LifecycleFakeWorker implements PiConversationWorker { this.timeoutType = type; } + deferNext(type: string): void { + this.deferredType = type; + } + + rejectDeferred(error: unknown): void { + this.deferredReject?.(error); + this.deferredReject = null; + } + completeLateSuccess(): void { this.lateResult?.({ response: { type: 'response', id: 'late-proof', success: true }, @@ -354,7 +371,7 @@ describe('Pi run background lifecycle lease', () => { willRetry: false, }); expect((await runtime.getSnapshot(runningConversation.id)).context.compaction).toBe('idle'); - workersByConversation.get(runningConversation.id)!.emit({ type: 'agent_settled' }); + workersByConversation.get(runningConversation.id)!.completeLateSuccess(); await expect.poll(async () => ( await runtime.getSnapshot(runningConversation.id) ).run.status).toBe('idle'); @@ -363,6 +380,31 @@ describe('Pi run background lifecycle lease', () => { ); expect(controller.getLeaseCount()).toBe(0); + workersByConversation.get(runningConversation.id)!.deferNext('compact'); + const rejectedCompact = runtime.compact(runningConversation.id); + void rejectedCompact.catch(() => undefined); + await vi.advanceTimersByTimeAsync(0); + workersByConversation.get(runningConversation.id)!.emit({ + type: 'compaction_start', + reason: 'manual', + }); + workersByConversation.get(runningConversation.id)!.emit({ + type: 'compaction_end', + reason: 'manual', + aborted: false, + willRetry: false, + errorMessage: 'Compaction failed: controlled rejection', + }); + await vi.advanceTimersByTimeAsync(0); + expect((await runtime.getSnapshot(runningConversation.id)).run.status).toBe('error'); + workersByConversation.get(runningConversation.id)!.rejectDeferred(new PiProcessError( + 'PI_RPC_RESPONSE_ERROR', + 'controlled compact rejection', + { generation: 1 }, + )); + await expect(rejectedCompact).rejects.toBeInstanceOf(PiProcessError); + expect(controller.getLeaseCount()).toBe(0); + await vi.advanceTimersByTimeAsync(100); expect(onSleep).toHaveBeenCalledTimes(1); expect(onStopRuntime).toHaveBeenCalledTimes(1); diff --git a/tests/unit/pi-conversation-runtime.test.ts b/tests/unit/pi-conversation-runtime.test.ts index 6261534..fd0abb2 100644 --- a/tests/unit/pi-conversation-runtime.test.ts +++ b/tests/unit/pi-conversation-runtime.test.ts @@ -510,8 +510,8 @@ describe('Pi Conversation runtime', () => { expect((await runtime.getSnapshot(right.id)).run.status).toBe('error'); expect(activeBackgroundLeases).toBe(0); await runtime.compact(right.id); - expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting'); - expect(activeBackgroundLeases).toBe(1); + await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle'); + expect(activeBackgroundLeases).toBe(0); expect(workers.get(right.id)!.requests.at(-1)).toEqual({ type: 'compact' }); expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle'); const rightDurable = { @@ -529,7 +529,7 @@ describe('Pi Conversation runtime', () => { durableSessions.set(right.id, rightDurable); workers.get(right.id)!.setSessionData(rightDurable); workers.get(right.id)!.emit({ type: 'agent_end' }); - expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting'); + expect((await runtime.getSnapshot(right.id)).run.status).toBe('idle'); workers.get(right.id)!.emit({ type: 'agent_settled' }); await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle'); expect(activeBackgroundLeases).toBe(0); diff --git a/tests/unit/pi-release-proof-wiring.test.ts b/tests/unit/pi-release-proof-wiring.test.ts index 74932ae..3e26f0c 100644 --- a/tests/unit/pi-release-proof-wiring.test.ts +++ b/tests/unit/pi-release-proof-wiring.test.ts @@ -40,6 +40,9 @@ 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('keepRecentTokens: 1'); + expect(proofSource).toContain("mode === 'resilience' && delayedCompactionArmed"); + expect(mainSource).toContain("userDataDir: app.getPath('userData')"); expect(proofSource).toContain('delayNextTopLevelConfirmationForProof'); expect(mainSource).toContain("'resilience.arm-compact-delay'"); expect(mainSource).toContain("'resilience.arm-prompt-delay'"); @@ -47,5 +50,19 @@ describe('Pi packaged release proof wiring', () => { expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.arm-prompt-delay')"); expect(scriptSource).toContain("status?.target?.errorCode === 'CODING_REQUEST_UNCERTAIN'"); expect(scriptSource).toContain('status?.target?.completedCompactions >= 1'); + const releaseParents = scriptSource.indexOf( + "await evaluateProof(electronApplication, 'resilience.release-parents')", + ); + const restoreVisible = scriptSource.indexOf( + 'await setMainWindowVisible(electronApplication, true)', + releaseParents, + ); + const awaitSettlement = scriptSource.indexOf( + 'await waitForResilienceProof(', + releaseParents, + ); + expect(releaseParents).toBeGreaterThan(-1); + expect(restoreVisible).toBeGreaterThan(releaseParents); + expect(restoreVisible).toBeLessThan(awaitSettlement); }); }); diff --git a/tests/unit/pi-worker-pool.test.ts b/tests/unit/pi-worker-pool.test.ts index 001292e..0c2cde3 100644 --- a/tests/unit/pi-worker-pool.test.ts +++ b/tests/unit/pi-worker-pool.test.ts @@ -7,6 +7,7 @@ import { PiWorkerPool, type PiConversationWorker, type PiWorkerOpenResult, + type PiWorkerPoolEvent, } from '../../electron/coding-runtime/pi/worker-pool'; import type { PiProcessError } from '../../electron/coding-runtime/pi/process-errors'; import { PiProcessError as PiProcessFailure } from '../../electron/coding-runtime/pi/process-errors'; @@ -224,6 +225,34 @@ describe('Pi worker pool', () => { expect(pool.getResilienceProofDiagnostics().runs).toEqual({ active: 0, waiting: 0 }); }); + it('settles compact from its authoritative RPC success without agent_settled', async () => { + const worker = new FakeWorker('worker-target'); + const events: PiWorkerPoolEvent[] = []; + const pool = new PiWorkerPool({ + maxIdle: 2, + openWorker: async () => ({ + worker, + session: { piSessionId: 'session-target', sessionKey: 'key-target' }, + }), + }); + pool.subscribe((event) => events.push(event)); + await pool.prepare(conversation('conversation-target')); + + const ticket = pool.startTopLevel({ + conversationId: 'conversation-target', + runId: 'run-compact-success', + command: { type: 'compact' }, + }); + + await expect(ticket.accepted).resolves.toMatchObject({ success: true }); + expect(pool.getActiveRun('conversation-target')).toBeNull(); + expect(pool.getState('conversation-target')?.state).toBe('idle'); + expect(events).toContainEqual(expect.objectContaining({ + type: 'top-level.settled', + runId: 'run-compact-success', + })); + }); + it('treats agent settlement as authoritative when the RPC confirmation is still pending', async () => { const worker = new FakeWorker('worker-target'); const pool = new PiWorkerPool({