fix(coding-runtime): close Pi extension lifecycle races
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
- Worktree: D:\Datas\OthersProjects\makelore-pi-extension-host-7e4c91a2
|
- Worktree: D:\Datas\OthersProjects\makelore-pi-extension-host-7e4c91a2
|
||||||
- Base commit: 47159b3cbdf06ea66db9a4425e0a9c13bcd73f7f
|
- Base commit: 47159b3cbdf06ea66db9a4425e0a9c13bcd73f7f
|
||||||
- Owner: codex
|
- Owner: codex
|
||||||
- Status: Completed — pending planner review
|
- Status: Completed — correction candidate pending planner re-review
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -99,6 +99,18 @@
|
|||||||
- The Pi 0.84.2 real-child smoke now loads the materialized managed extension
|
- The Pi 0.84.2 real-child smoke now loads the materialized managed extension
|
||||||
through Electron Node and confirms RPC readiness without extension-load or
|
through Electron Node and confirms RPC readiness without extension-load or
|
||||||
stdout-protocol failure. No real external Provider was contacted.
|
stdout-protocol failure. No real external Provider was contacted.
|
||||||
|
- Planner review of initial candidate `3861c32` returned **Needs Fix / Not
|
||||||
|
Done** with four deterministic supported-path findings. The correction
|
||||||
|
closes each finding without changing the PI DAG: recover clears the old run
|
||||||
|
before worker replacement and replacement only continues a run still active
|
||||||
|
in the pool; interaction response ownership is atomic across double-submit
|
||||||
|
and generation invalidation; Host close stops new connections, waits tracked
|
||||||
|
request handlers, and terminates stale lease waiters; UI diagnostics retain
|
||||||
|
only the newest 256 bounded entries.
|
||||||
|
- Added exact regressions for active-run recover, delayed interaction send plus
|
||||||
|
generation cancellation, holder+waiter Host shutdown, and 1,000 unknown UI
|
||||||
|
events. All four planner reproductions now pass locally; PI-070 remains the
|
||||||
|
sole frontier until planner re-review marks the correction Done.
|
||||||
- Real Provider validation remains **Explicitly Waived / Accepted Risk** with
|
- Real Provider validation remains **Explicitly Waived / Accepted Risk** with
|
||||||
`realTurnVerified=false`; provider concurrency, credential isolation, and
|
`realTurnVerified=false`; provider concurrency, credential isolation, and
|
||||||
protocol compatibility are not Pass. macOS x64/arm64 remains deferred to
|
protocol compatibility are not Pass. macOS x64/arm64 remains deferred to
|
||||||
@@ -123,6 +135,22 @@
|
|||||||
- `corepack pnpm test` — 199 files / 2192 tests passed before the final bundle
|
- `corepack pnpm test` — 199 files / 2192 tests passed before the final bundle
|
||||||
integration test was added; that new test then passed independently, making
|
integration test was added; that new test then passed independently, making
|
||||||
all 200 current test files green across the two recorded runs.
|
all 200 current test files green across the two recorded runs.
|
||||||
|
- Planner correction focused suite — 4 files / 9 tests passed, including all
|
||||||
|
four deterministic review regressions.
|
||||||
|
- Post-correction all-Pi run — 19 files / 91 tests passed; the existing
|
||||||
|
`pi-conversation-runtime` Windows temporary JSON `rename EPERM` occurred in
|
||||||
|
the aggregate run, then the same test passed 1/1 in isolation. No Pi behavior
|
||||||
|
assertion failed.
|
||||||
|
- Post-correction full suite — 199 files / 2194 tests passed; only that same
|
||||||
|
pre-existing Windows temporary-file `rename EPERM` prevented a single green
|
||||||
|
aggregate result. The affected runtime test passed immediately in isolation,
|
||||||
|
so all 200 files / 2195 tests are green across the recorded aggregate plus
|
||||||
|
isolated rerun. The failure is not in a PI-070-owned path and no retry or
|
||||||
|
filesystem workaround was added.
|
||||||
|
- Post-correction `corepack pnpm run typecheck` and `lint:check` — passed; lint
|
||||||
|
remains 0 errors with the same 6 unrelated warnings.
|
||||||
|
- Post-correction `corepack pnpm run build:vite` — passed for all four build
|
||||||
|
targets with unchanged existing warnings.
|
||||||
- `corepack pnpm run build:vite` — passed for Renderer, Electron Main,
|
- `corepack pnpm run build:vite` — passed for Renderer, Electron Main,
|
||||||
Preload, and release utility output. Existing dynamic-import and chunk-size
|
Preload, and release utility output. Existing dynamic-import and chunk-size
|
||||||
warnings remain unchanged.
|
warnings remain unchanged.
|
||||||
|
|||||||
@@ -63,9 +63,11 @@ export class PiManagedExtensionHost {
|
|||||||
private readonly leases: PiProjectWriteLeaseCoordinator;
|
private readonly leases: PiProjectWriteLeaseCoordinator;
|
||||||
private readonly registrations = new Map<string, WorkerRegistrationRecord>();
|
private readonly registrations = new Map<string, WorkerRegistrationRecord>();
|
||||||
private readonly runBindings = new Map<string, string>();
|
private readonly runBindings = new Map<string, string>();
|
||||||
|
private readonly requestFlights = new Set<Promise<void>>();
|
||||||
private server: Server | null = null;
|
private server: Server | null = null;
|
||||||
private bridgeUrl: string | null = null;
|
private bridgeUrl: string | null = null;
|
||||||
private startFlight: Promise<string> | null = null;
|
private startFlight: Promise<string> | null = null;
|
||||||
|
private closing = false;
|
||||||
|
|
||||||
constructor(leases = new PiProjectWriteLeaseCoordinator()) {
|
constructor(leases = new PiProjectWriteLeaseCoordinator()) {
|
||||||
this.leases = leases;
|
this.leases = leases;
|
||||||
@@ -131,16 +133,20 @@ export class PiManagedExtensionHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
for (const record of [...this.registrations.values()]) this.disposeRecord(record);
|
|
||||||
this.runBindings.clear();
|
|
||||||
const server = this.server;
|
const server = this.server;
|
||||||
this.server = null;
|
this.server = null;
|
||||||
this.bridgeUrl = null;
|
this.bridgeUrl = null;
|
||||||
this.startFlight = null;
|
this.startFlight = null;
|
||||||
if (!server) return;
|
this.closing = true;
|
||||||
await new Promise<void>((resolve, reject) => {
|
const closed = server ? new Promise<void>((resolve, reject) => {
|
||||||
server.close((error) => error ? reject(error) : resolve());
|
server.close((error) => error ? reject(error) : resolve());
|
||||||
});
|
}) : Promise.resolve();
|
||||||
|
for (const record of [...this.registrations.values()]) this.disposeRecord(record);
|
||||||
|
this.runBindings.clear();
|
||||||
|
await Promise.allSettled([...this.requestFlights]);
|
||||||
|
server?.closeIdleConnections();
|
||||||
|
await closed;
|
||||||
|
this.closing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private start(): Promise<string> {
|
private start(): Promise<string> {
|
||||||
@@ -148,7 +154,10 @@ export class PiManagedExtensionHost {
|
|||||||
if (this.startFlight) return this.startFlight;
|
if (this.startFlight) return this.startFlight;
|
||||||
this.startFlight = new Promise<string>((resolve, reject) => {
|
this.startFlight = new Promise<string>((resolve, reject) => {
|
||||||
const server = createServer((request, response) => {
|
const server = createServer((request, response) => {
|
||||||
void this.handle(request, response);
|
const flight = this.handle(request, response).finally(() => {
|
||||||
|
this.requestFlights.delete(flight);
|
||||||
|
});
|
||||||
|
this.requestFlights.add(flight);
|
||||||
});
|
});
|
||||||
server.once('error', reject);
|
server.once('error', reject);
|
||||||
server.listen(0, '127.0.0.1', () => {
|
server.listen(0, '127.0.0.1', () => {
|
||||||
@@ -159,6 +168,7 @@ export class PiManagedExtensionHost {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.server = server;
|
this.server = server;
|
||||||
|
this.closing = false;
|
||||||
this.bridgeUrl = `http://127.0.0.1:${address.port}/v1/worker`;
|
this.bridgeUrl = `http://127.0.0.1:${address.port}/v1/worker`;
|
||||||
resolve(this.bridgeUrl);
|
resolve(this.bridgeUrl);
|
||||||
});
|
});
|
||||||
@@ -226,6 +236,7 @@ export class PiManagedExtensionHost {
|
|||||||
);
|
);
|
||||||
if (record.runId !== value.runId || this.registrations.get(token) !== record) {
|
if (record.runId !== value.runId || this.registrations.get(token) !== record) {
|
||||||
lease.release();
|
lease.release();
|
||||||
|
this.respond(response, 409, { error: 'Worker run identity is stale' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
record.leases.set(value.resourceId, lease);
|
record.leases.set(value.resourceId, lease);
|
||||||
@@ -266,7 +277,10 @@ export class PiManagedExtensionHost {
|
|||||||
|
|
||||||
private respond(response: ServerResponse, status: number, body: Record<string, unknown>): void {
|
private respond(response: ServerResponse, status: number, body: Record<string, unknown>): void {
|
||||||
if (response.writableEnded) return;
|
if (response.writableEnded) return;
|
||||||
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
response.writeHead(status, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
...(this.closing ? { connection: 'close' } : {}),
|
||||||
|
});
|
||||||
response.end(JSON.stringify(body));
|
response.end(JSON.stringify(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const MAX_TITLE_LENGTH = 256;
|
|||||||
const MAX_EDITOR_TEXT_LENGTH = 64 * 1024;
|
const MAX_EDITOR_TEXT_LENGTH = 64 * 1024;
|
||||||
const MAX_WIDGET_LINES = 32;
|
const MAX_WIDGET_LINES = 32;
|
||||||
const MAX_WIDGET_LINE_LENGTH = 512;
|
const MAX_WIDGET_LINE_LENGTH = 512;
|
||||||
|
const MAX_DIAGNOSTICS = 256;
|
||||||
|
|
||||||
export type PiExtensionUiProjection =
|
export type PiExtensionUiProjection =
|
||||||
| { kind: 'notify'; conversationId: string; message: string; level: 'info' | 'warning' | 'error' }
|
| { kind: 'notify'; conversationId: string; message: string; level: 'info' | 'warning' | 'error' }
|
||||||
@@ -136,7 +137,7 @@ export class PiExtensionUiProjector {
|
|||||||
}
|
}
|
||||||
if (event.method === 'set_editor_text' && typeof event.text === 'string') {
|
if (event.method === 'set_editor_text' && typeof event.text === 'string') {
|
||||||
if (this.getDraftRevision(conversationId) !== run.draftRevision) {
|
if (this.getDraftRevision(conversationId) !== run.draftRevision) {
|
||||||
this.diagnostics.push({ method: event.method, reason: 'stale-draft-revision' });
|
this.recordDiagnostic({ method: event.method, reason: 'stale-draft-revision' });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -151,12 +152,17 @@ export class PiExtensionUiProjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private unsupported(method: string): null {
|
private unsupported(method: string): null {
|
||||||
this.diagnostics.push({ method: bounded(method, 128), reason: 'unsupported-ui-method' });
|
this.recordDiagnostic({ method: bounded(method, 128), reason: 'unsupported-ui-method' });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private invalid(method: string): null {
|
private invalid(method: string): null {
|
||||||
this.diagnostics.push({ method: bounded(method, 128), reason: 'invalid-ui-payload' });
|
this.recordDiagnostic({ method: bounded(method, 128), reason: 'invalid-ui-payload' });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private recordDiagnostic(diagnostic: PiExtensionUiDiagnostic): void {
|
||||||
|
this.diagnostics.push(diagnostic);
|
||||||
|
if (this.diagnostics.length > MAX_DIAGNOSTICS) this.diagnostics.shift();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface StoredInteraction {
|
|||||||
interaction: ConversationInteraction;
|
interaction: ConversationInteraction;
|
||||||
generation: number;
|
generation: number;
|
||||||
labels: Map<string, string>;
|
labels: Map<string, string>;
|
||||||
|
phase: 'pending' | 'responding';
|
||||||
untrack(): void;
|
untrack(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ export class PiInteractionStore {
|
|||||||
interaction,
|
interaction,
|
||||||
generation,
|
generation,
|
||||||
labels,
|
labels,
|
||||||
|
phase: 'pending',
|
||||||
untrack: () => undefined,
|
untrack: () => undefined,
|
||||||
};
|
};
|
||||||
stored.untrack = this.transport.trackGenerationResource({
|
stored.untrack = this.transport.trackGenerationResource({
|
||||||
@@ -102,6 +104,7 @@ export class PiInteractionStore {
|
|||||||
async respond(conversationId: string, response: PiInteractionResponse): Promise<ConversationInteraction> {
|
async respond(conversationId: string, response: PiInteractionResponse): Promise<ConversationInteraction> {
|
||||||
const stored = this.pending.get(this.key(conversationId, response.interactionId));
|
const stored = this.pending.get(this.key(conversationId, response.interactionId));
|
||||||
if (!stored) throw new Error('Pi interaction is not pending');
|
if (!stored) throw new Error('Pi interaction is not pending');
|
||||||
|
if (stored.phase === 'responding') throw new Error('Pi interaction response is already in progress');
|
||||||
const state = this.transport.getState(conversationId);
|
const state = this.transport.getState(conversationId);
|
||||||
const active = this.transport.getActiveRun(conversationId);
|
const active = this.transport.getActiveRun(conversationId);
|
||||||
if (state?.generation !== stored.generation
|
if (state?.generation !== stored.generation
|
||||||
@@ -126,7 +129,18 @@ export class PiInteractionStore {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error('Pi interaction response does not match its kind');
|
throw new Error('Pi interaction response does not match its kind');
|
||||||
}
|
}
|
||||||
|
stored.phase = 'responding';
|
||||||
|
try {
|
||||||
await this.transport.send(conversationId, command);
|
await this.transport.send(conversationId, command);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.pending.get(this.key(conversationId, stored.interaction.id)) === stored) {
|
||||||
|
stored.phase = 'pending';
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (this.pending.get(this.key(conversationId, stored.interaction.id)) !== stored) {
|
||||||
|
throw new Error('Pi interaction belongs to a stale worker run');
|
||||||
|
}
|
||||||
return this.finish(stored, 'cancelled' in response
|
return this.finish(stored, 'cancelled' in response
|
||||||
? 'cancelled'
|
? 'cancelled'
|
||||||
: stored.interaction.kind === 'confirm' && 'confirmed' in response && !response.confirmed
|
: stored.interaction.kind === 'confirm' && 'confirmed' in response && !response.confirmed
|
||||||
|
|||||||
@@ -798,6 +798,13 @@ export class PiConversationRuntime implements CodingConversationRuntime {
|
|||||||
|
|
||||||
async recover(conversationId: string): Promise<ConversationRuntimeState> {
|
async recover(conversationId: string): Promise<ConversationRuntimeState> {
|
||||||
await this.waitForProjection(conversationId);
|
await this.waitForProjection(conversationId);
|
||||||
|
const before = this.snapshot(conversationId);
|
||||||
|
const generation = this.pool.getState(conversationId)?.generation;
|
||||||
|
if (before.run.runId && generation) {
|
||||||
|
await this.interactions.cancelRun(conversationId, before.run.runId, true);
|
||||||
|
this.extensionUi.endRun(conversationId, before.run.runId);
|
||||||
|
await this.extensionHost?.clearRun(conversationId, generation, before.run.runId);
|
||||||
|
}
|
||||||
const state = await this.pool.recover(conversationId);
|
const state = await this.pool.recover(conversationId);
|
||||||
await this.requestHydration(conversationId, state, false);
|
await this.requestHydration(conversationId, state, false);
|
||||||
this.settleRecoveredRun(conversationId);
|
this.settleRecoveredRun(conversationId);
|
||||||
@@ -1003,8 +1010,12 @@ export class PiConversationRuntime implements CodingConversationRuntime {
|
|||||||
this.replaceWorkerGeneration(event.conversationId, event.state, true);
|
this.replaceWorkerGeneration(event.conversationId, event.state, true);
|
||||||
this.resetProjector(event.conversationId);
|
this.resetProjector(event.conversationId);
|
||||||
const runId = this.states.get(event.conversationId)?.snapshot.run.runId;
|
const runId = this.states.get(event.conversationId)?.snapshot.run.runId;
|
||||||
if (runId) this.extensionUi.replaceGeneration(event.conversationId, event.generation, runId);
|
const activeRun = this.pool.getActiveRun(event.conversationId);
|
||||||
if (this.extensionHost && runId) {
|
const continuesActiveRun = Boolean(runId && activeRun?.runId === runId);
|
||||||
|
if (runId && continuesActiveRun) {
|
||||||
|
this.extensionUi.replaceGeneration(event.conversationId, event.generation, runId);
|
||||||
|
}
|
||||||
|
if (this.extensionHost && runId && continuesActiveRun) {
|
||||||
void this.extensionHost.bindRun(event.conversationId, event.generation, runId).catch((error) => {
|
void this.extensionHost.bindRun(event.conversationId, event.generation, runId).catch((error) => {
|
||||||
this.recordProjectionFailure(event.conversationId, event.generation, error);
|
this.recordProjectionFailure(event.conversationId, event.generation, error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { PiConversationRuntime } from '../../electron/coding-runtime/pi/runtime';
|
import { PiConversationRuntime } from '../../electron/coding-runtime/pi/runtime';
|
||||||
import { PiSessionProjectionError } from '../../electron/coding-runtime/pi/session-projector';
|
import { PiSessionProjectionError } from '../../electron/coding-runtime/pi/session-projector';
|
||||||
import { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry';
|
import { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry';
|
||||||
|
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
|
||||||
import {
|
import {
|
||||||
PiWorkerPool,
|
PiWorkerPool,
|
||||||
type PiConversationWorker,
|
type PiConversationWorker,
|
||||||
@@ -114,6 +115,18 @@ class RuntimeFakeWorker implements PiConversationWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TrackingExtensionHost extends PiManagedExtensionHost {
|
||||||
|
readonly runs = new Map<string, { generation: number; runId: string }>();
|
||||||
|
|
||||||
|
override async bindRun(conversationId: string, generation: number, runId: string): Promise<void> {
|
||||||
|
this.runs.set(conversationId, { generation, runId });
|
||||||
|
}
|
||||||
|
|
||||||
|
override async clearRun(conversationId: string, _generation: number, runId?: string): Promise<void> {
|
||||||
|
if (!runId || this.runs.get(conversationId)?.runId === runId) this.runs.delete(conversationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||||
});
|
});
|
||||||
@@ -189,10 +202,12 @@ describe('Pi Conversation runtime', () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const trackingHost = new TrackingExtensionHost();
|
||||||
const runtime = new PiConversationRuntime({
|
const runtime = new PiConversationRuntime({
|
||||||
pool,
|
pool,
|
||||||
registry: new PiSessionRegistry({ projectStore }),
|
registry: new PiSessionRegistry({ projectStore }),
|
||||||
createId: (kind) => `${kind}-fixed`,
|
createId: (kind) => `${kind}-fixed`,
|
||||||
|
extensionHost: trackingHost,
|
||||||
resolveModel: async (candidate) => {
|
resolveModel: async (candidate) => {
|
||||||
if (candidate.accountId !== 'account-b' || candidate.modelId !== 'model-b') {
|
if (candidate.accountId !== 'account-b' || candidate.modelId !== 'model-b') {
|
||||||
throw new Error('model unavailable');
|
throw new Error('model unavailable');
|
||||||
@@ -483,6 +498,17 @@ describe('Pi Conversation runtime', () => {
|
|||||||
await expect(runtime.getSnapshot(forkTarget.id)).rejects.toMatchObject({
|
await expect(runtime.getSnapshot(forkTarget.id)).rejects.toMatchObject({
|
||||||
publicError: { code: 'CODING_CONVERSATION_NOT_FOUND' },
|
publicError: { code: 'CODING_CONVERSATION_NOT_FOUND' },
|
||||||
});
|
});
|
||||||
|
await runtime.prompt({
|
||||||
|
clientRequestId: 'request-active-recover',
|
||||||
|
conversationId: left.id,
|
||||||
|
mode: 'prompt',
|
||||||
|
text: 'Recover this active run',
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
|
expect(trackingHost.runs.get(left.id)?.runId).toBe('run-fixed');
|
||||||
|
await runtime.recover(left.id);
|
||||||
|
expect(trackingHost.runs.has(left.id)).toBe(false);
|
||||||
|
expect((await runtime.getSnapshot(left.id)).run).toEqual({ status: 'idle' });
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
|
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
|
||||||
|
import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease';
|
||||||
|
|
||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
const hosts: PiManagedExtensionHost[] = [];
|
const hosts: PiManagedExtensionHost[] = [];
|
||||||
@@ -29,6 +30,40 @@ async function post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('managed Pi extension bridge', () => {
|
describe('managed Pi extension bridge', () => {
|
||||||
|
it('finishes a queued HTTP request while closing a holder and waiter', async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-close-'));
|
||||||
|
roots.push(root);
|
||||||
|
const leases = new PiProjectWriteLeaseCoordinator();
|
||||||
|
const host = new PiManagedExtensionHost(leases);
|
||||||
|
hosts.push(host);
|
||||||
|
const holder = await host.registerWorker({
|
||||||
|
conversationId: 'conversation-holder', generation: 1, projectId: 'project-a', extensionsDir: root,
|
||||||
|
});
|
||||||
|
const waiter = await host.registerWorker({
|
||||||
|
conversationId: 'conversation-waiter', generation: 1, projectId: 'project-a', extensionsDir: root,
|
||||||
|
});
|
||||||
|
await Promise.all([
|
||||||
|
host.bindRun('conversation-holder', 1, 'run-holder'),
|
||||||
|
host.bindRun('conversation-waiter', 1, 'run-waiter'),
|
||||||
|
]);
|
||||||
|
const held = await post(holder, {
|
||||||
|
action: 'lease.acquire', conversationId: 'conversation-holder', workerGeneration: 1,
|
||||||
|
runId: 'run-holder', resourceId: 'held-tool',
|
||||||
|
});
|
||||||
|
expect(held.status).toBe(200);
|
||||||
|
const waiting = post(waiter, {
|
||||||
|
action: 'lease.acquire', conversationId: 'conversation-waiter', workerGeneration: 1,
|
||||||
|
runId: 'run-waiter', resourceId: 'waiting-tool',
|
||||||
|
});
|
||||||
|
await expect.poll(() => leases.waitingCount('project-a')).toBe(1);
|
||||||
|
|
||||||
|
await expect(Promise.race([
|
||||||
|
host.close().then(() => 'closed'),
|
||||||
|
new Promise<string>((resolve) => setTimeout(() => resolve('timeout'), 500)),
|
||||||
|
])).resolves.toBe('closed');
|
||||||
|
await expect(waiting).resolves.toMatchObject({ status: 409 });
|
||||||
|
});
|
||||||
|
|
||||||
it('materializes the active run into a replacement generation before spawn', async () => {
|
it('materializes the active run into a replacement generation before spawn', async () => {
|
||||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-rebuild-'));
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-rebuild-'));
|
||||||
roots.push(root);
|
roots.push(root);
|
||||||
|
|||||||
@@ -40,5 +40,15 @@ describe('Pi extension UI projector', () => {
|
|||||||
expect(projector.getDiagnostics()).toEqual([{
|
expect(projector.getDiagnostics()).toEqual([{
|
||||||
method: 'setWidget', reason: 'unsupported-ui-method',
|
method: 'setWidget', reason: 'unsupported-ui-method',
|
||||||
}]);
|
}]);
|
||||||
|
for (let index = 0; index < 1_000; index += 1) {
|
||||||
|
projector.project('conversation-a', 1, 'run-1', {
|
||||||
|
type: 'extension_ui_request', id: `unknown-${index}`, method: `unknown-${index}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const diagnostics = projector.getDiagnostics();
|
||||||
|
expect(diagnostics).toHaveLength(256);
|
||||||
|
expect(diagnostics.at(-1)).toEqual({
|
||||||
|
method: 'unknown-999', reason: 'invalid-ui-payload',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,4 +75,43 @@ describe('Pi interaction store', () => {
|
|||||||
expect(changes.map(({ status }) => status)).toEqual(['cancelled', 'cancelled']);
|
expect(changes.map(({ status }) => status)).toEqual(['cancelled', 'cancelled']);
|
||||||
expect(store.list()).toEqual([]);
|
expect(store.list()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('makes response ownership atomic across double submit and generation invalidation', async () => {
|
||||||
|
const sent: PiRpcCommand[] = [];
|
||||||
|
const changes: ConversationInteraction[] = [];
|
||||||
|
let cancelResource = () => undefined;
|
||||||
|
let releaseSend = () => undefined;
|
||||||
|
const sendGate = new Promise<void>((resolve) => { releaseSend = resolve; });
|
||||||
|
const store = new PiInteractionStore({
|
||||||
|
getState: () => ({
|
||||||
|
conversationId: 'conversation-a', workerId: 'worker-a', state: 'running', generation: 1,
|
||||||
|
session: { piSessionId: 'session-a', sessionKey: 'key-a' },
|
||||||
|
}),
|
||||||
|
getActiveRun: () => ({ generation: 1, runId: 'run-1' }),
|
||||||
|
send: async (_conversationId, command) => {
|
||||||
|
sent.push(command);
|
||||||
|
await sendGate;
|
||||||
|
},
|
||||||
|
trackGenerationResource: (input) => {
|
||||||
|
cancelResource = input.cancel;
|
||||||
|
return () => undefined;
|
||||||
|
},
|
||||||
|
}, (interaction) => changes.push(interaction));
|
||||||
|
store.open('conversation-a', 1, 'run-1', {
|
||||||
|
type: 'extension_ui_request', id: 'question-race', method: 'confirm', title: 'Continue?',
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = store.respond('conversation-a', {
|
||||||
|
interactionId: 'question-race', confirmed: true,
|
||||||
|
});
|
||||||
|
await expect(store.respond('conversation-a', {
|
||||||
|
interactionId: 'question-race', confirmed: false,
|
||||||
|
})).rejects.toThrow('already in progress');
|
||||||
|
cancelResource();
|
||||||
|
releaseSend();
|
||||||
|
|
||||||
|
await expect(first).rejects.toThrow('stale');
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
expect(changes.map(({ status }) => status)).toEqual(['cancelled']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user