fix(coding): bound missing Pi settlement
This commit is contained in:
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { PiAgentServerProcess } from '../../electron/coding-runtime/pi/agent-server-process';
|
||||
import { materializeMakelorePiExtension } from '../../electron/coding-runtime/pi/extensions/makelore-runtime';
|
||||
import { MAKELORE_DEFAULT_LANGUAGE_PROMPT } from '../../electron/coding-runtime/pi/resource-loader';
|
||||
import type { PiRpcEvent } from '../../electron/coding-runtime/pi/rpc-client';
|
||||
import type { PiWorkerProcessOptions } from '../../electron/coding-runtime/pi/worker-process';
|
||||
|
||||
const roots: string[] = [];
|
||||
@@ -124,6 +125,16 @@ describe('Pi Agent Server real process', () => {
|
||||
writeFile(languagePromptPath, MAKELORE_DEFAULT_LANGUAGE_PROMPT),
|
||||
]);
|
||||
const extensionPath = await materializeMakelorePiExtension(extensionDir);
|
||||
const invalidJsonExtensionPath = path.join(extensionDir, 'invalid-json-extension.mjs');
|
||||
await writeFile(invalidJsonExtensionPath, `
|
||||
export function createMakeloreRuntime() {
|
||||
return async function invalidJsonRuntime(pi) {
|
||||
pi.on('message_end', async (event) => ({
|
||||
message: { ...event.message, makeloreInvalidJson: 1n },
|
||||
}));
|
||||
};
|
||||
}
|
||||
`);
|
||||
const runtimeRoot = path.resolve('node_modules/@earendil-works/pi-coding-agent');
|
||||
const server = new PiAgentServerProcess({
|
||||
executablePath: process.execPath,
|
||||
@@ -132,7 +143,11 @@ describe('Pi Agent Server real process', () => {
|
||||
configDir,
|
||||
});
|
||||
|
||||
const workerOptions = (conversationId: string, generation: number): PiWorkerProcessOptions => {
|
||||
const workerOptions = (
|
||||
conversationId: string,
|
||||
generation: number,
|
||||
selectedExtensionPath = extensionPath,
|
||||
): PiWorkerProcessOptions => {
|
||||
const contextFile = path.join(root, `${conversationId}.json`);
|
||||
return {
|
||||
executablePath: process.execPath,
|
||||
@@ -148,7 +163,7 @@ describe('Pi Agent Server real process', () => {
|
||||
'--thinking', 'medium',
|
||||
'--system-prompt', promptPath,
|
||||
'--append-system-prompt', languagePromptPath,
|
||||
'--extension', extensionPath,
|
||||
'--extension', selectedExtensionPath,
|
||||
'--session-id', `session-${conversationId}`,
|
||||
],
|
||||
env: {
|
||||
@@ -163,7 +178,7 @@ describe('Pi Agent Server real process', () => {
|
||||
};
|
||||
};
|
||||
|
||||
await Promise.all(['left', 'right'].map(async (conversationId) => {
|
||||
await Promise.all(['left', 'right', 'broken'].map(async (conversationId) => {
|
||||
await writeFile(path.join(root, `${conversationId}.json`), JSON.stringify({
|
||||
conversationId,
|
||||
workerGeneration: 1,
|
||||
@@ -216,6 +231,25 @@ describe('Pi Agent Server real process', () => {
|
||||
provider.release();
|
||||
await Promise.all([leftSettled, rightSettled]);
|
||||
|
||||
const broken = server.createWorker(workerOptions('broken', 1, invalidJsonExtensionPath));
|
||||
await broken.start();
|
||||
const postAcceptanceFailure = new Promise<PiRpcEvent>((resolve) => {
|
||||
const unsubscribe = broken.subscribe((event) => {
|
||||
if (event.type !== 'makelore_thread_error') return;
|
||||
unsubscribe();
|
||||
resolve(event);
|
||||
});
|
||||
});
|
||||
await expect(broken.request({ type: 'prompt', message: 'BREAK_AFTER_ACCEPTANCE' }))
|
||||
.resolves.toMatchObject({ success: true });
|
||||
await expect(postAcceptanceFailure).resolves.toMatchObject({
|
||||
type: 'makelore_thread_error',
|
||||
code: 'PROMPT_FAILED_AFTER_ACCEPTANCE',
|
||||
});
|
||||
await expect(broken.request<{ isStreaming: boolean }>({ type: 'get_state' }))
|
||||
.resolves.toMatchObject({ data: { isStreaming: false } });
|
||||
await broken.stop('test_injection');
|
||||
|
||||
await left.stop('test_injection');
|
||||
expect(server.processId).toBe(processId);
|
||||
expect(server.activeThreadCount).toBe(1);
|
||||
|
||||
@@ -58,6 +58,12 @@ class FakeWorker implements PiConversationWorker {
|
||||
private readonly invalidationListeners = new Set<(error: PiProcessError) => void>();
|
||||
private timeoutType: string | null = null;
|
||||
private pendingType: string | null = null;
|
||||
private stateData: unknown = {
|
||||
isStreaming: true,
|
||||
isCompacting: false,
|
||||
pendingMessageCount: 0,
|
||||
retryAttempt: 0,
|
||||
};
|
||||
private lateResult: ((result: {
|
||||
response?: PiRpcResponse;
|
||||
error?: PiProcessError;
|
||||
@@ -86,7 +92,18 @@ class FakeWorker implements PiConversationWorker {
|
||||
} | undefined)?.onLateResult;
|
||||
throw new PiProcessFailure('PI_RPC_TIMEOUT', `fake ${command.type} confirmation timeout`);
|
||||
}
|
||||
return { type: 'response' as const, id: 'fake', success: true };
|
||||
return {
|
||||
type: 'response' as const,
|
||||
id: 'fake',
|
||||
success: true,
|
||||
...(command.type === 'get_state'
|
||||
? { data: structuredClone(this.stateData) as T }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
setState(state: unknown): void {
|
||||
this.stateData = structuredClone(state);
|
||||
}
|
||||
|
||||
timeoutNext(type: string): void {
|
||||
@@ -310,6 +327,125 @@ describe('Pi worker pool', () => {
|
||||
expect(pool.getResilienceProofDiagnostics().runs).toEqual({ active: 0, waiting: 0 });
|
||||
});
|
||||
|
||||
it('settles from authoritative idle state when agent_settled is missing and ignores a late duplicate', async () => {
|
||||
const worker = new FakeWorker('worker-target');
|
||||
const events: PiWorkerPoolEvent[] = [];
|
||||
const pool = new PiWorkerPool({
|
||||
maxIdle: 2,
|
||||
settlementProbeIntervalMs: 5,
|
||||
settlementProbeTimeoutMs: 20,
|
||||
terminalSettlementTimeoutMs: 100,
|
||||
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-missing-settled',
|
||||
command: { type: 'prompt', message: 'finish without settlement event' },
|
||||
});
|
||||
await expect(ticket.accepted).resolves.toMatchObject({ success: true });
|
||||
worker.setState({
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
pendingMessageCount: 0,
|
||||
retryAttempt: 0,
|
||||
});
|
||||
|
||||
await expect.poll(() => pool.getActiveRun('conversation-target')).toBeNull();
|
||||
expect(pool.getState('conversation-target')?.state).toBe('idle');
|
||||
expect(events.filter((event) => event.type === 'top-level.settled')).toEqual([
|
||||
expect.objectContaining({ source: 'state_probe' }),
|
||||
]);
|
||||
|
||||
worker.emit({ type: 'agent_settled' });
|
||||
expect(events.filter((event) => event.type === 'top-level.settled')).toHaveLength(1);
|
||||
await pool.shutdown();
|
||||
});
|
||||
|
||||
it('fails only the target thread when an accepted prompt rejects during settlement', async () => {
|
||||
const workers = new Map<string, FakeWorker>();
|
||||
const events: PiWorkerPoolEvent[] = [];
|
||||
const pool = new PiWorkerPool({
|
||||
maxIdle: 2,
|
||||
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}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
pool.subscribe((event) => events.push(event));
|
||||
await Promise.all([
|
||||
pool.prepare(conversation('conversation-target')),
|
||||
pool.prepare(conversation('conversation-sibling')),
|
||||
]);
|
||||
const ticket = pool.startTopLevel({
|
||||
conversationId: 'conversation-target',
|
||||
runId: 'run-post-accept-failure',
|
||||
command: { type: 'prompt', message: 'accepted then failed' },
|
||||
});
|
||||
await expect(ticket.accepted).resolves.toMatchObject({ success: true });
|
||||
|
||||
workers.get('conversation-target')!.emit({
|
||||
type: 'makelore_thread_error',
|
||||
code: 'PROMPT_FAILED_AFTER_ACCEPTANCE',
|
||||
});
|
||||
|
||||
expect(pool.getActiveRun('conversation-target')).toBeNull();
|
||||
expect(pool.getState('conversation-target')).toMatchObject({
|
||||
state: 'crashed',
|
||||
failureCode: 'PI_RPC_PROTOCOL_ERROR',
|
||||
});
|
||||
expect(pool.getState('conversation-sibling')).toMatchObject({ state: 'ready' });
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
type: 'worker.crashed',
|
||||
conversationId: 'conversation-target',
|
||||
}));
|
||||
expect(workers.get('conversation-target')!.requests.filter(({ type }) => type === 'prompt'))
|
||||
.toHaveLength(1);
|
||||
await pool.shutdown();
|
||||
});
|
||||
|
||||
it('bounds a contradictory terminal phase without replaying the accepted prompt', async () => {
|
||||
const worker = new FakeWorker('worker-target');
|
||||
const pool = new PiWorkerPool({
|
||||
maxIdle: 2,
|
||||
settlementProbeIntervalMs: 5,
|
||||
settlementProbeTimeoutMs: 20,
|
||||
terminalSettlementTimeoutMs: 25,
|
||||
openWorker: async () => ({
|
||||
worker,
|
||||
session: { piSessionId: 'session-target', sessionKey: 'key-target' },
|
||||
}),
|
||||
});
|
||||
await pool.prepare(conversation('conversation-target'));
|
||||
const ticket = pool.startTopLevel({
|
||||
conversationId: 'conversation-target',
|
||||
runId: 'run-terminal-stall',
|
||||
command: { type: 'prompt', message: 'terminal stall' },
|
||||
});
|
||||
await expect(ticket.accepted).resolves.toMatchObject({ success: true });
|
||||
worker.emit({
|
||||
type: 'message_end',
|
||||
message: { role: 'assistant', content: [], stopReason: 'stop' },
|
||||
});
|
||||
|
||||
await expect.poll(() => pool.getState('conversation-target')?.state).toBe('crashed');
|
||||
expect(pool.getActiveRun('conversation-target')).toBeNull();
|
||||
expect(worker.requests.filter(({ type }) => type === 'prompt')).toHaveLength(1);
|
||||
await pool.shutdown();
|
||||
});
|
||||
|
||||
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