fix(pi): settle delayed compact ownership

This commit is contained in:
brother7 committed 2026-08-26 09:41:25 +08:00
1 parent f5e6a04c7d
commit 9f05e2d7e1
12 files changed
+334 -25

No files matched your search

@@ -8,7 +8,7 @@
- Worktree: D:\Datas\OthersProjects\makelore-pi-background-run-lease-6a4e2c91 - Worktree: D:\Datas\OthersProjects\makelore-pi-background-run-lease-6a4e2c91
- Base commit: 92f4c91088e79252aca76af3279b184da68e1eb7 - Base commit: 92f4c91088e79252aca76af3279b184da68e1eb7
- Owner: codex-root - Owner: codex-root
- Status: Implementing follow-up - Status: Ready for integration
## Scope ## Scope
@@ -133,13 +133,21 @@ Follow-up plan from cumulative HEAD `621ebb17810394f6f7b97154cb01217bc9112857`:
service performs model/fork rejection before metadata persistence or fork service performs model/fork rejection before metadata persistence or fork
target creation; sibling Conversations retain independent permits. target creation; sibling Conversations retain independent permits.
- Manual compact uses the same retained ownership through - Manual compact uses the same retained ownership through
`compaction_start`, `compaction_end`, authoritative settlement, and hydrate. `compaction_start`, `compaction_end`, correlated RPC settlement, and
The final-ASAR proof now arms a controlled 12-second compact response and hydrate. Locked Pi `0.84.2` does not emit `agent_settled` for a manual
records its running/uncertain and completed/idle states. compact, so the pool now emits a target-scoped top-level settlement after
- Renderer submission errors retain their stable backend code. An uncertain the correlated compact success (including a late success) and the runtime
submission keeps the draft and prevents overlap, shows the actionable delay releases its lease exactly once. If `compaction_end(error)` terminalizes the
message instead of a runtime-outage banner, and automatically removes the projection before the RPC rejection arrives, the later failure cleanup also
transient message when the authoritative run reaches a terminal state. 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 Model, thinking, compact, and fork controls are disabled while the run is
active; abort and recover remain available. 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 old implementation retired the correlation and released active ownership at
10 seconds, returned a raw timeout, and retained the UI uncertainty after a 10 seconds, returned a raw timeout, and retained the UI uncertainty after a
completed run. 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 and compact, late success/failure, settled-before-response, same-target
mutation exclusion with zero fork/model persistence, sibling isolation, mutation exclusion with zero fork/model persistence, sibling isolation,
safe Host error mapping, Renderer automatic unlock, and final-ASAR wiring. safe Host error mapping, Renderer automatic unlock, and final-ASAR wiring.
- `pnpm run typecheck` — passed after the final runtime/Renderer/proof changes. - `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 - `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`. 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. followed by the isolated pressure file / 1 test passed.
- `pnpm run build:vite` — passed for Renderer, Main, Preload, and utility - `pnpm run build:vite` — passed for Renderer, Main, Preload, and utility
bundles; only existing dynamic-import and chunk-size warnings were emitted. bundles; only existing dynamic-import and chunk-size warnings were emitted.
- `pnpm run test:electron:windows` — passed: 2 files / 4 tests. - `pnpm run test:electron:windows` — passed: 2 files / 4 tests.
- Final amended-HEAD NSIS, artifact closure, 12-second packaged UI/Main proof, - Final clean-HEAD NSIS, artifact closure, 12-second packaged UI/Main proof,
Authenticode, and zero-residual-process results are completed after the task Authenticode, and zero-residual-process results are run after this task
documentation/registry commit so the returned artifact binds to a clean documentation commit so the returned artifact binds to the docs-bearing
cumulative HEAD. cumulative HEAD.
- The first candidate packaged proof intentionally required - The first candidate packaged proof intentionally required
`CODING_REQUEST_UNCERTAIN` but the controlled Provider's message-shape `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 through the existing Main fault-injection surface, is absent from Renderer
and product configuration, and preserves the real packaged Pi command, and product configuration, and preserves the real packaged Pi command,
session, Provider request, events, and cleanup path. 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 - `pnpm install --frozen-lockfile` — passed with package-manager-pinned pnpm
`10.33.4` and locked Pi `0.84.2`. `10.33.4` and locked Pi `0.84.2`.
+14 -3
View File
@@ -1,6 +1,6 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { execFile } from 'node:child_process'; 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 { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
@@ -26,6 +26,7 @@ import type { ConversationPatchEnvelope, PrepareConversationInput } from '../con
import { PiManagedExtensionHost } from './extension-host'; import { PiManagedExtensionHost } from './extension-host';
import { PiManagedInputRevisionCoordinator } from './managed-input-revision'; import { PiManagedInputRevisionCoordinator } from './managed-input-revision';
import { runPiReleasePressureCleanup } from './release-proof-cleanup'; import { runPiReleasePressureCleanup } from './release-proof-cleanup';
import { ensurePiManagedPaths } from './resource-loader';
import type { PiRpcEvent } from './rpc-client'; import type { PiRpcEvent } from './rpc-client';
import { createPiManagedWorkerOpener, PiConversationRuntime } from './runtime'; import { createPiManagedWorkerOpener, PiConversationRuntime } from './runtime';
import { PiSessionRegistry } from './session-registry'; import { PiSessionRegistry } from './session-registry';
@@ -510,7 +511,9 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
return; return;
} }
const toolNames = toolNamesFrom(body); const toolNames = toolNamesFrom(body);
const role: ProofWorkerRole = toolNames.includes('subagent') ? 'parent' : 'child'; const role: ProofWorkerRole = mode === 'resilience' && delayedCompactionArmed
? 'parent'
: toolNames.includes('subagent') ? 'parent' : 'child';
const toolResult = hasToolResult(body); const toolResult = hasToolResult(body);
const providerRequest: ProviderRequest = { const providerRequest: ProviderRequest = {
role, role,
@@ -552,7 +555,7 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
respondWithSubagentCall(response, model, 'coding'); respondWithSubagentCall(response, model, 'coding');
return; return;
} }
if (role === 'parent' && delayedCompactionArmed) { if (delayedCompactionArmed) {
delayedCompactionArmed = false; delayedCompactionArmed = false;
const entry = { role, response } satisfies HeldProviderResponse; const entry = { role, response } satisfies HeldProviderResponse;
held.add(entry); held.add(entry);
@@ -1743,6 +1746,7 @@ async function waitForResilienceStatus(
export async function startFinalAsarResilienceProof(input: { export async function startFinalAsarResilienceProof(input: {
composition: CodingProductComposition; composition: CodingProductComposition;
projectPath: string; projectPath: string;
userDataDir: string;
hostProxyBaseUrl: string; hostProxyBaseUrl: string;
hostToken: string; hostToken: string;
}): Promise<{ }): Promise<{
@@ -1766,6 +1770,13 @@ export async function startFinalAsarResilienceProof(input: {
const providerService = getProviderService(); const providerService = getProviderService();
await providerService.createAccount(proxyProviderAccount(input.hostProxyBaseUrl)); await providerService.createAccount(proxyProviderAccount(input.hostProxyBaseUrl));
await providerService.setDefaultAccount(PROOF_ACCOUNT_ID); await providerService.setDefaultAccount(PROOF_ACCOUNT_ID);
const managedPaths = await ensurePiManagedPaths(input.userDataDir);
await writeFile(path.join(managedPaths.configDir, 'settings.json'), JSON.stringify({
compaction: {
reserveTokens: 128,
keepRecentTokens: 1,
},
}, null, 2), 'utf8');
const project = await input.composition.projects.createProject({ projectPath: input.projectPath }); const project = await input.composition.projects.createProject({ projectPath: input.projectPath });
projectId = project.project.id; projectId = project.project.id;
await createCodingProjectAgent(input.projectPath, { await createCodingProjectAgent(input.projectPath, {
+39 -1
View File
@@ -1398,6 +1398,41 @@ export class PiConversationRuntime implements CodingConversationRuntime {
}); });
return; return;
} }
if (event.type === 'top-level.settled') {
void this.enqueueProjection(event.conversationId, async () => {
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') { if (event.type === 'top-level.failed') {
void this.enqueueProjection(event.conversationId, async () => { void this.enqueueProjection(event.conversationId, async () => {
await this.failRun( await this.failRun(
@@ -1525,8 +1560,11 @@ export class PiConversationRuntime implements CodingConversationRuntime {
const current = this.states.get(conversationId)?.snapshot?.run; const current = this.states.get(conversationId)?.snapshot?.run;
const snapshotGeneration = this.states.get(conversationId)?.snapshot?.cursor.workerGeneration; const snapshotGeneration = this.states.get(conversationId)?.snapshot?.cursor.workerGeneration;
if (current?.runId !== runId if (current?.runId !== runId
|| runIsTerminal(current.status)
|| (generation !== undefined && snapshotGeneration !== generation)) return; || (generation !== undefined && snapshotGeneration !== generation)) return;
if (runIsTerminal(current.status)) {
if (releaseBackgroundLease) this.releaseRunBackgroundLease(conversationId, runId);
return;
}
const publicError = runtimeFailure(error); const publicError = runtimeFailure(error);
this.emit(conversationId, { this.emit(conversationId, {
op: 'run.state', op: 'run.state',
+24
View File
@@ -117,6 +117,12 @@ export type PiWorkerPoolEvent =
generation: number; generation: number;
runId: string; runId: string;
} }
| {
type: 'top-level.settled';
conversationId: string;
generation: number;
runId: string;
}
| { | {
type: 'top-level.failed'; type: 'top-level.failed';
conversationId: string; conversationId: string;
@@ -756,6 +762,15 @@ export class PiWorkerPool {
onLateResult: (result) => this.handleLateTopLevelResult(current!, run, result), onLateResult: (result) => this.handleLateTopLevelResult(current!, run, result),
}); });
this.confirmTopLevel(current, run); 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); run.resolve(response);
} catch (error) { } catch (error) {
const active = this.activeRuns.get(run.conversationId); const active = this.activeRuns.get(run.conversationId);
@@ -823,6 +838,15 @@ export class PiWorkerPool {
generation: record.generation, generation: record.generation,
runId: run.runId, 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; return;
} }
if (result.error.code === 'PI_RPC_EXITED' if (result.error.code === 'PI_RPC_EXITED'
+1
View File
@@ -965,6 +965,7 @@ export async function runPiReleaseProofE2E(action: PiReleaseProofAction) {
resilience: await startFinalAsarResilienceProof({ resilience: await startFinalAsarResilienceProof({
composition: codingProducts, composition: codingProducts,
projectPath: join(app.getPath('userData'), 'pi-resilience-proof-project'), 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`, hostProxyBaseUrl: `http://127.0.0.1:${address.port}/api/ai-proxy/v1`,
hostToken: getHostApiToken(), hostToken: getHostApiToken(),
}), }),
+1 -1
View File
@@ -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)}`); throw new Error(`Hidden packaged Pi run was stopped despite active leases: ${JSON.stringify(backgroundHidden?.resilience)}`);
} }
await evaluateProof(electronApplication, 'resilience.release-parents'); await evaluateProof(electronApplication, 'resilience.release-parents');
await setMainWindowVisible(electronApplication, true);
await waitForResilienceProof( await waitForResilienceProof(
electronApplication, electronApplication,
(status) => status?.target?.runStatus === 'idle' (status) => status?.target?.runStatus === 'idle'
@@ -619,7 +620,6 @@ export async function runPackagedProductProof(options) {
&& status?.resources?.backgroundLeases?.active === 0, && status?.resources?.backgroundLeases?.active === 0,
'Hidden packaged Pi runs did not settle and release their background leases', '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 }); await page.getByText('RESILIENCE_ACTIVE').last().waitFor({ state: 'visible', timeout: 30_000 });
if (!await resilienceComposer.isEnabled()) { if (!await resilienceComposer.isEnabled()) {
throw new Error('Packaged composer stayed disabled after the hidden run settled'); throw new Error('Packaged composer stayed disabled after the hidden run settled');
+43 -3
View File
@@ -48,6 +48,32 @@ const THINKING_OPTIONS: Array<{ value: ConversationThinkingLevel; label: string
{ value: 'high', label: '高思考' }, { 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 { function runLabel(snapshot: ConversationSnapshot | null): string {
if (!snapshot) return '正在准备'; if (!snapshot) return '正在准备';
const { run } = snapshot; const { run } = snapshot;
@@ -90,7 +116,7 @@ export function CodingConversationHeader({
const vendors = useProviderStore((state) => state.vendors); const vendors = useProviderStore((state) => state.vendors);
const options = useMemo(() => buildCodingModelOptions(accounts, vendors), [accounts, vendors]); const options = useMemo(() => buildCodingModelOptions(accounts, vendors), [accounts, vendors]);
const [busyAction, setBusyAction] = useState<string | null>(null); const [busyAction, setBusyAction] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<LocalActionError | null>(null);
const [renameOpen, setRenameOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false);
const [titleDraft, setTitleDraft] = useState(conversation?.title ?? ''); const [titleDraft, setTitleDraft] = useState(conversation?.title ?? '');
@@ -110,13 +136,27 @@ export function CodingConversationHeader({
? Math.min(100, Math.round((context.usedTokens / context.contextWindow) * 100)) ? Math.min(100, Math.round((context.usedTokens / context.contextWindow) * 100))
: 0; : 0;
const recoverable = Boolean(snapshot?.run.error?.recoverable || snapshot?.worker.error?.recoverable); 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<void>) => { const perform = (key: string, action: () => Promise<void>) => {
if (busyAction || !conversation) return; if (busyAction || !conversation) return;
const startedGeneration = snapshot?.cursor.workerGeneration ?? 0;
const startedSeq = snapshot?.cursor.seq ?? 0;
setBusyAction(key); setBusyAction(key);
setActionError(null); setActionError(null);
void action() 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)); .finally(() => setBusyAction((current) => current === key ? null : current));
}; };
@@ -226,7 +266,7 @@ export function CodingConversationHeader({
</Button> </Button>
</div> </div>
)} )}
{actionError && <p className="border-t border-destructive/10 bg-destructive/5 px-5 py-2 text-xs text-destructive" role="alert">{actionError}</p>} {visibleActionError && <p className="border-t border-destructive/10 bg-destructive/5 px-5 py-2 text-xs text-destructive" role="alert">{visibleActionError.message}</p>}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}> <Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent> <DialogContent>
+90
View File
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers'; import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers';
import type { ConversationSnapshot } from '@/types/coding-conversation';
const interactionApi = vi.hoisted(() => ({ const interactionApi = vi.hoisted(() => ({
respond: vi.fn(), respond: vi.fn(),
@@ -275,6 +276,95 @@ describe('PI-130 feature-complete Coding UI', () => {
expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument(); 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(
<CodingConversationHeader
conversation={conversation}
snapshot={snapshot({ status: 'idle' })}
connectionState="live"
{...callbacks}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '整理上下文' }));
expect(await screen.findByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument();
rerender(
<CodingConversationHeader
conversation={conversation}
snapshot={snapshot({
status: 'compacting',
runId: 'run-compact-uncertain',
error: {
code: 'CODING_REQUEST_UNCERTAIN',
message: '请求确认延迟,可能仍在执行。',
recoverable: true,
},
}, 2)}
connectionState="live"
{...callbacks}
/>,
);
expect(screen.getByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument();
rerender(
<CodingConversationHeader
conversation={conversation}
snapshot={snapshot({
status: 'idle',
runId: 'run-compact-uncertain',
settledAt: 12_000,
terminalReason: 'completed',
}, 3)}
connectionState="live"
{...callbacks}
/>,
);
await waitFor(() => expect(screen.queryByText(/请求确认延迟,可能仍在执行/))
.not.toBeInTheDocument());
});
it('blocks overlapping mutations but keeps abort and recover available while confirmation is uncertain', async () => { it('blocks overlapping mutations but keeps abort and recover available while confirmation is uncertain', async () => {
interactionApi.abort.mockResolvedValue(undefined); interactionApi.abort.mockResolvedValue(undefined);
const recover = vi.fn(async () => undefined); const recover = vi.fn(async () => undefined);
+43 -1
View File
@@ -36,6 +36,8 @@ class LifecycleFakeWorker implements PiConversationWorker {
private readonly events = new Set<(event: PiRpcEvent) => void>(); private readonly events = new Set<(event: PiRpcEvent) => void>();
private readonly invalidations = new Set<(error: PiProcessError) => void>(); private readonly invalidations = new Set<(error: PiProcessError) => void>();
private timeoutType: string | null = null; private timeoutType: string | null = null;
private deferredType: string | null = null;
private deferredReject: ((error: unknown) => void) | null = null;
private compacted = false; private compacted = false;
private lateResult: PiRpcRequestOptions['onLateResult']; private lateResult: PiRpcRequestOptions['onLateResult'];
@@ -57,6 +59,12 @@ class LifecycleFakeWorker implements PiConversationWorker {
)), 10_000); )), 10_000);
}); });
} }
if (this.deferredType === command.type) {
this.deferredType = null;
await new Promise<void>((_resolve, reject) => {
this.deferredReject = reject;
});
}
const data = command.type === 'get_state' const data = command.type === 'get_state'
? { ? {
sessionId: `session-${this.id}`, sessionId: `session-${this.id}`,
@@ -132,6 +140,15 @@ class LifecycleFakeWorker implements PiConversationWorker {
this.timeoutType = type; this.timeoutType = type;
} }
deferNext(type: string): void {
this.deferredType = type;
}
rejectDeferred(error: unknown): void {
this.deferredReject?.(error);
this.deferredReject = null;
}
completeLateSuccess(): void { completeLateSuccess(): void {
this.lateResult?.({ this.lateResult?.({
response: { type: 'response', id: 'late-proof', success: true }, response: { type: 'response', id: 'late-proof', success: true },
@@ -354,7 +371,7 @@ describe('Pi run background lifecycle lease', () => {
willRetry: false, willRetry: false,
}); });
expect((await runtime.getSnapshot(runningConversation.id)).context.compaction).toBe('idle'); 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 expect.poll(async () => (
await runtime.getSnapshot(runningConversation.id) await runtime.getSnapshot(runningConversation.id)
).run.status).toBe('idle'); ).run.status).toBe('idle');
@@ -363,6 +380,31 @@ describe('Pi run background lifecycle lease', () => {
); );
expect(controller.getLeaseCount()).toBe(0); 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); await vi.advanceTimersByTimeAsync(100);
expect(onSleep).toHaveBeenCalledTimes(1); expect(onSleep).toHaveBeenCalledTimes(1);
expect(onStopRuntime).toHaveBeenCalledTimes(1); expect(onStopRuntime).toHaveBeenCalledTimes(1);
+3 -3
View File
@@ -510,8 +510,8 @@ describe('Pi Conversation runtime', () => {
expect((await runtime.getSnapshot(right.id)).run.status).toBe('error'); expect((await runtime.getSnapshot(right.id)).run.status).toBe('error');
expect(activeBackgroundLeases).toBe(0); expect(activeBackgroundLeases).toBe(0);
await runtime.compact(right.id); await runtime.compact(right.id);
expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting'); await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle');
expect(activeBackgroundLeases).toBe(1); expect(activeBackgroundLeases).toBe(0);
expect(workers.get(right.id)!.requests.at(-1)).toEqual({ type: 'compact' }); expect(workers.get(right.id)!.requests.at(-1)).toEqual({ type: 'compact' });
expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle'); expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle');
const rightDurable = { const rightDurable = {
@@ -529,7 +529,7 @@ describe('Pi Conversation runtime', () => {
durableSessions.set(right.id, rightDurable); durableSessions.set(right.id, rightDurable);
workers.get(right.id)!.setSessionData(rightDurable); workers.get(right.id)!.setSessionData(rightDurable);
workers.get(right.id)!.emit({ type: 'agent_end' }); 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' }); workers.get(right.id)!.emit({ type: 'agent_settled' });
await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle'); await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle');
expect(activeBackgroundLeases).toBe(0); expect(activeBackgroundLeases).toBe(0);
@@ -40,6 +40,9 @@ describe('Pi packaged release proof wiring', () => {
expect(scriptSource).toContain('setMainWindowVisible(electronApplication, false)'); expect(scriptSource).toContain('setMainWindowVisible(electronApplication, false)');
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.dispose-target')"); expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.dispose-target')");
expect(proofSource).toContain('const PROOF_MUTATION_CONFIRMATION_DELAY_MS = 12_000;'); 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(proofSource).toContain('delayNextTopLevelConfirmationForProof');
expect(mainSource).toContain("'resilience.arm-compact-delay'"); expect(mainSource).toContain("'resilience.arm-compact-delay'");
expect(mainSource).toContain("'resilience.arm-prompt-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("evaluateProof(electronApplication, 'resilience.arm-prompt-delay')");
expect(scriptSource).toContain("status?.target?.errorCode === 'CODING_REQUEST_UNCERTAIN'"); expect(scriptSource).toContain("status?.target?.errorCode === 'CODING_REQUEST_UNCERTAIN'");
expect(scriptSource).toContain('status?.target?.completedCompactions >= 1'); 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);
}); });
}); });
+29
View File
@@ -7,6 +7,7 @@ import {
PiWorkerPool, PiWorkerPool,
type PiConversationWorker, type PiConversationWorker,
type PiWorkerOpenResult, type PiWorkerOpenResult,
type PiWorkerPoolEvent,
} from '../../electron/coding-runtime/pi/worker-pool'; } from '../../electron/coding-runtime/pi/worker-pool';
import type { PiProcessError } from '../../electron/coding-runtime/pi/process-errors'; import type { PiProcessError } from '../../electron/coding-runtime/pi/process-errors';
import { PiProcessError as PiProcessFailure } 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 }); 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 () => { it('treats agent settlement as authoritative when the RPC confirmation is still pending', async () => {
const worker = new FakeWorker('worker-target'); const worker = new FakeWorker('worker-target');
const pool = new PiWorkerPool({ const pool = new PiWorkerPool({