fix(pi): retain ownership for uncertain mutations
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user