fix(pi): retain ownership for uncertain mutations

This commit is contained in:
2026-08-25 23:53:54 +08:00
parent 621ebb1781
commit 019cbb115a
21 changed files with 1113 additions and 67 deletions

View File

@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AppError } from '@/lib/error-model';
import type { ConversationSnapshot } from '@/types/coding-conversation';
import type {
CodingConversationMetadata,
@@ -596,6 +597,89 @@ describe('CodingChatPanel first Conversation', () => {
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledTimes(2));
});
it('clears a confirmation uncertainty automatically after the authoritative run settles', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.submit.mockRejectedValue(new AppError(
'UNKNOWN',
'请求确认延迟,可能仍在执行。请等待结果,或中止/恢复后再重试。',
undefined,
{ backendCode: 'CODING_REQUEST_UNCERTAIN' },
));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
const textbox = await screen.findByRole('textbox');
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id));
fireEvent.change(textbox, { target: { value: 'Slow prompt' } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
expect(await screen.findByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument();
expect(textbox).toHaveValue('Slow prompt');
act(() => codingConversationStore.getState().applyPatchBatchEvent({
type: 'patch-batch',
conversationId: conversation.id,
workerGeneration: 0,
fromSeq: 1,
toSeq: 1,
items: [{
seq: 1,
at: 1_000,
runId: 'run-uncertain',
patch: {
op: 'run.state',
run: {
status: 'running',
runId: 'run-uncertain',
mode: 'prompt',
error: {
code: 'CODING_REQUEST_UNCERTAIN',
message: '请求确认延迟,可能仍在执行。',
recoverable: true,
},
},
},
}],
}));
await waitFor(() => expect(screen.getByRole('button', { name: '整理上下文' })).toBeDisabled());
act(() => codingConversationStore.getState().applyPatchBatchEvent({
type: 'patch-batch',
conversationId: conversation.id,
workerGeneration: 0,
fromSeq: 2,
toSeq: 2,
items: [{
seq: 2,
at: 12_000,
runId: 'run-uncertain',
patch: {
op: 'run.state',
run: {
status: 'idle',
runId: 'run-uncertain',
mode: 'prompt',
settledAt: 12_000,
terminalReason: 'completed',
},
},
}],
}));
await waitFor(() => expect(screen.queryByText(/请求确认延迟,可能仍在执行/))
.not.toBeInTheDocument());
expect(screen.queryByText(/本地编程运行时暂时不可用/)).not.toBeInTheDocument();
expect(textbox).toHaveValue('Slow prompt');
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
});
it('caps one message at 16 images and uploads at most four concurrently', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });

View File

@@ -587,7 +587,21 @@ describe('coding Conversation store', () => {
})).rejects.toThrow('Delivery is uncertain');
source.fail();
source.open();
source.emit('snapshot', snapshotEvent(snapshot('conversation-a')));
const activeSnapshot = snapshot('conversation-a');
source.emit('snapshot', snapshotEvent({
...activeSnapshot,
run: {
status: 'running',
runId: 'run-uncertain',
mode: 'prompt',
startedAt: 1_000,
error: {
code: 'CODING_REQUEST_UNCERTAIN',
message: '请求确认延迟,可能仍在执行。',
recoverable: true,
},
},
}));
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Do not replay');
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
@@ -596,6 +610,22 @@ describe('coding Conversation store', () => {
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
.toMatchObject({ id: 'node-1', status: 'optimistic' });
expect(submitPrompt).toHaveBeenCalledTimes(1);
store.getState().applyPatchBatchEvent(patchBatch('conversation-a', 1, [{
op: 'run.state',
run: {
status: 'idle',
runId: 'run-uncertain',
mode: 'prompt',
startedAt: 1_000,
settledAt: 12_000,
terminalReason: 'completed',
},
}]));
expect(store.getState().requestsByConversationId['conversation-a']).toEqual({});
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Do not replay');
expect(store.getState().entriesByConversationId['conversation-a'].error).toBeNull();
});
it('keeps a primed first Conversation usable while runtime preparation is still held', async () => {

View File

@@ -764,7 +764,7 @@ describe('PI-100 coding core Host contract', () => {
uncertainCalls = 0;
override async prompt(input: PromptConversationInput) {
if (input.clientRequestId === 'request-uncertain') {
if (input.clientRequestId.startsWith('request-uncertain')) {
this.uncertainCalls += 1;
throw new CodingRuntimeContractError(
'CODING_REQUEST_UNCERTAIN',
@@ -794,6 +794,25 @@ describe('PI-100 coding core Host contract', () => {
await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({
code: 'CODING_REQUEST_UNCERTAIN',
});
const routeResponse = await dispatchHostApiRequest(context(result), {
path: `/api/coding/conversations/${conversation.id}/prompt`,
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
clientRequestId: 'request-uncertain-route',
mode: 'prompt',
text: 'Still running',
attachments: [],
}),
});
expect(routeResponse).toMatchObject({
status: 409,
json: {
code: 'CODING_REQUEST_UNCERTAIN',
error: '请求确认延迟,可能仍在执行。请等待结果,或中止/恢复后再重试。',
},
});
expect(JSON.stringify(routeResponse.json)).not.toContain('本地编程运行时暂时不可用');
for (let index = 0; index < 512; index += 1) {
await result.conversations.acceptPrompt({
conversationId: conversation.id,
@@ -806,9 +825,78 @@ describe('PI-100 coding core Host contract', () => {
await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({
code: 'CODING_REQUEST_UNCERTAIN',
});
expect(runtime.uncertainCalls).toBe(1);
expect(runtime.uncertainCalls).toBe(2);
}, 15_000);
it('rejects model and fork mutations before persistence while confirmation is uncertain', async () => {
class UncertainMutationRuntime extends InMemoryConversationRuntime {
sourceConversationId = '';
readonly forkInputs: Parameters<InMemoryConversationRuntime['fork']>[0][] = [];
override async getSnapshot(conversationId: string): Promise<ConversationSnapshot> {
const snapshot = await super.getSnapshot(conversationId);
if (conversationId !== this.sourceConversationId) return snapshot;
return {
...snapshot,
nodes: [{
kind: 'message',
id: 'node-user-uncertain',
sourceEntryId: 'entry-user-uncertain',
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', id: 'text-user-uncertain', text: 'Fork later', status: 'complete' }],
}],
run: {
status: 'running',
runId: 'run-uncertain',
mode: 'prompt',
startedAt: 1_000,
error: {
code: 'CODING_REQUEST_UNCERTAIN',
message: '请求确认延迟,可能仍在执行。',
recoverable: true,
},
},
};
}
override async fork(input: Parameters<InMemoryConversationRuntime['fork']>[0]) {
this.forkInputs.push(structuredClone(input));
return await super.fork(input);
}
}
const runtime = new UncertainMutationRuntime();
const result = await setup(runtime);
const source = await createConversation(result.conversations);
runtime.sourceConversationId = source.id;
await result.conversations.getSnapshot(source.id);
const store = result.projects.conversationStore(result.root);
const create = vi.spyOn(store, 'create');
const setModelState = vi.spyOn(store, 'setModelState');
create.mockClear();
setModelState.mockClear();
await expect(result.conversations.setModel(source.id, {
...MODEL,
modelId: 'model-next',
})).rejects.toMatchObject({
status: 409,
code: 'CODING_REQUEST_UNCERTAIN',
});
await expect(result.conversations.fork(source.id, 'entry-user-uncertain')).rejects.toMatchObject({
status: 409,
code: 'CODING_REQUEST_UNCERTAIN',
});
expect(setModelState).not.toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
expect(runtime.forkInputs).toEqual([]);
await expect(store.read()).resolves.toMatchObject({
conversations: [expect.objectContaining({ id: source.id, model: MODEL })],
});
});
it('disposes and archives a partially created fork before metadata rollback', async () => {
let bindFork: ((conversationId: string) => Promise<void>) | undefined;
let forkTargetId = '';

View File

@@ -182,7 +182,7 @@ describe('PI-130 feature-complete Coding UI', () => {
expect(await screen.findByText(/请求可能已失效/)).toBeInTheDocument();
});
it('keeps model, thinking, abort, metadata, and fork controls on the selected Conversation', async () => {
it('keeps model, thinking, metadata, and fork controls on the selected idle Conversation', async () => {
const { useProviderStore } = await import('@/stores/providers');
useProviderStore.setState({
accounts: [{
@@ -239,7 +239,7 @@ describe('PI-130 feature-complete Coding UI', () => {
},
},
nodes: [],
run: { status: 'running', runId: 'run-1', mode: 'prompt' },
run: { status: 'idle' },
queue: { items: [] },
context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' },
pendingInteractions: [],
@@ -270,13 +270,84 @@ describe('PI-130 feature-complete Coding UI', () => {
expect(within(thinkingSelect).getAllByRole('option')).toHaveLength(1);
expect(within(thinkingSelect).getByRole('option', { name: '高思考' })).toBeInTheDocument();
expect(within(thinkingSelect).queryByRole('option', { name: '关闭思考' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '中止' }));
await waitFor(() => expect(interactionApi.abort).toHaveBeenCalledWith('conversation-1'));
fireEvent.click(screen.getByRole('button', { name: '创建分支' }));
await waitFor(() => expect(callbacks.fork).toHaveBeenCalledOnce());
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);
const fork = vi.fn(async () => undefined);
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
render(
<CodingConversationHeader
conversation={{
id: 'conversation-uncertain',
agentId: 'agent-1',
title: 'Slow provider',
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' },
modelResolution: 'resolved',
}}
snapshot={{
schemaVersion: 1,
conversation: {
id: 'conversation-uncertain',
projectId: 'project-1',
agentId: 'agent-1',
title: 'Slow provider',
model: {
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
availableThinkingLevels: ['off', 'medium'],
},
},
nodes: [],
run: {
status: 'running',
runId: 'run-uncertain',
mode: 'prompt',
error: {
code: 'CODING_REQUEST_UNCERTAIN',
message: '请求确认延迟,可能仍在执行。',
recoverable: true,
},
},
queue: { items: [] },
context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' },
pendingInteractions: [],
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 2 },
}}
connectionState="live"
onRename={vi.fn(async () => undefined)}
onArchive={vi.fn(async () => undefined)}
onToggleUnread={vi.fn(async () => undefined)}
onFork={fork}
onRefresh={vi.fn(async () => undefined)}
onRecover={recover}
onOpenInspector={vi.fn()}
/>,
);
expect(screen.getByRole('combobox', { name: '当前对话模型' })).toBeDisabled();
expect(screen.getByRole('combobox', { name: '当前对话思考级别' })).toBeDisabled();
expect(screen.getByRole('button', { name: '整理上下文' })).toBeDisabled();
expect(screen.getByRole('button', { name: '创建分支' })).toBeDisabled();
expect(screen.queryByText('本地编程运行时暂时不可用')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '中止' }));
await waitFor(() => expect(interactionApi.abort).toHaveBeenCalledWith('conversation-uncertain'));
await waitFor(() => expect(screen.getByRole('button', { name: '恢复' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
await waitFor(() => expect(recover).toHaveBeenCalledOnce());
expect(fork).not.toHaveBeenCalled();
});
it('does not let an old tools load overwrite the newly selected Conversation inspector', async () => {
let resolveOld!: (value: { commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }) => void;
const oldCommands = new Promise<{ commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }>((resolve) => {

View File

@@ -24,7 +24,7 @@ import type {
PiRpcResponse,
} from '../../electron/coding-runtime/pi/rpc-client';
import type { PiWorkerStopReason } from '../../electron/coding-runtime/pi/worker-process';
import type { PiProcessError } from '../../electron/coding-runtime/pi/process-errors';
import { PiProcessError } from '../../electron/coding-runtime/pi/process-errors';
import { BackgroundLifecycleController } from '../../electron/main/background-lifecycle';
const roots: string[] = [];
@@ -35,14 +35,28 @@ class LifecycleFakeWorker implements PiConversationWorker {
readonly stopReasons: PiWorkerStopReason[] = [];
private readonly events = new Set<(event: PiRpcEvent) => void>();
private readonly invalidations = new Set<(error: PiProcessError) => void>();
private timeoutType: string | null = null;
private compacted = false;
private lateResult: PiRpcRequestOptions['onLateResult'];
constructor(readonly id: string, readonly generation: number) {}
async request<T = unknown>(
command: PiRpcCommand,
_options?: PiRpcRequestOptions,
options?: PiRpcRequestOptions,
): Promise<PiRpcResponse<T>> {
this.requests.push(structuredClone(command));
if (this.timeoutType === command.type) {
this.timeoutType = null;
this.lateResult = options?.onLateResult;
await new Promise<void>((_resolve, reject) => {
setTimeout(() => reject(new PiProcessError(
'PI_RPC_TIMEOUT',
`fake ${command.type} confirmation timeout`,
{ generation: this.generation },
)), 10_000);
});
}
const data = command.type === 'get_state'
? {
sessionId: `session-${this.id}`,
@@ -52,7 +66,29 @@ class LifecycleFakeWorker implements PiConversationWorker {
pendingMessageCount: 0,
}
: command.type === 'get_entries'
? { entries: [], leafId: null }
? this.compacted
? {
entries: [
{
type: 'message',
id: 'entry-kept',
parentId: null,
timestamp: NOW,
message: { role: 'user', content: 'retained', timestamp: 1 },
},
{
type: 'compaction',
id: 'entry-compaction-proof',
parentId: 'entry-kept',
timestamp: NOW,
summary: 'not exposed',
firstKeptEntryId: 'entry-kept',
tokensBefore: 5_000,
},
],
leafId: 'entry-compaction-proof',
}
: { entries: [], leafId: null }
: command.type === 'get_session_stats'
? {
contextUsage: { tokens: 0, contextWindow: 100_000, percent: 0 },
@@ -86,9 +122,23 @@ class LifecycleFakeWorker implements PiConversationWorker {
}
emit(event: PiRpcEvent): void {
if (event.type === 'compaction_end' && event.aborted !== true && typeof event.errorMessage !== 'string') {
this.compacted = true;
}
for (const listener of this.events) listener(event);
}
timeoutNext(type: string): void {
this.timeoutType = type;
}
completeLateSuccess(): void {
this.lateResult?.({
response: { type: 'response', id: 'late-proof', success: true },
});
this.lateResult = undefined;
}
async stop(reason: PiWorkerStopReason) {
this.stopReasons.push(reason);
return { mode: 'stdin-close' as const, code: 0, signal: null };
@@ -101,7 +151,7 @@ afterEach(async () => {
});
describe('Pi run background lifecycle lease', () => {
it('keeps accepted running and queued work alive while hidden, then evicts idle workers', async () => {
it('keeps confirmation-uncertain and queued work alive while hidden, then evicts idle workers', async () => {
vi.useFakeTimers();
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-background-'));
roots.push(projectPath);
@@ -203,13 +253,39 @@ describe('Pi run background lifecycle lease', () => {
});
await Promise.all(inputs.map(async (input) => await runtime.prepare(input)));
const accepted = await runtime.prompt({
workersByConversation.get(runningConversation.id)!.timeoutNext('prompt');
const uncertain = runtime.prompt({
clientRequestId: 'request-1',
conversationId: runningConversation.id,
mode: 'prompt',
text: 'Keep working while hidden',
attachments: [],
});
void uncertain.catch(() => undefined);
await vi.advanceTimersByTimeAsync(10_000);
await expect(uncertain).rejects.toMatchObject({
publicError: { code: 'CODING_REQUEST_UNCERTAIN' },
});
expect(runtime.getResilienceProofDiagnostics()).toMatchObject({
backgroundLeases: { active: 1 },
pool: { runs: { active: 1, waiting: 0 } },
});
expect((await runtime.getSnapshot(runningConversation.id)).run).toMatchObject({
status: 'running',
error: { code: 'CODING_REQUEST_UNCERTAIN' },
});
await expect(runtime.compact(runningConversation.id)).rejects.toMatchObject({
publicError: { code: 'CODING_REQUEST_UNCERTAIN' },
});
expect(workersByConversation.get(runningConversation.id)!.requests.filter(
({ type }) => type === 'compact',
)).toHaveLength(0);
workersByConversation.get(runningConversation.id)!.completeLateSuccess();
await expect.poll(async () => (
await runtime.getSnapshot(runningConversation.id)
).run.error).toBeUndefined();
expect(controller.getLeaseCount()).toBe(1);
const queued = await runtime.prompt({
clientRequestId: 'request-2',
conversationId: queuedConversation.id,
@@ -217,7 +293,6 @@ describe('Pi run background lifecycle lease', () => {
text: 'Wait safely while hidden',
attachments: [],
});
expect(accepted.accepted).toBe(true);
expect(queued).toMatchObject({ accepted: true, queuePosition: 1 });
expect(controller.getLeaseCount()).toBe(2);
const releaseChild = pool.trackGenerationResource({
@@ -249,6 +324,45 @@ describe('Pi run background lifecycle lease', () => {
expect(releasedLeaseIds.sort()).toEqual(acquiredLeaseIds.sort());
expect(new Set(releasedLeaseIds).size).toBe(releasedLeaseIds.length);
workersByConversation.get(runningConversation.id)!.timeoutNext('compact');
const compact = runtime.compact(runningConversation.id);
void compact.catch(() => undefined);
await vi.advanceTimersByTimeAsync(10_000);
await expect(compact).rejects.toMatchObject({
publicError: { code: 'CODING_REQUEST_UNCERTAIN' },
});
expect(controller.getLeaseCount()).toBe(1);
expect(onStopRuntime).not.toHaveBeenCalled();
expect((await runtime.getSnapshot(runningConversation.id)).run).toMatchObject({
status: 'compacting',
error: { code: 'CODING_REQUEST_UNCERTAIN' },
});
workersByConversation.get(runningConversation.id)!.emit({
type: 'compaction_start',
reason: 'manual',
});
workersByConversation.get(runningConversation.id)!.emit({
type: 'compaction_end',
reason: 'manual',
result: {
summary: 'controlled summary',
firstKeptEntryId: 'entry-kept',
tokensBefore: 5_000,
},
aborted: false,
willRetry: false,
});
expect((await runtime.getSnapshot(runningConversation.id)).context.compaction).toBe('idle');
workersByConversation.get(runningConversation.id)!.emit({ type: 'agent_settled' });
await expect.poll(async () => (
await runtime.getSnapshot(runningConversation.id)
).run.status).toBe('idle');
expect((await runtime.getSnapshot(runningConversation.id)).nodes).toContainEqual(
expect.objectContaining({ kind: 'compaction', status: 'complete' }),
);
expect(controller.getLeaseCount()).toBe(0);
await vi.advanceTimersByTimeAsync(100);
expect(onSleep).toHaveBeenCalledTimes(1);
expect(onStopRuntime).toHaveBeenCalledTimes(1);

View File

@@ -39,5 +39,10 @@ describe('Pi packaged release proof wiring', () => {
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.idle-status')");
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(mainSource).toContain("'resilience.arm-compact-delay'");
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.arm-compact-delay')");
expect(scriptSource).toContain("status?.target?.errorCode === 'CODING_REQUEST_UNCERTAIN'");
expect(scriptSource).toContain('status?.target?.completedCompactions >= 1');
});
});

View File

@@ -1,6 +1,6 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
@@ -57,6 +57,7 @@ async function processAlive(pid: number): Promise<boolean> {
}
afterEach(async () => {
vi.useRealTimers();
await Promise.all(workers.splice(0).map((worker) => worker.stop('test_injection').catch(() => undefined)));
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
recursive: true,
@@ -101,6 +102,49 @@ describe('strict Pi LF JSONL framing', () => {
});
describe('Pi RPC client', () => {
it('keeps a mutation correlated after the 10 second confirmation timeout', async () => {
vi.useFakeTimers();
let written = '';
const writable = new Writable({
write(chunk, _encoding, callback) {
written += chunk.toString();
callback();
},
});
const lateResults: Array<{ success: boolean; code?: string }> = [];
const client = new PiRpcClient(writable, { generation: 1, defaultTimeoutMs: 10_000 });
const requested = client.request(
{ type: 'prompt', message: 'continue after slow preflight' },
{
retainAfterTimeout: true,
onLateResult: (result: { response?: { success: boolean }; error?: PiProcessError }) => {
lateResults.push({
success: result.response?.success === true,
...(result.error ? { code: result.error.code } : {}),
});
},
} as Parameters<PiRpcClient['request']>[1] & {
retainAfterTimeout: true;
onLateResult(result: {
response?: { success: boolean };
error?: PiProcessError;
}): void;
},
);
void requested.catch(() => undefined);
await Promise.resolve();
await vi.advanceTimersByTimeAsync(10_000);
await expect(requested).rejects.toMatchObject({ code: 'PI_RPC_TIMEOUT' });
expect(client.pendingCount).toBe(1);
const command = JSON.parse(written) as { id: string };
client.accept({ type: 'response', id: command.id, success: true });
expect(client.pendingCount).toBe(0);
expect(lateResults).toEqual([{ success: true }]);
});
it('waits for writable completion when the stream applies backpressure', async () => {
let written = '';
let flush: (() => void) | undefined;

View File

@@ -10,7 +10,12 @@ import {
} 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';
import type { PiRpcCommand, PiRpcEvent } from '../../electron/coding-runtime/pi/rpc-client';
import type {
PiRpcCommand,
PiRpcEvent,
PiRpcRequestOptions,
PiRpcResponse,
} from '../../electron/coding-runtime/pi/rpc-client';
import type { PiRuntimeTelemetryEvent } from '../../electron/coding-runtime/pi/telemetry';
import { PiSubagentScheduler } from '../../electron/coding-runtime/pi/subagent';
import type {
@@ -50,14 +55,61 @@ class FakeWorker implements PiConversationWorker {
readonly stopReasons: PiWorkerStopReason[] = [];
private readonly eventListeners = new Set<(event: PiRpcEvent) => void>();
private readonly invalidationListeners = new Set<(error: PiProcessError) => void>();
private timeoutType: string | null = null;
private pendingType: string | null = null;
private lateResult: ((result: {
response?: PiRpcResponse;
error?: PiProcessError;
}) => void) | undefined;
constructor(readonly id: string) {}
async request() {
this.requests.push(arguments[0] as PiRpcCommand);
async request<T = unknown>(command: PiRpcCommand, options?: PiRpcRequestOptions) {
this.requests.push(command);
if (this.pendingType === command.type) {
this.pendingType = null;
return await new Promise<PiRpcResponse<T>>((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => reject(new PiProcessFailure(
'PI_RPC_ABORTED',
`fake ${command.type} was aborted after authoritative settlement`,
)), { once: true });
});
}
if (this.timeoutType === command.type) {
this.timeoutType = null;
this.lateResult = (options as PiRpcRequestOptions & {
onLateResult?(result: {
response?: PiRpcResponse;
error?: PiProcessError;
}): void;
} | undefined)?.onLateResult;
throw new PiProcessFailure('PI_RPC_TIMEOUT', `fake ${command.type} confirmation timeout`);
}
return { type: 'response' as const, id: 'fake', success: true };
}
timeoutNext(type: string): void {
this.timeoutType = type;
}
pendNext(type: string): void {
this.pendingType = type;
}
completeLateSuccess(): void {
this.lateResult?.({
response: { type: 'response', id: 'fake-late', success: true },
});
this.lateResult = undefined;
}
completeLateFailure(): void {
this.lateResult?.({
error: new PiProcessFailure('PI_RPC_RESPONSE_ERROR', 'fake late rejection'),
});
this.lateResult = undefined;
}
async send(command: PiRpcCommand): Promise<void> {
this.requests.push(command);
}
@@ -95,6 +147,108 @@ class FakeWorker implements PiConversationWorker {
}
describe('Pi worker pool', () => {
it('retains top-level ownership after a mutation confirmation timeout', async () => {
const workers = new Map<string, FakeWorker>();
const pool = new PiWorkerPool({
maxRunning: 2,
maxIdle: 4,
openWorker: async ({ conversation: input }) => {
const worker = new FakeWorker(`worker-${input.conversationId}`);
workers.set(input.conversationId, worker);
return {
worker,
session: {
piSessionId: `session-${input.conversationId}`,
sessionKey: `key-${input.conversationId}`,
},
};
},
});
await Promise.all([
pool.prepare(conversation('conversation-target')),
pool.prepare(conversation('conversation-sibling')),
]);
workers.get('conversation-target')!.timeoutNext('prompt');
const target = pool.startTopLevel({
conversationId: 'conversation-target',
runId: 'run-target',
command: { type: 'prompt', message: 'slow preflight' },
});
await expect(target.accepted).rejects.toMatchObject({ code: 'PI_RPC_TIMEOUT' });
expect(pool.getActiveRun('conversation-target')).toMatchObject({ runId: 'run-target' });
expect(pool.getResilienceProofDiagnostics().runs).toEqual({ active: 1, waiting: 0 });
expect(() => pool.startTopLevel({
conversationId: 'conversation-target',
runId: 'run-overlap',
command: { type: 'compact' },
})).toThrow('Conversation already has a top-level run');
const sibling = pool.startTopLevel({
conversationId: 'conversation-sibling',
runId: 'run-sibling',
command: { type: 'prompt', message: 'independent sibling' },
});
await expect(sibling.accepted).resolves.toMatchObject({ success: true });
workers.get('conversation-sibling')!.emit({ type: 'agent_settled' });
workers.get('conversation-target')!.completeLateSuccess();
expect(pool.getActiveRun('conversation-target')).toMatchObject({ runId: 'run-target' });
workers.get('conversation-target')!.emit({ type: 'agent_settled' });
expect(pool.getActiveRun('conversation-target')).toBeNull();
expect(pool.getResilienceProofDiagnostics().runs).toEqual({ active: 0, waiting: 0 });
});
it('releases uncertain top-level ownership after a late explicit failure', async () => {
const worker = new FakeWorker('worker-target');
const pool = new PiWorkerPool({
maxIdle: 2,
openWorker: async () => ({
worker,
session: { piSessionId: 'session-target', sessionKey: 'key-target' },
}),
});
await pool.prepare(conversation('conversation-target'));
worker.timeoutNext('compact');
const ticket = pool.startTopLevel({
conversationId: 'conversation-target',
runId: 'run-compact',
command: { type: 'compact' },
});
await expect(ticket.accepted).rejects.toMatchObject({ code: 'PI_RPC_TIMEOUT' });
expect(pool.getActiveRun('conversation-target')).toMatchObject({ runId: 'run-compact' });
worker.completeLateFailure();
await expect.poll(() => pool.getActiveRun('conversation-target')).toBeNull();
expect(pool.getResilienceProofDiagnostics().runs).toEqual({ active: 0, waiting: 0 });
});
it('treats agent settlement as authoritative when the RPC confirmation is still pending', async () => {
const worker = new FakeWorker('worker-target');
const pool = new PiWorkerPool({
maxIdle: 2,
openWorker: async () => ({
worker,
session: { piSessionId: 'session-target', sessionKey: 'key-target' },
}),
});
await pool.prepare(conversation('conversation-target'));
worker.pendNext('prompt');
const ticket = pool.startTopLevel({
conversationId: 'conversation-target',
runId: 'run-settled-first',
command: { type: 'prompt', message: 'settle before response' },
});
await expect.poll(() => worker.requests.some(({ type }) => type === 'prompt')).toBe(true);
worker.emit({ type: 'agent_settled' });
await expect(ticket.accepted).resolves.toMatchObject({ success: true });
expect(pool.getActiveRun('conversation-target')).toBeNull();
expect(pool.getResilienceProofDiagnostics().runs).toEqual({ active: 0, waiting: 0 });
});
it('injects a proof failure only into the requested current generation', async () => {
const workers = new Map<string, FakeWorker>();
const pool = new PiWorkerPool({