fix(pi): harden release proof lifecycle
This commit is contained in:
38
electron/coding-runtime/pi/release-proof-cleanup.ts
Normal file
38
electron/coding-runtime/pi/release-proof-cleanup.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export type PiReleasePressureCleanupStep = {
|
||||||
|
name: string;
|
||||||
|
run(): void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runPiReleasePressureCleanup(
|
||||||
|
steps: readonly PiReleasePressureCleanupStep[],
|
||||||
|
options: { stepTimeoutMs?: number } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const stepTimeoutMs = options.stepTimeoutMs ?? 10_000;
|
||||||
|
if (!Number.isSafeInteger(stepTimeoutMs) || stepTimeoutMs <= 0) {
|
||||||
|
throw new Error('PI release pressure cleanup timeout must be a positive safe integer');
|
||||||
|
}
|
||||||
|
const failures: Array<{ name: string; error: unknown }> = [];
|
||||||
|
for (const step of steps) {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
Promise.resolve().then(() => step.run()),
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
reject(new Error(`PI release pressure cleanup timed out: ${step.name}`));
|
||||||
|
}, stepTimeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({ name: step.name, error });
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.length > 0) {
|
||||||
|
throw new AggregateError(
|
||||||
|
failures.map(({ error }) => error),
|
||||||
|
`PI release pressure cleanup failed: ${failures.map(({ name }) => name).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import type { ProviderAccount } from '../../shared/providers/types';
|
|||||||
import type { PrepareConversationInput } from '../contracts';
|
import type { PrepareConversationInput } from '../contracts';
|
||||||
import { PiManagedExtensionHost } from './extension-host';
|
import { PiManagedExtensionHost } from './extension-host';
|
||||||
import { PiManagedInputRevisionCoordinator } from './managed-input-revision';
|
import { PiManagedInputRevisionCoordinator } from './managed-input-revision';
|
||||||
|
import { runPiReleasePressureCleanup } from './release-proof-cleanup';
|
||||||
import type { PiRpcEvent } from './rpc-client';
|
import type { PiRpcEvent } from './rpc-client';
|
||||||
import { createPiManagedWorkerOpener } from './runtime';
|
import { createPiManagedWorkerOpener } from './runtime';
|
||||||
import { PiSessionRegistry } from './session-registry';
|
import { PiSessionRegistry } from './session-registry';
|
||||||
@@ -28,6 +29,7 @@ import { PiProjectWriteLeaseCoordinator, type PiProjectWriteLease } from './writ
|
|||||||
type ProofWorkerRole = 'parent' | 'child';
|
type ProofWorkerRole = 'parent' | 'child';
|
||||||
type ProofProviderMode = 'subagent' | 'pressure';
|
type ProofProviderMode = 'subagent' | 'pressure';
|
||||||
type ProofMilestone = PiRuntimeTelemetryEvent['milestone'] | 'agent.start' | 'provider.first_event';
|
type ProofMilestone = PiRuntimeTelemetryEvent['milestone'] | 'agent.start' | 'provider.first_event';
|
||||||
|
type ProofMilestoneSource = 'main.telemetry' | 'pi.agent_start' | 'pi.assistant_message_start';
|
||||||
|
|
||||||
type TrackedProcess = {
|
type TrackedProcess = {
|
||||||
role: ProofWorkerRole;
|
role: ProofWorkerRole;
|
||||||
@@ -80,7 +82,7 @@ type RealProofComposition = {
|
|||||||
|
|
||||||
type PressureRun = {
|
type PressureRun = {
|
||||||
active: PiReleasePressureSnapshot;
|
active: PiReleasePressureSnapshot;
|
||||||
finish(): Promise<PiReleasePressureSnapshot>;
|
finish(options?: { injectFailureAt?: 'parents.settle' }): Promise<PiReleasePressureSnapshot>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PiReleaseManagedTurnProof {
|
export interface PiReleaseManagedTurnProof {
|
||||||
@@ -89,6 +91,7 @@ export interface PiReleaseManagedTurnProof {
|
|||||||
workerGeneration: number;
|
workerGeneration: number;
|
||||||
milestones: Array<{
|
milestones: Array<{
|
||||||
milestone: ProofMilestone;
|
milestone: ProofMilestone;
|
||||||
|
source: ProofMilestoneSource;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
at: number;
|
at: number;
|
||||||
}>;
|
}>;
|
||||||
@@ -116,6 +119,7 @@ export interface PiReleaseExtensionProof {
|
|||||||
subagentStatus: string;
|
subagentStatus: string;
|
||||||
subagentSummary: string;
|
subagentSummary: string;
|
||||||
materializedExtension: string;
|
materializedExtension: string;
|
||||||
|
providerFirstEventDelayMs: number;
|
||||||
managedTurns: PiReleaseManagedTurnProof[];
|
managedTurns: PiReleaseManagedTurnProof[];
|
||||||
managedWorkerMilestones: PiRuntimeTelemetryEvent[];
|
managedWorkerMilestones: PiRuntimeTelemetryEvent[];
|
||||||
released: {
|
released: {
|
||||||
@@ -129,6 +133,7 @@ export interface PiReleaseExtensionProof {
|
|||||||
const PROOF_ACCOUNT_ID = 'release-proof-account';
|
const PROOF_ACCOUNT_ID = 'release-proof-account';
|
||||||
const PROOF_AGENT_ID = 'release-proof-agent';
|
const PROOF_AGENT_ID = 'release-proof-agent';
|
||||||
const PROOF_MODEL_ID = 'release-proof-model';
|
const PROOF_MODEL_ID = 'release-proof-model';
|
||||||
|
const PROOF_PROVIDER_FIRST_EVENT_DELAY_MS = 75;
|
||||||
const EXPECTED_TURN_MILESTONES: readonly ProofMilestone[] = [
|
const EXPECTED_TURN_MILESTONES: readonly ProofMilestone[] = [
|
||||||
'worker.queue_wait',
|
'worker.queue_wait',
|
||||||
'resources.ready',
|
'resources.ready',
|
||||||
@@ -152,6 +157,19 @@ async function waitFor(predicate: () => boolean, message: string): Promise<void>
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function delay(durationMs: number): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, durationMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAssistantMessageStart(event: PiRpcEvent): boolean {
|
||||||
|
if (event.type !== 'message_start') return false;
|
||||||
|
const message = event.message;
|
||||||
|
return Boolean(message)
|
||||||
|
&& typeof message === 'object'
|
||||||
|
&& !Array.isArray(message)
|
||||||
|
&& (message as { role?: unknown }).role === 'assistant';
|
||||||
|
}
|
||||||
|
|
||||||
function shortRef(value: string): string {
|
function shortRef(value: string): string {
|
||||||
return value.replace(/[^A-Za-z0-9]/g, '').slice(-8).toLowerCase() || 'unknown';
|
return value.replace(/[^A-Za-z0-9]/g, '').slice(-8).toLowerCase() || 'unknown';
|
||||||
}
|
}
|
||||||
@@ -249,6 +267,8 @@ function respondWithSubagentCall(response: ServerResponse, model: string): void
|
|||||||
async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalProofProvider> {
|
async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalProofProvider> {
|
||||||
const requests: ProviderRequest[] = [];
|
const requests: ProviderRequest[] = [];
|
||||||
const held = new Set<HeldProviderResponse>();
|
const held = new Set<HeldProviderResponse>();
|
||||||
|
let closed = false;
|
||||||
|
let closeFlight: Promise<void> | null = null;
|
||||||
const server: Server = createServer(async (request, response) => {
|
const server: Server = createServer(async (request, response) => {
|
||||||
response.once('error', () => undefined);
|
response.once('error', () => undefined);
|
||||||
try {
|
try {
|
||||||
@@ -264,10 +284,12 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
|||||||
requests.push({ role, toolNames, hasToolResult: toolResult });
|
requests.push({ role, toolNames, hasToolResult: toolResult });
|
||||||
|
|
||||||
if (mode === 'subagent' && role === 'parent' && !toolResult) {
|
if (mode === 'subagent' && role === 'parent' && !toolResult) {
|
||||||
|
await delay(PROOF_PROVIDER_FIRST_EVENT_DELAY_MS);
|
||||||
respondWithSubagentCall(response, model);
|
respondWithSubagentCall(response, model);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === 'subagent' && role === 'parent') {
|
if (mode === 'subagent' && role === 'parent') {
|
||||||
|
await delay(PROOF_PROVIDER_FIRST_EVENT_DELAY_MS);
|
||||||
respondWithText(response, model, 'REAL_PARENT_COMPLETE');
|
respondWithText(response, model, 'REAL_PARENT_COMPLETE');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -314,11 +336,20 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
|||||||
releaseChildren: () => release('child'),
|
releaseChildren: () => release('child'),
|
||||||
releaseAll: () => release(),
|
releaseAll: () => release(),
|
||||||
close: async () => {
|
close: async () => {
|
||||||
release();
|
if (closed) return;
|
||||||
await new Promise<void>((resolve, reject) => {
|
if (!closeFlight) {
|
||||||
server.close((error) => (error ? reject(error) : resolve()));
|
closeFlight = (async () => {
|
||||||
server.closeIdleConnections?.();
|
release();
|
||||||
});
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
server.closeIdleConnections?.();
|
||||||
|
});
|
||||||
|
closed = true;
|
||||||
|
})().finally(() => {
|
||||||
|
closeFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await closeFlight;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -522,29 +553,35 @@ function timelineForTurn(
|
|||||||
const agentStart = observed.find(({ event }) => event.type === 'agent_start');
|
const agentStart = observed.find(({ event }) => event.type === 'agent_start');
|
||||||
const agentSettled = [...observed].reverse().find(({ event }) => event.type === 'agent_settled');
|
const agentSettled = [...observed].reverse().find(({ event }) => event.type === 'agent_settled');
|
||||||
const firstProviderEvent = agentStart && observed.find((entry) => (
|
const firstProviderEvent = agentStart && observed.find((entry) => (
|
||||||
entry !== agentStart
|
entry.at >= agentStart.at && isAssistantMessageStart(entry.event)
|
||||||
&& entry.at >= agentStart.at
|
|
||||||
&& entry.event.type !== 'agent_settled'
|
|
||||||
));
|
));
|
||||||
const promptAccepted = byMilestone.get('prompt.accepted');
|
const promptAccepted = byMilestone.get('prompt.accepted');
|
||||||
if (!agentStart || !agentSettled || !firstProviderEvent || !promptAccepted) {
|
if (!agentStart || !agentSettled || !firstProviderEvent || !promptAccepted) {
|
||||||
throw new Error(`Managed ${cold ? 'cold' : 'warm'} turn did not expose provider lifecycle events`);
|
throw new Error(`Managed ${cold ? 'cold' : 'warm'} turn did not expose provider lifecycle events`);
|
||||||
}
|
}
|
||||||
const proofEvents = new Map<ProofMilestone, { milestone: ProofMilestone; durationMs: number; at: number }>();
|
const proofEvents = new Map<ProofMilestone, {
|
||||||
|
milestone: ProofMilestone;
|
||||||
|
source: ProofMilestoneSource;
|
||||||
|
durationMs: number;
|
||||||
|
at: number;
|
||||||
|
}>();
|
||||||
for (const event of runTelemetry) {
|
for (const event of runTelemetry) {
|
||||||
proofEvents.set(event.milestone, {
|
proofEvents.set(event.milestone, {
|
||||||
milestone: event.milestone,
|
milestone: event.milestone,
|
||||||
|
source: 'main.telemetry',
|
||||||
durationMs: event.durationMs,
|
durationMs: event.durationMs,
|
||||||
at: event.at,
|
at: event.at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
proofEvents.set('agent.start', {
|
proofEvents.set('agent.start', {
|
||||||
milestone: 'agent.start',
|
milestone: 'agent.start',
|
||||||
|
source: 'pi.agent_start',
|
||||||
durationMs: Math.max(0, agentStart.at - promptAccepted.at),
|
durationMs: Math.max(0, agentStart.at - promptAccepted.at),
|
||||||
at: agentStart.at,
|
at: agentStart.at,
|
||||||
});
|
});
|
||||||
proofEvents.set('provider.first_event', {
|
proofEvents.set('provider.first_event', {
|
||||||
milestone: 'provider.first_event',
|
milestone: 'provider.first_event',
|
||||||
|
source: 'pi.assistant_message_start',
|
||||||
durationMs: Math.max(0, firstProviderEvent.at - agentStart.at),
|
durationMs: Math.max(0, firstProviderEvent.at - agentStart.at),
|
||||||
at: firstProviderEvent.at,
|
at: firstProviderEvent.at,
|
||||||
});
|
});
|
||||||
@@ -699,6 +736,7 @@ async function startPressureRun(): Promise<PressureRun> {
|
|||||||
providerRequests: providerRequestCounts(provider),
|
providerRequests: providerRequestCounts(provider),
|
||||||
childDiagnostics,
|
childDiagnostics,
|
||||||
})}`,
|
})}`,
|
||||||
|
{ cause: error },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const active = pressureSnapshot(composition, provider, writeLeases);
|
const active = pressureSnapshot(composition, provider, writeLeases);
|
||||||
@@ -709,22 +747,64 @@ async function startPressureRun(): Promise<PressureRun> {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
active,
|
active,
|
||||||
finish: async () => {
|
finish: (() => {
|
||||||
provider.releaseAll();
|
let flight: Promise<PiReleasePressureSnapshot> | null = null;
|
||||||
await Promise.all(dispatches);
|
const cleanup = async (injectFailureAt?: 'parents.settle') => {
|
||||||
await waitFor(
|
const steps = [
|
||||||
() => composition.pool.getDiagnostics().workers.every(({ state }) => state === 'idle'),
|
{ name: 'provider.release', run: () => provider.releaseAll() },
|
||||||
'Persistent Pi parents did not settle after pressure release',
|
{
|
||||||
);
|
name: 'dispatches.settle',
|
||||||
await composition.scheduler.close();
|
run: async () => {
|
||||||
await composition.pool.shutdown();
|
const results = await Promise.allSettled(dispatches);
|
||||||
for (const lease of heldWriteLeases.splice(0)) lease.release();
|
const failures = results.flatMap((result) => (
|
||||||
await composition.extensionHost.close();
|
result.status === 'rejected' ? [result.reason] : []
|
||||||
await provider.close();
|
));
|
||||||
const released = pressureSnapshot(composition, provider, writeLeases);
|
if (failures.length > 0) {
|
||||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
throw new AggregateError(failures, 'PI release pressure dispatch failed');
|
||||||
return released;
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parents.settle',
|
||||||
|
run: async () => await waitFor(
|
||||||
|
() => composition.pool.getDiagnostics().workers.every(({ state }) => state === 'idle'),
|
||||||
|
'Persistent Pi parents did not settle after pressure release',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ name: 'scheduler.close', run: async () => await composition.scheduler.close() },
|
||||||
|
{ name: 'pool.shutdown', run: async () => await composition.pool.shutdown() },
|
||||||
|
{
|
||||||
|
name: 'leases.release',
|
||||||
|
run: () => {
|
||||||
|
for (const lease of heldWriteLeases.splice(0)) lease.release();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'extension.close', run: async () => await composition.extensionHost.close() },
|
||||||
|
{ name: 'provider.close', run: async () => await provider.close() },
|
||||||
|
{
|
||||||
|
name: 'scratch.remove',
|
||||||
|
run: async () => await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (injectFailureAt) {
|
||||||
|
const target = steps.find(({ name }) => name === injectFailureAt);
|
||||||
|
if (!target) throw new Error(`Unknown PI release cleanup failure injection: ${injectFailureAt}`);
|
||||||
|
target.run = () => {
|
||||||
|
throw new Error(`Injected PI release cleanup failure at ${injectFailureAt}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await runPiReleasePressureCleanup(steps);
|
||||||
|
return pressureSnapshot(composition, provider, writeLeases);
|
||||||
|
};
|
||||||
|
return (options?: { injectFailureAt?: 'parents.settle' }) => {
|
||||||
|
if (!flight) {
|
||||||
|
flight = cleanup(options?.injectFailureAt).finally(() => {
|
||||||
|
flight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return flight;
|
||||||
|
};
|
||||||
|
})(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
provider.releaseAll();
|
provider.releaseAll();
|
||||||
@@ -842,6 +922,7 @@ export async function runFinalAsarExtensionProof(): Promise<PiReleaseExtensionPr
|
|||||||
subagentStatus: 'complete',
|
subagentStatus: 'complete',
|
||||||
subagentSummary: 'REAL_CHILD_COMPLETE',
|
subagentSummary: 'REAL_CHILD_COMPLETE',
|
||||||
materializedExtension: 'makelore-runtime-v3.mjs',
|
materializedExtension: 'makelore-runtime-v3.mjs',
|
||||||
|
providerFirstEventDelayMs: PROOF_PROVIDER_FIRST_EVENT_DELAY_MS,
|
||||||
managedTurns,
|
managedTurns,
|
||||||
managedWorkerMilestones: composition.telemetry,
|
managedWorkerMilestones: composition.telemetry,
|
||||||
released,
|
released,
|
||||||
@@ -854,9 +935,12 @@ export async function startFinalAsarPressureProof(): Promise<PiReleasePressureSn
|
|||||||
return pressureRun.active;
|
return pressureRun.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function finishFinalAsarPressureProof(): Promise<PiReleasePressureSnapshot> {
|
export async function finishFinalAsarPressureProof(
|
||||||
|
options?: { injectFailureAt?: 'parents.settle' },
|
||||||
|
): Promise<PiReleasePressureSnapshot> {
|
||||||
const current = pressureRun;
|
const current = pressureRun;
|
||||||
if (!current) throw new Error('PI release pressure proof is not running');
|
if (!current) throw new Error('PI release pressure proof is not running');
|
||||||
pressureRun = null;
|
const released = await current.finish(options);
|
||||||
return await current.finish();
|
if (pressureRun === current) pressureRun = null;
|
||||||
|
return released;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -880,7 +880,11 @@ export async function runLocalPreviewPreflightE2E(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type PiReleaseProofAction = 'extension' | 'pressure.start' | 'pressure.finish';
|
type PiReleaseProofAction =
|
||||||
|
| 'extension'
|
||||||
|
| 'pressure.start'
|
||||||
|
| 'pressure.finish'
|
||||||
|
| 'pressure.finish.inject-failure';
|
||||||
|
|
||||||
export async function runPiReleaseProofE2E(action: PiReleaseProofAction) {
|
export async function runPiReleaseProofE2E(action: PiReleaseProofAction) {
|
||||||
if (!isE2EMode) throw new Error('PI release proof is unavailable');
|
if (!isE2EMode) throw new Error('PI release proof is unavailable');
|
||||||
@@ -896,7 +900,15 @@ export async function runPiReleaseProofE2E(action: PiReleaseProofAction) {
|
|||||||
if (action === 'pressure.start') {
|
if (action === 'pressure.start') {
|
||||||
return { action, packagedMain, pressure: await startFinalAsarPressureProof() };
|
return { action, packagedMain, pressure: await startFinalAsarPressureProof() };
|
||||||
}
|
}
|
||||||
return { action, packagedMain, pressure: await finishFinalAsarPressureProof() };
|
return {
|
||||||
|
action,
|
||||||
|
packagedMain,
|
||||||
|
pressure: await finishFinalAsarPressureProof(
|
||||||
|
action === 'pressure.finish.inject-failure'
|
||||||
|
? { injectFailureAt: 'parents.settle' }
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isE2EMode) {
|
if (isE2EMode) {
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ async function runProductProofSamples(artifact, projectRoot, samples) {
|
|||||||
runtimeRoot: artifact.artifact.runtimeRoot,
|
runtimeRoot: artifact.artifact.runtimeRoot,
|
||||||
electronExecutable: artifact.artifact.executable,
|
electronExecutable: artifact.artifact.executable,
|
||||||
reportPath: undefined,
|
reportPath: undefined,
|
||||||
|
verifyCleanupFailure: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return reports;
|
return reports;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function parseArgs(argv, projectRoot = process.cwd()) {
|
|||||||
runtimeRoot: undefined,
|
runtimeRoot: undefined,
|
||||||
electronExecutable: undefined,
|
electronExecutable: undefined,
|
||||||
reportPath: undefined,
|
reportPath: undefined,
|
||||||
|
verifyCleanupFailure: true,
|
||||||
};
|
};
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
const argument = argv[index];
|
const argument = argv[index];
|
||||||
@@ -107,6 +108,27 @@ function assertReleasedPressure(pressure) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertManagedTurns(extension) {
|
||||||
|
const expectedMilestones = 'worker.queue_wait,resources.ready,worker.spawn,rpc.ready,session.open,prompt.accepted,agent.start,provider.first_event,agent.settled';
|
||||||
|
const expectedDelayMs = extension?.providerFirstEventDelayMs;
|
||||||
|
if (!Number.isFinite(expectedDelayMs) || expectedDelayMs < 1) {
|
||||||
|
throw new Error(`PI proof did not expose a controlled Provider first-event delay: ${expectedDelayMs}`);
|
||||||
|
}
|
||||||
|
for (const turn of extension?.managedTurns ?? []) {
|
||||||
|
if (turn.milestones.map(({ milestone }) => milestone).join(',') !== expectedMilestones) {
|
||||||
|
throw new Error(`PI managed turn timeline is incomplete: ${JSON.stringify(turn)}`);
|
||||||
|
}
|
||||||
|
const agentStart = turn.milestones.find(({ milestone }) => milestone === 'agent.start');
|
||||||
|
const providerFirstEvent = turn.milestones.find(({ milestone }) => milestone === 'provider.first_event');
|
||||||
|
if (agentStart?.source !== 'pi.agent_start'
|
||||||
|
|| providerFirstEvent?.source !== 'pi.assistant_message_start'
|
||||||
|
|| providerFirstEvent.at <= agentStart.at
|
||||||
|
|| providerFirstEvent.durationMs < Math.floor(expectedDelayMs * 0.75)) {
|
||||||
|
throw new Error(`PI Provider first-event milestone is not Provider-response-backed: ${JSON.stringify(turn)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function evaluateProof(electronApplication, action) {
|
async function evaluateProof(electronApplication, action) {
|
||||||
return await electronApplication.evaluate(async (_electron, requestedAction) => {
|
return await electronApplication.evaluate(async (_electron, requestedAction) => {
|
||||||
const proof = globalThis.__niancodeRunPiReleaseProofE2E;
|
const proof = globalThis.__niancodeRunPiReleaseProofE2E;
|
||||||
@@ -158,17 +180,14 @@ export async function runPackagedProductProof(options) {
|
|||||||
|
|
||||||
const extension = await evaluateProof(electronApplication, 'extension');
|
const extension = await evaluateProof(electronApplication, 'extension');
|
||||||
assertPackagedMain(extension);
|
assertPackagedMain(extension);
|
||||||
|
assertManagedTurns(extension.extension);
|
||||||
if (extension.extension?.subagentStatus !== 'complete'
|
if (extension.extension?.subagentStatus !== 'complete'
|
||||||
|| extension.extension?.childToolNames?.join(',') !== 'find,grep,ls,read'
|
|| extension.extension?.childToolNames?.join(',') !== 'find,grep,ls,read'
|
||||||
|| !extension.extension?.parentToolNames?.includes('subagent')
|
|| !extension.extension?.parentToolNames?.includes('subagent')
|
||||||
|| extension.extension?.parentProcessIds?.length < 2
|
|| extension.extension?.parentProcessIds?.length < 2
|
||||||
|| extension.extension?.childProcessIds?.length !== 1
|
|| extension.extension?.childProcessIds?.length !== 1
|
||||||
|| extension.extension?.providerRequests?.child !== 1
|
|| extension.extension?.providerRequests?.child !== 1
|
||||||
|| extension.extension?.managedTurns?.length !== 2
|
|| extension.extension?.managedTurns?.length !== 2) {
|
||||||
|| extension.extension.managedTurns.some(({ milestones }) => (
|
|
||||||
milestones.map(({ milestone }) => milestone).join(',')
|
|
||||||
!== 'worker.queue_wait,resources.ready,worker.spawn,rpc.ready,session.open,prompt.accepted,agent.start,provider.first_event,agent.settled'
|
|
||||||
))) {
|
|
||||||
throw new Error(`Final ASAR extension/subagent proof failed: ${JSON.stringify(extension.extension)}`);
|
throw new Error(`Final ASAR extension/subagent proof failed: ${JSON.stringify(extension.extension)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +206,34 @@ export async function runPackagedProductProof(options) {
|
|||||||
assertPackagedMain(pressureFinish);
|
assertPackagedMain(pressureFinish);
|
||||||
assertReleasedPressure(pressureFinish.pressure);
|
assertReleasedPressure(pressureFinish.pressure);
|
||||||
|
|
||||||
|
let failureCleanup;
|
||||||
|
if (options.verifyCleanupFailure === true) {
|
||||||
|
const failureStart = await evaluateProof(electronApplication, 'pressure.start');
|
||||||
|
pressureActive = true;
|
||||||
|
assertPackagedMain(failureStart);
|
||||||
|
assertActivePressure(failureStart.pressure);
|
||||||
|
let injectedFailure;
|
||||||
|
try {
|
||||||
|
await evaluateProof(electronApplication, 'pressure.finish.inject-failure');
|
||||||
|
} catch (error) {
|
||||||
|
injectedFailure = error;
|
||||||
|
}
|
||||||
|
if (!injectedFailure
|
||||||
|
|| !String(injectedFailure).includes('PI release pressure cleanup failed: parents.settle')) {
|
||||||
|
throw new Error(`PI pressure cleanup failure injection did not fail as expected: ${String(injectedFailure)}`);
|
||||||
|
}
|
||||||
|
const retryFinish = await evaluateProof(electronApplication, 'pressure.finish');
|
||||||
|
pressureActive = false;
|
||||||
|
assertPackagedMain(retryFinish);
|
||||||
|
assertReleasedPressure(retryFinish.pressure);
|
||||||
|
failureCleanup = {
|
||||||
|
injectedAt: 'parents.settle',
|
||||||
|
firstFinish: 'failed-as-injected',
|
||||||
|
retry: 'pass',
|
||||||
|
released: retryFinish.pressure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const report = {
|
const report = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
@@ -203,6 +250,7 @@ export async function runPackagedProductProof(options) {
|
|||||||
durationMs: uiInteractiveMs,
|
durationMs: uiInteractiveMs,
|
||||||
},
|
},
|
||||||
released: pressureFinish.pressure,
|
released: pressureFinish.pressure,
|
||||||
|
...(failureCleanup ? { failureCleanup } : {}),
|
||||||
},
|
},
|
||||||
result: 'pass',
|
result: 'pass',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ async function runExtensionSmoke(projectRoot, artifact) {
|
|||||||
runtimeRoot: artifact.artifact.runtimeRoot,
|
runtimeRoot: artifact.artifact.runtimeRoot,
|
||||||
electronExecutable: artifact.artifact.executable,
|
electronExecutable: artifact.artifact.executable,
|
||||||
reportPath: undefined,
|
reportPath: undefined,
|
||||||
|
verifyCleanupFailure: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
66
tests/unit/pi-release-proof-cleanup.test.ts
Normal file
66
tests/unit/pi-release-proof-cleanup.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { runPiReleasePressureCleanup } from '../../electron/coding-runtime/pi/release-proof-cleanup';
|
||||||
|
|
||||||
|
describe('PI release pressure cleanup', () => {
|
||||||
|
it('continues after an injected failure, reaches zero, and can be retried', async () => {
|
||||||
|
const counts = {
|
||||||
|
parentWorkers: 4,
|
||||||
|
childWorkers: 4,
|
||||||
|
liveProcessIds: 8,
|
||||||
|
providerRequests: 8,
|
||||||
|
processBudget: 8,
|
||||||
|
childPermits: 4,
|
||||||
|
dispatches: 4,
|
||||||
|
writeLeases: 4,
|
||||||
|
};
|
||||||
|
let failParentSettle = true;
|
||||||
|
const steps = [
|
||||||
|
{ name: 'provider.release', run: () => { counts.providerRequests = 0; } },
|
||||||
|
{
|
||||||
|
name: 'dispatches.settle',
|
||||||
|
run: () => {
|
||||||
|
counts.childWorkers = 0;
|
||||||
|
counts.childPermits = 0;
|
||||||
|
counts.dispatches = 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parents.settle',
|
||||||
|
run: () => {
|
||||||
|
if (failParentSettle) {
|
||||||
|
failParentSettle = false;
|
||||||
|
throw new Error('injected parent settle failure');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pool.shutdown',
|
||||||
|
run: () => {
|
||||||
|
counts.parentWorkers = 0;
|
||||||
|
counts.liveProcessIds = 0;
|
||||||
|
counts.processBudget = 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'leases.release', run: () => { counts.writeLeases = 0; } },
|
||||||
|
];
|
||||||
|
|
||||||
|
await expect(runPiReleasePressureCleanup(steps))
|
||||||
|
.rejects.toThrow('PI release pressure cleanup failed: parents.settle');
|
||||||
|
expect(Object.values(counts)).toEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
|
||||||
|
await expect(runPiReleasePressureCleanup(steps)).resolves.toBeUndefined();
|
||||||
|
expect(Object.values(counts)).toEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds a hanging step and still runs later cleanup', async () => {
|
||||||
|
let released = false;
|
||||||
|
await expect(runPiReleasePressureCleanup([
|
||||||
|
{ name: 'hang', run: async () => await new Promise(() => undefined) },
|
||||||
|
{ name: 'release', run: () => { released = true; } },
|
||||||
|
], { stepTimeoutMs: 10 })).rejects.toThrow(
|
||||||
|
'PI release pressure cleanup failed: hang',
|
||||||
|
);
|
||||||
|
expect(released).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user