// @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( command: PiRpcCommand, _options?: PiRpcRequestOptions, ): Promise> { 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 { 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(); 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(); }); });