fix(pi): keep active runs alive during background sleep
This commit is contained in:
@@ -901,6 +901,7 @@ describe('auth host api routes', () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
expect(stop).toHaveBeenCalledWith('auth_cleanup');
|
||||
expect(closeEventSessions).toHaveBeenCalledOnce();
|
||||
expect(providerServiceMock.deleteAccountApiKey).toHaveBeenCalledWith('niancode-user-models');
|
||||
});
|
||||
@@ -927,6 +928,7 @@ describe('auth host api routes', () => {
|
||||
success: false,
|
||||
error: 'Failed to clear local AI runtime state',
|
||||
});
|
||||
expect(stop).toHaveBeenCalledWith('auth_cleanup');
|
||||
expect(providerServiceMock.deleteAccountApiKey).toHaveBeenCalledWith('niancode-user-models');
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,36 @@ describe('BackgroundLifecycleController', () => {
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it('does not stop the runtime when a lease is acquired while sleep preparation is in flight', async () => {
|
||||
vi.useFakeTimers();
|
||||
let finishSleepPreparation!: () => void;
|
||||
const sleepPreparation = new Promise<void>((resolve) => {
|
||||
finishSleepPreparation = resolve;
|
||||
});
|
||||
const onSleep = vi.fn(() => sleepPreparation);
|
||||
const onStopRuntime = vi.fn().mockResolvedValue(undefined);
|
||||
const controller = new BackgroundLifecycleController({
|
||||
idleStopMs: 60_000,
|
||||
onSleep,
|
||||
onStopRuntime,
|
||||
});
|
||||
|
||||
controller.setActivity({ visible: false, module: 'learning' });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(onSleep).toHaveBeenCalledTimes(1);
|
||||
|
||||
const release = controller.acquireLease({ id: 'coding-run:1', kind: 'coding-run' });
|
||||
finishSleepPreparation();
|
||||
await vi.runAllTicks();
|
||||
|
||||
expect(onStopRuntime).not.toHaveBeenCalled();
|
||||
|
||||
release();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(onStopRuntime).toHaveBeenCalledTimes(1);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it('cancels the idle stop when programming becomes visible again', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSleep = vi.fn();
|
||||
|
||||
@@ -270,9 +270,11 @@ describe('PI-100 coding core Host contract', () => {
|
||||
await archivePiConversationSession({ userDataDir, ...input });
|
||||
},
|
||||
});
|
||||
const dispose = vi.spyOn(result.runtime, 'dispose');
|
||||
|
||||
await conversations.deleteConversation(conversation.id);
|
||||
|
||||
expect(dispose).toHaveBeenCalledWith(conversation.id, 'conversation_deleted');
|
||||
await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.toBeNull();
|
||||
await expect(readFile(path.join(
|
||||
userDataDir,
|
||||
@@ -652,16 +654,24 @@ describe('PI-100 coding core Host contract', () => {
|
||||
createId: () => `project-${++nextId}`,
|
||||
});
|
||||
const first = await createLocalCodingProject({ projectPath: projectRoots[0]! }, store);
|
||||
const deactivated: string[] = [];
|
||||
const deactivated: Array<{ projectId: string; reason: string }> = [];
|
||||
const projects = new CodingProjectService(store, {
|
||||
onProjectDeactivated: async (project) => { deactivated.push(project.id); },
|
||||
onProjectDeactivated: async (project, reason) => {
|
||||
deactivated.push({ projectId: project.id, reason });
|
||||
},
|
||||
});
|
||||
|
||||
const opened = await projects.openProject(projectRoots[1]!);
|
||||
const created = await projects.createProject({ projectPath: projectRoots[2]! });
|
||||
await projects.setActiveProject(first.project.id);
|
||||
await projects.removeProject(first.project.id);
|
||||
|
||||
expect(deactivated).toEqual([first.project.id, opened.id, created.project.id]);
|
||||
expect(deactivated).toEqual([
|
||||
{ projectId: first.project.id, reason: 'project_deactivated' },
|
||||
{ projectId: opened.id, reason: 'project_deactivated' },
|
||||
{ projectId: created.project.id, reason: 'project_deactivated' },
|
||||
{ projectId: first.project.id, reason: 'project_removed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes absolute project roots from every core project response', async () => {
|
||||
@@ -852,7 +862,7 @@ describe('PI-100 coding core Host contract', () => {
|
||||
await expect(conversations.fork(source.id, 'entry-user-fork-cleanup')).rejects.toMatchObject({
|
||||
code: 'CODING_SESSION_UNREADABLE',
|
||||
});
|
||||
expect(dispose).toHaveBeenCalledWith(forkTargetId);
|
||||
expect(dispose).toHaveBeenCalledWith(forkTargetId, 'fork_replacement');
|
||||
expect(archiveSession).toHaveBeenCalledWith({
|
||||
projectId: 'project-a',
|
||||
sessionKey: 'fork-session-key',
|
||||
|
||||
261
tests/unit/pi-background-lifecycle.test.ts
Normal file
261
tests/unit/pi-background-lifecycle.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
|
||||
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
|
||||
import {
|
||||
createCodingProjectStore,
|
||||
createLocalCodingProject,
|
||||
createMemoryCodingProjectStorage,
|
||||
} from '../../electron/coding-projects/project-store';
|
||||
import { PiConversationRuntime } from '../../electron/coding-runtime/pi/runtime';
|
||||
import { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry';
|
||||
import {
|
||||
PiWorkerPool,
|
||||
type PiConversationWorker,
|
||||
} from '../../electron/coding-runtime/pi/worker-pool';
|
||||
import type {
|
||||
PiRpcCommand,
|
||||
PiRpcEvent,
|
||||
PiRpcRequestOptions,
|
||||
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 { BackgroundLifecycleController } from '../../electron/main/background-lifecycle';
|
||||
|
||||
const roots: string[] = [];
|
||||
const NOW = '2026-08-25T12:00:00.000Z';
|
||||
|
||||
class LifecycleFakeWorker implements PiConversationWorker {
|
||||
readonly requests: PiRpcCommand[] = [];
|
||||
readonly stopReasons: PiWorkerStopReason[] = [];
|
||||
private readonly events = new Set<(event: PiRpcEvent) => void>();
|
||||
private readonly invalidations = new Set<(error: PiProcessError) => void>();
|
||||
|
||||
constructor(readonly id: string, readonly generation: number) {}
|
||||
|
||||
async request<T = unknown>(
|
||||
command: PiRpcCommand,
|
||||
_options?: PiRpcRequestOptions,
|
||||
): Promise<PiRpcResponse<T>> {
|
||||
this.requests.push(structuredClone(command));
|
||||
const data = command.type === 'get_state'
|
||||
? {
|
||||
sessionId: `session-${this.id}`,
|
||||
thinkingLevel: 'medium',
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
pendingMessageCount: 0,
|
||||
}
|
||||
: command.type === 'get_entries'
|
||||
? { entries: [], leafId: null }
|
||||
: command.type === 'get_session_stats'
|
||||
? {
|
||||
contextUsage: { tokens: 0, contextWindow: 100_000, percent: 0 },
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
}
|
||||
: command.type === 'get_commands'
|
||||
? { commands: [] }
|
||||
: command.type === 'get_available_thinking_levels'
|
||||
? { levels: ['off', 'medium', 'high'] }
|
||||
: undefined;
|
||||
return {
|
||||
type: 'response',
|
||||
id: `${this.id}-${this.requests.length}`,
|
||||
success: true,
|
||||
...(data === undefined ? {} : { data: structuredClone(data) as T }),
|
||||
};
|
||||
}
|
||||
|
||||
async send(command: PiRpcCommand): Promise<void> {
|
||||
this.requests.push(structuredClone(command));
|
||||
}
|
||||
|
||||
subscribe(listener: (event: PiRpcEvent) => void): () => void {
|
||||
this.events.add(listener);
|
||||
return () => this.events.delete(listener);
|
||||
}
|
||||
|
||||
subscribeInvalidation(listener: (error: PiProcessError) => void): () => void {
|
||||
this.invalidations.add(listener);
|
||||
return () => this.invalidations.delete(listener);
|
||||
}
|
||||
|
||||
emit(event: PiRpcEvent): void {
|
||||
for (const listener of this.events) listener(event);
|
||||
}
|
||||
|
||||
async stop(reason: PiWorkerStopReason) {
|
||||
this.stopReasons.push(reason);
|
||||
return { mode: 'stdin-close' as const, code: 0, signal: null };
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('Pi run background lifecycle lease', () => {
|
||||
it('keeps accepted running 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);
|
||||
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-a',
|
||||
now: () => NOW,
|
||||
});
|
||||
await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
|
||||
await createCodingProjectAgent(projectPath, {
|
||||
id: 'agent-a',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: 'Implementer',
|
||||
name: 'Agent A',
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
responsibility: {
|
||||
mission: 'Implement',
|
||||
owns: [],
|
||||
boundaries: [],
|
||||
collaborators: [],
|
||||
principles: [],
|
||||
},
|
||||
}, { now: NOW });
|
||||
const store = createCodingConversationStore(projectPath, {
|
||||
createId: (() => {
|
||||
const ids = [
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
'8b1a9953-c461-4d88-9c3e-7e1f8f3f2c11',
|
||||
];
|
||||
return () => ids.shift() as string;
|
||||
})(),
|
||||
now: () => NOW,
|
||||
});
|
||||
const runningConversation = await store.create({
|
||||
agentId: 'agent-a',
|
||||
title: 'Running lease proof',
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
const queuedConversation = await store.create({
|
||||
agentId: 'agent-a',
|
||||
title: 'Queued lease proof',
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
const inputs = [runningConversation, queuedConversation].map((conversation) => ({
|
||||
conversationId: conversation.id,
|
||||
projectId: 'project-a',
|
||||
agentId: 'agent-a',
|
||||
title: conversation.title,
|
||||
model: { model: conversation.model, modelResolution: conversation.modelResolution },
|
||||
}));
|
||||
const workers: LifecycleFakeWorker[] = [];
|
||||
const workersByConversation = new Map<string, LifecycleFakeWorker>();
|
||||
const pool = new PiWorkerPool({
|
||||
maxRunning: 1,
|
||||
openWorker: async ({ conversation: workerConversation, generation, existingSession }) => {
|
||||
const worker = new LifecycleFakeWorker(
|
||||
`worker-${workerConversation.conversationId}-${generation}`,
|
||||
generation,
|
||||
);
|
||||
workers.push(worker);
|
||||
workersByConversation.set(workerConversation.conversationId, worker);
|
||||
return {
|
||||
worker,
|
||||
session: existingSession ?? {
|
||||
piSessionId: `session-${workerConversation.conversationId}`,
|
||||
sessionKey: `key-${workerConversation.conversationId}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
let runtime!: PiConversationRuntime;
|
||||
const onSleep = vi.fn().mockResolvedValue(undefined);
|
||||
const onStopRuntime = vi.fn(async () => {
|
||||
await Promise.all(runtime.getDiagnostics().workers.map(async ({ conversationId }) => {
|
||||
await runtime.dispose(conversationId, 'background_sleep');
|
||||
}));
|
||||
});
|
||||
const controller = new BackgroundLifecycleController({
|
||||
idleStopMs: 100,
|
||||
onSleep,
|
||||
onStopRuntime,
|
||||
});
|
||||
const acquiredLeaseIds: string[] = [];
|
||||
const releasedLeaseIds: string[] = [];
|
||||
runtime = new PiConversationRuntime({
|
||||
pool,
|
||||
registry: new PiSessionRegistry({ projectStore }),
|
||||
createId: (kind) => `${kind}-lease-proof`,
|
||||
acquireBackgroundLease: (lease) => {
|
||||
acquiredLeaseIds.push(lease.id);
|
||||
const release = controller.acquireLease(lease);
|
||||
return () => {
|
||||
releasedLeaseIds.push(lease.id);
|
||||
release();
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(inputs.map(async (input) => await runtime.prepare(input)));
|
||||
const accepted = await runtime.prompt({
|
||||
clientRequestId: 'request-1',
|
||||
conversationId: runningConversation.id,
|
||||
mode: 'prompt',
|
||||
text: 'Keep working while hidden',
|
||||
attachments: [],
|
||||
});
|
||||
const queued = await runtime.prompt({
|
||||
clientRequestId: 'request-2',
|
||||
conversationId: queuedConversation.id,
|
||||
mode: 'prompt',
|
||||
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({
|
||||
conversationId: runningConversation.id,
|
||||
kind: 'child',
|
||||
id: 'child-proof',
|
||||
cancel: () => undefined,
|
||||
});
|
||||
releaseChild();
|
||||
expect(controller.getLeaseCount()).toBe(2);
|
||||
|
||||
controller.setActivity({ visible: false, module: 'programming' });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(onStopRuntime).not.toHaveBeenCalled();
|
||||
expect(workers.every(({ stopReasons }) => stopReasons.length === 0)).toBe(true);
|
||||
expect((await runtime.getSnapshot(runningConversation.id)).run.status).toBe('running');
|
||||
expect((await runtime.getSnapshot(queuedConversation.id)).run.status).toBe('queued');
|
||||
|
||||
workersByConversation.get(runningConversation.id)!.emit({ type: 'agent_settled' });
|
||||
expect((await runtime.getSnapshot(runningConversation.id)).run.status).toBe('idle');
|
||||
await expect.poll(async () => (
|
||||
await runtime.getSnapshot(queuedConversation.id)
|
||||
).run.status).toBe('running');
|
||||
expect(controller.getLeaseCount()).toBe(1);
|
||||
|
||||
workersByConversation.get(queuedConversation.id)!.emit({ type: 'agent_settled' });
|
||||
expect((await runtime.getSnapshot(queuedConversation.id)).run.status).toBe('idle');
|
||||
expect(controller.getLeaseCount()).toBe(0);
|
||||
expect(releasedLeaseIds.sort()).toEqual(acquiredLeaseIds.sort());
|
||||
expect(new Set(releasedLeaseIds).size).toBe(releasedLeaseIds.length);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(onSleep).toHaveBeenCalledTimes(1);
|
||||
expect(onStopRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(workers.map(({ stopReasons }) => stopReasons)).toEqual([
|
||||
['background_sleep'],
|
||||
['background_sleep'],
|
||||
]);
|
||||
controller.dispose();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
PiWorkerPool,
|
||||
type PiConversationWorker,
|
||||
} from '../../electron/coding-runtime/pi/worker-pool';
|
||||
import type { PiWorkerStopReason } from '../../electron/coding-runtime/pi/worker-process';
|
||||
import { PiProcessError } from '../../electron/coding-runtime/pi/process-errors';
|
||||
import type {
|
||||
PiRpcCommand,
|
||||
@@ -33,6 +34,7 @@ const NOW = '2026-08-22T15:00:00.000Z';
|
||||
|
||||
class RuntimeFakeWorker implements PiConversationWorker {
|
||||
readonly requests: PiRpcCommand[] = [];
|
||||
readonly stopReasons: PiWorkerStopReason[] = [];
|
||||
private stateData: unknown;
|
||||
private entriesData: unknown = { entries: [], leafId: null };
|
||||
private statsData: unknown = {
|
||||
@@ -143,7 +145,8 @@ class RuntimeFakeWorker implements PiConversationWorker {
|
||||
for (const listener of this.invalidations) listener(error);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
async stop(reason: PiWorkerStopReason) {
|
||||
this.stopReasons.push(reason);
|
||||
return { mode: 'stdin-close' as const, code: 0, signal: null };
|
||||
}
|
||||
}
|
||||
@@ -236,11 +239,25 @@ describe('Pi Conversation runtime', () => {
|
||||
},
|
||||
});
|
||||
const trackingHost = new TrackingExtensionHost();
|
||||
let acquiredBackgroundLeases = 0;
|
||||
let releasedBackgroundLeases = 0;
|
||||
let activeBackgroundLeases = 0;
|
||||
const runtime = new PiConversationRuntime({
|
||||
pool,
|
||||
registry: new PiSessionRegistry({ projectStore }),
|
||||
createId: (kind) => `${kind}-fixed`,
|
||||
extensionHost: trackingHost,
|
||||
acquireBackgroundLease: () => {
|
||||
acquiredBackgroundLeases += 1;
|
||||
activeBackgroundLeases += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) throw new Error('background lease released twice');
|
||||
released = true;
|
||||
releasedBackgroundLeases += 1;
|
||||
activeBackgroundLeases -= 1;
|
||||
};
|
||||
},
|
||||
resolveModel: async (candidate) => {
|
||||
if (candidate.accountId !== 'account-b'
|
||||
|| !['model-b', 'model-c'].includes(candidate.modelId)) {
|
||||
@@ -297,6 +314,7 @@ describe('Pi Conversation runtime', () => {
|
||||
releasePromptAcceptance();
|
||||
const accepted = await acceptance;
|
||||
expect(accepted).toMatchObject({ accepted: true, runId: 'run-fixed', mode: 'prompt' });
|
||||
expect(activeBackgroundLeases).toBe(1);
|
||||
expect((await runtime.getSnapshot(left.id)).run.status).toBe('running');
|
||||
for (const event of PI_084_TEXT_TURN.events) {
|
||||
workers.get(left.id)!.emit(structuredClone(event));
|
||||
@@ -372,6 +390,7 @@ describe('Pi Conversation runtime', () => {
|
||||
workers.get(left.id)!.emit({ type: 'agent_settled' });
|
||||
await expect.poll(async () => (await runtime.getSnapshot(left.id)).queue.items).toEqual([]);
|
||||
expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle');
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
const settledNodes = (await runtime.getSnapshot(left.id)).nodes;
|
||||
expect(settledNodes).toEqual(checkpoint.nodes);
|
||||
expect(leftGenerationOneSeqs).toEqual(leftGenerationOneSeqs.map((_, index) => index + 1));
|
||||
@@ -483,13 +502,16 @@ describe('Pi Conversation runtime', () => {
|
||||
]);
|
||||
workers.get(left.id)!.emit({ type: 'agent_settled' });
|
||||
await expect.poll(async () => (await runtime.getSnapshot(left.id)).run.status).toBe('idle');
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
expect((await runtime.getSnapshot(left.id)).queue.items).toEqual([]);
|
||||
|
||||
workers.get(right.id)!.failNext('compact');
|
||||
await expect(runtime.compact(right.id)).rejects.toThrow('fake compact rejection');
|
||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('error');
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
await runtime.compact(right.id);
|
||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting');
|
||||
expect(activeBackgroundLeases).toBe(1);
|
||||
expect(workers.get(right.id)!.requests.at(-1)).toEqual({ type: 'compact' });
|
||||
expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle');
|
||||
const rightDurable = {
|
||||
@@ -510,6 +532,7 @@ describe('Pi Conversation runtime', () => {
|
||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting');
|
||||
workers.get(right.id)!.emit({ type: 'agent_settled' });
|
||||
await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle');
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
|
||||
const leftBeforeFailedRecovery = await runtime.getSnapshot(left.id);
|
||||
const rightBeforeFailedRecovery = await runtime.getSnapshot(right.id);
|
||||
@@ -568,7 +591,7 @@ describe('Pi Conversation runtime', () => {
|
||||
sourceEntryId: 'entry-a',
|
||||
});
|
||||
|
||||
await runtime.dispose(forkTarget.id);
|
||||
await runtime.dispose(forkTarget.id, 'test_injection');
|
||||
expect(pool.getState(forkTarget.id)).toBeNull();
|
||||
expect(pool.getState(left.id)).toMatchObject({ generation: 2, state: 'idle' });
|
||||
await expect(runtime.getSnapshot(forkTarget.id)).rejects.toMatchObject({
|
||||
@@ -585,6 +608,7 @@ describe('Pi Conversation runtime', () => {
|
||||
await runtime.recover(left.id);
|
||||
expect(trackingHost.runs.has(left.id)).toBe(false);
|
||||
expect((await runtime.getSnapshot(left.id)).run).toEqual({ status: 'idle' });
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
|
||||
workers.get(left.id)!.setSessionData({
|
||||
thinkingLevels: { levels: ['high'] },
|
||||
@@ -620,6 +644,7 @@ describe('Pi Conversation runtime', () => {
|
||||
crashPatchOps.length = 0;
|
||||
workers.get(left.id)!.invalidate();
|
||||
await expect.poll(async () => (await runtime.getSnapshot(left.id)).run.status).toBe('error');
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
expect((await runtime.getSnapshot(left.id))).toMatchObject({
|
||||
run: {
|
||||
status: 'error',
|
||||
@@ -669,6 +694,7 @@ describe('Pi Conversation runtime', () => {
|
||||
'strict JSONL protocol failure',
|
||||
));
|
||||
await expect.poll(async () => (await runtime.getSnapshot(left.id)).run.status).toBe('error');
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
expect((await runtime.getSnapshot(left.id)).run).toMatchObject({
|
||||
terminalReason: 'failed',
|
||||
error: { code: 'CODING_RUNTIME_PROTOCOL_ERROR', recoverable: true },
|
||||
@@ -676,6 +702,47 @@ describe('Pi Conversation runtime', () => {
|
||||
workers.get(left.id)!.emit({ type: 'agent_settled' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect((await runtime.getSnapshot(left.id)).run.status).toBe('error');
|
||||
|
||||
await runtime.recover(left.id);
|
||||
const siblingBeforeForcedDispose = await runtime.getSnapshot(right.id);
|
||||
const bindingBeforeForcedDispose = await store.get(left.id);
|
||||
const forcedDisposePatches: string[] = [];
|
||||
const unsubscribeForcedDispose = runtime.subscribe((envelope) => {
|
||||
if (envelope.conversationId === left.id && envelope.patch.op === 'run.state') {
|
||||
forcedDisposePatches.push(JSON.stringify(envelope.patch));
|
||||
}
|
||||
});
|
||||
const forcedWorker = workers.get(left.id)!;
|
||||
await runtime.prompt({
|
||||
clientRequestId: 'request-project-deactivated',
|
||||
conversationId: left.id,
|
||||
mode: 'prompt',
|
||||
text: 'Do not replay this accepted prompt',
|
||||
attachments: [],
|
||||
});
|
||||
const forcedPromptCount = forcedWorker.requests.filter(({ type }) => type === 'prompt').length;
|
||||
await runtime.dispose(left.id, 'project_deactivated');
|
||||
unsubscribeForcedDispose();
|
||||
expect(forcedDisposePatches.at(-1)).toContain('CODING_RUNTIME_START_FAILED');
|
||||
expect(forcedDisposePatches.at(-1)).toContain('"recoverable":true');
|
||||
expect(forcedWorker.stopReasons.at(-1)).toBe('project_deactivated');
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
expect(await runtime.getSnapshot(right.id)).toEqual(siblingBeforeForcedDispose);
|
||||
expect((await store.get(left.id))?.sessionKey).toBe(bindingBeforeForcedDispose?.sessionKey);
|
||||
|
||||
const persistedAfterForcedDispose = (await store.get(left.id))!;
|
||||
await runtime.prepare({
|
||||
...inputs[0]!,
|
||||
model: {
|
||||
model: persistedAfterForcedDispose.model,
|
||||
modelResolution: persistedAfterForcedDispose.modelResolution,
|
||||
},
|
||||
});
|
||||
expect(forcedWorker.requests.filter(({ type }) => type === 'prompt')).toHaveLength(
|
||||
forcedPromptCount,
|
||||
);
|
||||
expect(acquiredBackgroundLeases).toBe(releasedBackgroundLeases);
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,4 +25,19 @@ describe('Pi packaged release proof wiring', () => {
|
||||
expect(source.slice(proxyStart, resilienceStart))
|
||||
.not.toContain("startLocalProofProvider('subagent')");
|
||||
});
|
||||
|
||||
it('wires the packaged resilience proof through Main background and intentional-dispose seams', async () => {
|
||||
const [mainSource, proofSource, scriptSource] = await Promise.all([
|
||||
readFile(resolve('electron/main/index.ts'), 'utf8'),
|
||||
readFile(resolve('electron/coding-runtime/pi/release-proof.ts'), 'utf8'),
|
||||
readFile(resolve('scripts/run-pi-subagent-packaged-smoke.mjs'), 'utf8'),
|
||||
]);
|
||||
|
||||
expect(mainSource).toContain("codingProducts?.sleep('background_sleep')");
|
||||
expect(mainSource).toContain('acquireBackgroundLease: (lease) => backgroundLifecycle!');
|
||||
expect(proofSource).toContain("runtime.dispose(run.targetConversationId, 'test_injection')");
|
||||
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.idle-status')");
|
||||
expect(scriptSource).toContain('setMainWindowVisible(electronApplication, false)');
|
||||
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.dispose-target')");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,7 +261,7 @@ describe('Pi worker pool', () => {
|
||||
await expect.poll(() => processBudget.waitingCount).toBe(1);
|
||||
expect(opened).toEqual(['conversation-a', 'conversation-b']);
|
||||
|
||||
await pool.dispose('conversation-a');
|
||||
await pool.dispose('conversation-a', 'test_injection');
|
||||
await expect(third).resolves.toMatchObject({ conversationId: 'conversation-c', state: 'ready' });
|
||||
expect(opened).toEqual(['conversation-a', 'conversation-b', 'conversation-c']);
|
||||
expect(processBudget.activeCount).toBe(2);
|
||||
@@ -292,7 +292,7 @@ describe('Pi worker pool', () => {
|
||||
});
|
||||
await pool.prepare(conversation('conversation-stop-failure'));
|
||||
|
||||
await expect(pool.dispose('conversation-stop-failure')).rejects.toThrow('stop failed');
|
||||
await expect(pool.dispose('conversation-stop-failure', 'test_injection')).rejects.toThrow('stop failed');
|
||||
expect(processBudget.activeCount).toBe(0);
|
||||
expect(processBudget.waitingCount).toBe(0);
|
||||
});
|
||||
@@ -804,7 +804,7 @@ describe('Pi worker pool', () => {
|
||||
expect(workers.get('conversation-a')![0]!.stopped).toBe(true);
|
||||
expect(workers.get('conversation-b')![0]!.stopped).toBe(false);
|
||||
|
||||
await pool.dispose('conversation-a');
|
||||
await pool.dispose('conversation-a', 'test_injection');
|
||||
expect(workers.get('conversation-a')![1]!.stopped).toBe(true);
|
||||
expect(pool.getState('conversation-a')).toBeNull();
|
||||
expect(pool.getState('conversation-b')).toMatchObject({ state: 'ready', generation: 1 });
|
||||
|
||||
Reference in New Issue
Block a user