From 621ebb17810394f6f7b97154cb01217bc9112857 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 25 Aug 2026 20:56:17 +0800 Subject: [PATCH] fix(pi): keep active runs alive during background sleep --- ...260825-pi-background-run-lease-6a4e2c91.md | 184 ++++++++++++ electron/api/coding-composition.ts | 15 +- electron/api/coding-product-services.ts | 2 +- electron/coding-projects/project-service.ts | 11 +- .../coding-runtime/conversation-service.ts | 6 +- .../in-memory-conversation-runtime.ts | 3 +- electron/coding-runtime/pi/release-proof.ts | 104 ++++++- electron/coding-runtime/pi/runtime.ts | 200 +++++++++++--- electron/coding-runtime/pi/worker-pool.ts | 12 +- electron/coding-runtime/pi/worker-process.ts | 12 +- electron/main/background-lifecycle.ts | 1 + electron/main/index.ts | 19 +- electron/services/works-square-runtime.ts | 4 +- scripts/run-pi-subagent-packaged-smoke.mjs | 141 +++++++++- shared/coding-conversation-contracts.ts | 12 +- tests/unit/auth-routes.test.ts | 2 + tests/unit/background-lifecycle.test.ts | 30 ++ tests/unit/coding-core-routes.test.ts | 18 +- tests/unit/pi-background-lifecycle.test.ts | 261 ++++++++++++++++++ tests/unit/pi-conversation-runtime.test.ts | 71 ++++- tests/unit/pi-release-proof-wiring.test.ts | 15 + tests/unit/pi-worker-pool.test.ts | 6 +- 22 files changed, 1061 insertions(+), 68 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260825-pi-background-run-lease-6a4e2c91.md create mode 100644 tests/unit/pi-background-lifecycle.test.ts diff --git a/.project-docs/30-worklog/tasks/20260825-pi-background-run-lease-6a4e2c91.md b/.project-docs/30-worklog/tasks/20260825-pi-background-run-lease-6a4e2c91.md new file mode 100644 index 0000000..adf22e2 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260825-pi-background-run-lease-6a4e2c91.md @@ -0,0 +1,184 @@ +# Task: Prevent background sleep from terminating active Pi runs + +## Identity + +- Task ID: 20260825-pi-background-run-lease-6a4e2c91 +- Mode: Feature +- Branch: codex/20260825-pi-background-run-lease-6a4e2c91-pi-background-run-lease +- Worktree: D:\Datas\OthersProjects\makelore-pi-background-run-lease-6a4e2c91 +- Base commit: 92f4c91088e79252aca76af3279b184da68e1eb7 +- Owner: codex-root +- Status: Ready for integration + +## Scope + +- Add a Main-owned authoritative background lease for every queued or accepted + top-level Pi Conversation run, acquired before mutation queueing and held + through parent/child work until authoritative terminal cleanup. +- Close the `BackgroundLifecycleController.sleepIfIdle()` in-flight race so a + lease acquired while `onSleep` is awaiting prevents the later runtime stop, + while preserving hidden-idle warm-worker eviction after all runs settle. +- Terminalize supported intentional disposal of an active target Conversation + before runtime projection state is removed, without replaying an accepted or + uncertain mutation or affecting sibling Conversations. +- Replace ambiguous Main stop reason `dispose` at supported callers with a + bounded caller-specific reason, and retain content-free lifecycle evidence. +- Extend focused Main/runtime/Renderer tests, Windows Electron E2E, final-ASAR + packaged proof, and formal Windows NSIS/artifact verification. + +## Intent And Constraints + +- Preserve the Pi hard cutover and exact Pi `0.84.2`; do not add OpenCode + fallback, a Provider watchdog, automatic mutation replay, a global daemon, + schema migration, feature flag, compatibility track, or Pi upgrade. +- The authoritative lease is Main-owned. Renderer visibility or IPC lease + delivery must not own run liveness. Acquire synchronously before a top-level + mutation can be queued/accepted; a parent lease covers its child/subagent. +- Release the lease exactly once only after authoritative settle, definite + pre-accept rejection, terminal crash/protocol failure, completed cancel, + explicit dispose cleanup, or application shutdown cleanup. Generation or + resource replacement for the same logical run must not release it early. +- Preserve target isolation, the existing same-session recover contract, and + no-replay semantics. Intentional termination while the UI remains alive must + project a recoverable terminal state; it must not fabricate success. +- Keep stop reasons finite and content-free. Diagnostics may retain only safe + lifecycle classification, reason, redacted identifiers, generation, + code/signal, stage, and duration; no prompt/tool/header/token/session text or + complete project path may enter logs or Renderer DTOs. +- Do not edit the read-only diagnosis task or overwrite the user's installed + Makelore. Do not push or publish. Real Provider remains Explicitly Waived / + Accepted Risk (`realTurnVerified=false`); macOS/native Linux gates are out. + +## Project Context Loaded + +- Concurrent Task Gate passed with isolated ownership of this task, branch, + and worktree at base `92f4c91088e79252aca76af3279b184da68e1eb7`. +- Diagnosis commit `dc820b2` is a docs-only sibling based on `941b015`, not an + ancestor of the cumulative product HEAD; its task record was read-only and + is not used as the implementation base. +- Shared canonical memory remains OpenCode-stale relative to the cumulative Pi + product. Current source, the Pi resilience contract, cumulative Pi task + records, and the read-only output-stall diagnosis are authoritative here. +- Eleven non-ready peer records were inspected. One owns an old main/OpenCode + integration and the others are unrelated or placeholder scopes; no known + peer owns Pi background lifecycle, runtime disposal, or packaged proof + semantics. Placeholder scopes remain unknown coordination state but expose + no semantic conflict that changes this isolated plan. +- Relevant constraints are Main-owned run liveness, target-scoped monotonic + terminalization, same-binding recovery, bounded stop reasons, no mutation + replay, and final Windows packaged proof with zero residual resources. +- Planning Gate result: Passed. + +## Plan + +1. Trace actual background lifecycle, Pi mutation/dispose, composition, and + stop-reason callers; build a deterministic combined red test for the exact + hidden-active-run failure and the in-flight sleep race. +2. Add the narrow Main-owned lease dependency, acquire before top-level + queue/acceptance, and release exactly once across settle/reject/crash/ + protocol/abort/dispose/shutdown paths. +3. Reuse the existing Conversation projection chain to terminalize active + intentional disposal before state removal, and pass caller-specific bounded + reasons without changing settled runs or sibling Conversations. +4. Extend Renderer/Host/Windows E2E and final packaged proof for hidden active + completion, later idle stop, explicit-dispose unlock/recover/isolation, no + replay, safe diagnostics, and zero resource/process residue. +5. Run focused tests, typecheck, lint, full unit, build, Windows Electron E2E, + then commit a clean candidate and run formal `package:win`, Windows/Pi + closure verification, packaged proof, artifact fingerprinting, and the Task + Documentation Gate. + +## Outcome + +- Implemented a Main-owned per-Conversation top-level run lease in the Pi + runtime. Prompt and compact acquire before optimistic/queue acceptance; + steer/follow-up reuse the parent run lease. Settle, definite rejection, + crash, protocol invalidation, abort convergence, explicit recovery, + intentional disposal, and shutdown release through an idempotent owner map. +- Closed both background-stop races: the lifecycle controller rechecks + visibility and leases after asynchronous sleep preparation, and the Pi pool + rechecks the target's authoritative active-work predicate after pending + prepare/rebuild work and immediately before worker cancellation. +- Background sleep now refuses the entire coding-runtime sleep while any run + is queued or active, while settled hidden workers remain eligible for normal + eviction. A parent lease remains held while child/subagent resources finish. +- Active intentional disposal projects the existing safe recoverable terminal + failure on the target Conversation chain before cleanup/removal, preserves + the durable session binding, does not replay the accepted prompt, and leaves + sibling Conversations unchanged. +- Replaced the ambiguous `dispose` worker stop reason with caller-owned finite + reasons for background sleep, project deactivation/removal, Conversation + deletion, auth cleanup, model reconfiguration, fork cleanup, app shutdown, + and existing internal replacement/cleanup paths. +- Extended the final-ASAR resilience proof to use the real Main composition for + hidden active-run survival, settled idle eviction, intentional-dispose UI + unlock/recover/isolation, safe lifecycle reason evidence, background lease + counts, and zero residual resource/process verification. +- The clean implementation candidate completed Windows NSIS creation, both + artifact/closure verifiers, and the final packaged Main/UI/fault proof. The + task record is amended into the cumulative delivery commit before the formal + commands are rerun for the returned artifact. + +## Verification + +- `pnpm install --frozen-lockfile` — passed with package-manager-pinned pnpm + `10.33.4` and locked Pi `0.84.2`. +- Red phase: the new background lifecycle in-flight test observed one unwanted + `onStopRuntime` call; the real Pi-runtime/lifecycle combination observed zero + Main run leases after prompt acceptance. +- Focused regression gate — passed: 8 files / 106 tests, covering lifecycle, + running+queued Pi work, parent/child lease retention, exactly-once release, + crash/protocol/abort/recover, forced disposal, worker reason diagnostics, + project/auth callers, and packaged proof wiring. +- `pnpm run typecheck` — passed. +- `pnpm run lint:check` — passed with 5 pre-existing warnings and zero errors. +- `pnpm test` — passed: 182 files, 1529 tests passed, 2 skipped. +- `pnpm run build:vite` — passed for Renderer, Main, Preload, and utility + bundles; only existing dynamic-import/chunk-size warnings were emitted. +- `pnpm run test:electron:windows` — passed: 2 files / 4 tests. +- Clean-candidate `pnpm run package:win` — passed; generated the Windows x64 + NSIS without overwriting an installed Makelore directory. +- `pnpm run verify:artifact:win` — passed for NSIS/unpacked Electron, app.asar, + bundled Python/uv/npm, and Windows native modules; verifier HEAD matched the + clean implementation candidate. +- `pnpm run verify:artifact:pi` — passed: exact Pi `0.84.2`, 130 production + packages, 6 asset groups, zero missing/relocated package or asset, 5 native + assets outside ASAR, managed Skills, and materialized extension/subagent. +- `pnpm run test:pi-subagent:packaged` — passed from packaged app.asar Main: + two UI/Main runs retained 2 background leases and 2 worker processes while + hidden past grace; both settled, released leases, then warm idle workers + stopped with reason `background_sleep`. Controlled intentional disposal + projected `CODING_RUNTIME_START_FAILED` as recoverable, kept the sibling + running and both bindings intact, and did not change Provider request counts. + Recover reused the binding without mutation replay. Final workers, Pi + processes, process leases, run permits, child permits/dispatches, extension + registrations, write leases, and background leases all reached zero. +- Packaged proof clean exit — passed with zero lingering tracked Electron/Pi + PIDs. Lifecycle evidence distinguished unexpected exit, protocol + invalidation, and intentional stop; every stop/replacement had a reason and + redaction/content-free checks passed. +- Authenticode inspection — `NotSigned`; no signing identity was available. +- The returned installer is rebuilt and reverified from the amended cumulative + clean HEAD; its exact path, size, SHA-256, and signing state are reported in + the delivery response. + +## Follow-ups + +- PI-R002 soak/field attribution remains a separate follow-up and is not a + blocker for this deterministic failure-convergence hotfix. +- Real external Provider concurrency, credential isolation, and protocol + compatibility remain Explicitly Waived / Accepted Risk with + `realTurnVerified=false`; loopback/fault injection is not a Provider Pass. +- macOS and native non-WSL Linux gate status is unchanged and not promoted. + +## Promotion Candidates + +- Target canonical document: Pi runtime architecture/resilience documentation. + Proposal: record Main-owned top-level run leases, post-`onSleep` activity + recheck, terminal-before-dispose monotonicity, and finite caller-owned stop + reasons as accepted invariants. Evidence: focused lifecycle/runtime tests, + Windows Electron E2E, final-ASAR hidden-run/idle-eviction/intentional-dispose + proof, and zero-resource shutdown. Future impact: new Pi mutation or stop + callers must join these seams rather than relying on Renderer visibility. + Semantic conflicts: none known with the accepted Pi hard cutover/resilience + plan. Human confirmation required: yes, during Integration Gate. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index ae1b093..e6d4fd0 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -47,6 +47,7 @@ export interface CreateCodingCompositionOptions { browser: AgentBrowserModule; paths: CodingCompositionPaths; getLocalProxyCredential?(): string | undefined; + acquireBackgroundLease?(lease: { id: string; kind: 'coding-run' }): () => void; } export function resolveCodingPiRuntimePaths(input: { @@ -158,6 +159,9 @@ export function createCodingComposition( mimeType: record.mime, }; })), + ...(options.acquireBackgroundLease + ? { acquireBackgroundLease: options.acquireBackgroundLease } + : {}), }); const projects = new CodingProjectService(projectStore, { migration: { @@ -174,13 +178,13 @@ export function createCodingComposition( .catch(() => []); for (const conversation of conversations) registry.forget(conversation.id); }, - onProjectDeactivated: async (project) => { + onProjectDeactivated: async (project, reason) => { const conversations = await conversationStoreForProject(project.path).read() .then((file) => file.conversations) .catch(() => []); await Promise.allSettled([ options.browser.close(project.path), - ...conversations.map(({ id }) => runtime.dispose(id)), + ...conversations.map(({ id }) => runtime.dispose(id, reason)), ]); }, }); @@ -205,9 +209,12 @@ export function createCodingComposition( conversations, runtime, host, - async sleep() { + async sleep(reason) { + if (reason === 'background_sleep' && runtime.hasActiveWork()) return; const conversationIds = runtime.getDiagnostics().workers.map((worker) => worker.conversationId); - await Promise.allSettled(conversationIds.map((conversationId) => runtime.dispose(conversationId))); + await Promise.allSettled(conversationIds.map((conversationId) => ( + runtime.dispose(conversationId, reason) + ))); }, async shutdown() { await subagents.close(); diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index 4573234..1ec8365 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -40,7 +40,7 @@ export interface CodingProductComposition { conversations: CodingConversationService; runtime: CodingConversationRuntime; host: CodingProductHost; - sleep(): Promise; + sleep(reason: 'background_sleep' | 'auth_cleanup'): Promise; shutdown(): Promise; } diff --git a/electron/coding-projects/project-service.ts b/electron/coding-projects/project-service.ts index 2fb5c07..6d45484 100644 --- a/electron/coding-projects/project-service.ts +++ b/electron/coding-projects/project-service.ts @@ -60,7 +60,10 @@ export interface CodingProjectConfigSnapshot { export interface CodingProjectServiceOptions { onResourcesChanged?(project: CodingProject): Promise | void; - onProjectDeactivated?(project: CodingProject): Promise | void; + onProjectDeactivated?( + project: CodingProject, + reason: 'project_deactivated' | 'project_removed', + ): Promise | void; createConversationStore?: typeof createCodingConversationStore; writeConfig?: typeof writeCodingProjectConfigV2; migration?: CodingProjectMigrationDependencies; @@ -198,7 +201,9 @@ export class CodingProjectService { async removeProject(projectId: string): Promise { const project = await this.getProject(projectId); const active = await this.store.getActiveProject(); - if (active?.id === project.id) await this.options.onProjectDeactivated?.(project); + if (active?.id === project.id) { + await this.options.onProjectDeactivated?.(project, 'project_removed'); + } try { await this.store.removeProject(project.id); } catch (error) { @@ -373,7 +378,7 @@ export class CodingProjectService { const previous = await this.store.getActiveProject(); const result = await operation(); if (previous && previous.id !== result.project.id) { - await this.options.onProjectDeactivated?.(previous); + await this.options.onProjectDeactivated?.(previous, 'project_deactivated'); } return result.value; }; diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index 2594edb..364f0ea 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -335,7 +335,7 @@ export class CodingConversationService { async deleteConversation(conversationId: string): Promise { const { project, conversation } = await this.projects.findActiveConversation(conversationId); try { - await this.runtime.dispose(conversationId); + await this.runtime.dispose(conversationId, 'conversation_deleted'); } catch (error) { runtimeError(error); } @@ -463,7 +463,7 @@ export class CodingConversationService { const preparing = this.prepareFlights.get(conversationId); if (preparing) await preparing.catch(() => undefined); try { - await this.runtime.dispose(conversationId); + await this.runtime.dispose(conversationId, 'model_reconfiguration'); this.prepareFlights.delete(conversationId); await this.ensurePrepared(conversationId); return state; @@ -537,7 +537,7 @@ export class CodingConversationService { return publicConversation(created); } catch (error) { try { - await this.runtime.dispose(created.id); + await this.runtime.dispose(created.id, 'fork_replacement'); const latest = await store.get(created.id); await this.archiveSession(source.project.id, latest?.sessionKey); await persist(() => store.delete(created.id)); diff --git a/electron/coding-runtime/in-memory-conversation-runtime.ts b/electron/coding-runtime/in-memory-conversation-runtime.ts index ef7e985..c6de1d4 100644 --- a/electron/coding-runtime/in-memory-conversation-runtime.ts +++ b/electron/coding-runtime/in-memory-conversation-runtime.ts @@ -2,6 +2,7 @@ import type { CodingConversationRuntime, CodingRuntimeCommand, CodingRuntimeDiagnostics, + CodingRuntimeDisposeReason, ConversationModelState, ConversationInteraction, ConversationInteractionResponse, @@ -455,7 +456,7 @@ export class InMemoryConversationRuntime implements CodingConversationRuntime { return this.runtimeState(conversationId); } - async dispose(conversationId: string): Promise { + async dispose(conversationId: string, _reason: CodingRuntimeDisposeReason): Promise { if (!this.states.has(conversationId)) return; const snapshot = this.snapshot(conversationId); this.replaceSnapshot({ diff --git a/electron/coding-runtime/pi/release-proof.ts b/electron/coding-runtime/pi/release-proof.ts index 3c017f3..0c74eb9 100644 --- a/electron/coding-runtime/pi/release-proof.ts +++ b/electron/coding-runtime/pi/release-proof.ts @@ -22,7 +22,7 @@ import { clearWorksSquareAIGatewayCredential, seedWorksSquareAIGatewayCredential, } from '../../services/works-square-ai-gateway'; -import type { PrepareConversationInput } from '../contracts'; +import type { ConversationPatchEnvelope, PrepareConversationInput } from '../contracts'; import { PiManagedExtensionHost } from './extension-host'; import { PiManagedInputRevisionCoordinator } from './managed-input-revision'; import { runPiReleasePressureCleanup } from './release-proof-cleanup'; @@ -273,6 +273,23 @@ export interface PiReleaseResilienceProof extends PiReleaseResilienceStatus { realTurnVerified: false; } +export interface PiReleaseResilienceIdleStatus { + workers: ReturnType['workers']; + resources: ReturnType; + processes: { supported: boolean; parent: number[]; child: number[] }; + backgroundSleepReasoned: boolean; + realTurnVerified: false; +} + +export interface PiReleaseIntentionalDisposeProof { + terminal: { observed: boolean; errorCode: string | null; recoverable: boolean }; + other: { runStatus: string; bindingPreserved: boolean }; + targetBindingPreserved: boolean; + providerRequestsUnchanged: boolean; + resources: ReturnType; + realTurnVerified: false; +} + const PROOF_ACCOUNT_ID = 'release-proof-account'; const PROOF_AGENT_ID = 'release-proof-agent'; const PROOF_MODEL_ID = 'release-proof-model'; @@ -1843,6 +1860,90 @@ export function releaseFinalAsarResilienceParents(): void { run.provider.releaseParents(); } +export async function restartFinalAsarResilienceOther(): Promise { + const run = resilienceCompositionRun; + if (!run) throw new Error('PI resilience proof is not running'); + const accepted = await run.composition.conversations.acceptPrompt({ + conversationId: run.otherConversationId, + clientRequestId: 'release-proof-other-active-restarted', + mode: 'prompt', + text: 'RESILIENCE_OTHER_ACTIVE_RESTARTED', + attachments: [], + }); + if (!accepted.accepted) throw new Error('Resilience isolation restart was not accepted'); + return await waitForResilienceStatus( + (status) => status.other.runStatus === 'running' + && status.activeProviderRequests.parent === 1, + 'Resilience isolation restart did not become active', + ); +} + +export async function getFinalAsarResilienceIdleStatus(): Promise { + const run = resilienceCompositionRun; + if (!run) throw new Error('PI resilience proof is not running'); + const runtime = resilienceRuntime(run); + const processInspection = await inspectWindowsPiProcesses(run.hostToken); + const lifecycleLogs = getRecentLogs().filter((line) => ( + line.includes('[PiWorkerLifecycle]') + && (line.includes(run.targetConversationId) || line.includes(run.otherConversationId)) + )); + return { + workers: runtime.getDiagnostics().workers, + resources: runtime.getResilienceProofDiagnostics(), + processes: { + supported: processInspection.supported, + parent: processInspection.processes + .filter(({ role }) => role === 'parent') + .map(({ processId }) => processId) + .sort((left, right) => left - right), + child: processInspection.processes + .filter(({ role }) => role === 'child') + .map(({ processId }) => processId) + .sort((left, right) => left - right), + }, + backgroundSleepReasoned: lifecycleLogs.some((line) => ( + line.includes('"classification": "intentional_stop"') + && line.includes('"reason": "background_sleep"') + )), + realTurnVerified: false, + }; +} + +export async function disposeFinalAsarResilienceTarget(): Promise { + const run = resilienceCompositionRun; + if (!run) throw new Error('PI resilience proof is not running'); + const runtime = resilienceRuntime(run); + const beforeRequests = providerRequestCounts(run.provider); + let errorCode: string | null = null; + let recoverable = false; + const unsubscribe = runtime.subscribe((envelope: ConversationPatchEnvelope) => { + if (envelope.conversationId !== run.targetConversationId + || envelope.patch.op !== 'run.state' + || envelope.patch.run.status !== 'error') return; + errorCode = envelope.patch.run.error?.code ?? null; + recoverable = envelope.patch.run.error?.recoverable ?? false; + }); + try { + await runtime.dispose(run.targetConversationId, 'test_injection'); + } finally { + unsubscribe(); + } + const [other, targetBindingPreserved, otherBindingPreserved] = await Promise.all([ + run.composition.conversations.getSnapshot(run.otherConversationId), + resilienceBindingPreserved(run, run.targetConversationId, run.targetBinding), + resilienceBindingPreserved(run, run.otherConversationId, run.otherBinding), + ]); + return { + terminal: { observed: errorCode !== null, errorCode, recoverable }, + other: { runStatus: other.run.status, bindingPreserved: otherBindingPreserved }, + targetBindingPreserved, + providerRequestsUnchanged: JSON.stringify(beforeRequests) + === JSON.stringify(providerRequestCounts(run.provider)), + resources: runtime.getResilienceProofDiagnostics(), + realTurnVerified: false, + }; +} + export async function finishFinalAsarResilienceProof(): Promise { const run = resilienceCompositionRun; if (!run) throw new Error('PI resilience proof is not running'); @@ -1883,6 +1984,7 @@ export async function finishFinalAsarResilienceProof(): Promise void; } export interface PiWorkerProcessAdapter { @@ -516,6 +518,13 @@ export class PiConversationRuntime implements CodingConversationRuntime { private readonly interactions: PiInteractionStore; private readonly extensionUi: PiExtensionUiProjector; private readonly onExtensionUiProjection: ((projection: PiExtensionUiProjection) => void) | undefined; + private readonly acquireBackgroundLease: + | ((lease: { id: string; kind: 'coding-run' }) => () => void) + | undefined; + private readonly runBackgroundLeases = new Map< + string, + { runId: string; release: () => void } + >(); private readonly states = new Map(); private readonly inputs = new Map(); private readonly listeners = new Set<(patch: ConversationPatchEnvelope) => void>(); @@ -568,6 +577,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { : {}), }); this.onExtensionUiProjection = options.onExtensionUiProjection; + this.acquireBackgroundLease = options.acquireBackgroundLease; if (Boolean(this.isAuthenticationError) !== Boolean(this.refreshCredential)) { throw new Error('Provider authentication detection and refresh must be configured together'); } @@ -642,6 +652,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { this.snapshot(input.conversationId); const images = await this.resolveImages(input.attachments); const runId = this.id('run'); + this.acquireRunBackgroundLease(input.conversationId, runId); const messageId = `client:${input.clientRequestId}`; this.emit(input.conversationId, { op: 'message.upsert', @@ -676,8 +687,9 @@ export class PiConversationRuntime implements CodingConversationRuntime { } catch (error) { this.extensionUi.endRun(input.conversationId, runId); if (this.extensionHost && generation) { - await this.extensionHost.clearRun(input.conversationId, generation, runId); + await this.extensionHost.clearRun(input.conversationId, generation, runId).catch(() => undefined); } + this.releaseRunBackgroundLease(input.conversationId, runId); throw error; } this.emit(input.conversationId, { @@ -734,7 +746,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { if (latest.runId === current.runId && latest.status === 'aborting' && (!worker || worker.state === 'crashed' || worker.generation !== generation)) { - this.failRun(conversationId, current.runId!, error, generation); + await this.failRun(conversationId, current.runId!, error, generation); } else if (latest.runId === current.runId && latest.status === 'aborting') { this.emit(conversationId, { op: 'run.state', run: current }, current.runId); } @@ -865,6 +877,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { async compact(conversationId: string): Promise { await this.waitForProjection(conversationId); const runId = this.id('run'); + this.acquireRunBackgroundLease(conversationId, runId); const generation = this.pool.getState(conversationId)?.generation; if (generation) this.extensionUi.beginRun(conversationId, generation, runId); let ticket; @@ -880,8 +893,9 @@ export class PiConversationRuntime implements CodingConversationRuntime { } catch (error) { this.extensionUi.endRun(conversationId, runId); if (this.extensionHost && generation) { - await this.extensionHost.clearRun(conversationId, generation, runId); + await this.extensionHost.clearRun(conversationId, generation, runId).catch(() => undefined); } + this.releaseRunBackgroundLease(conversationId, runId); throw error; } this.emit(conversationId, { @@ -891,7 +905,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { try { await ticket.accepted; } catch (error) { - this.failRun(conversationId, runId, error); + await this.failRun(conversationId, runId, error); throw error; } } @@ -936,21 +950,69 @@ export class PiConversationRuntime implements CodingConversationRuntime { 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); + try { + 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); + await this.requestHydration(conversationId, state, false); + this.settleRecoveredRun(conversationId); + return this.runtimeState(conversationId); + } finally { + if (before.run.runId) { + this.releaseRunBackgroundLease(conversationId, before.run.runId); + } } - const state = await this.pool.recover(conversationId); - await this.requestHydration(conversationId, state, false); - this.settleRecoveredRun(conversationId); - return this.runtimeState(conversationId); } - async dispose(conversationId: string): Promise { + async dispose( + conversationId: string, + reason: CodingRuntimeDisposeReason, + ): Promise { + await this.waitForProjection(conversationId); + const before = this.states.get(conversationId)?.snapshot; + if (reason === 'background_sleep' + && (this.runBackgroundLeases.has(conversationId) + || Boolean(before && !runIsTerminal(before.run.status)))) return; + + const runId = before?.run.runId; + if (runId && before && !runIsTerminal(before.run.status)) { + await this.enqueueProjection(conversationId, async () => { + const current = this.states.get(conversationId)?.snapshot; + if (!current || current.run.runId !== runId || runIsTerminal(current.run.status)) return; + await this.failRun( + conversationId, + runId, + new CodingRuntimeContractError( + 'CODING_RUNTIME_START_FAILED', + '本地 Agent 已中断,原请求未自动重发。', + true, + ), + current.cursor.workerGeneration, + false, + ); + }); + } + const state = this.pool.getState(conversationId); - if (state) await this.interactions.cancelGeneration(conversationId, state.generation); - await this.pool.dispose(conversationId); + let stopped = reason !== 'background_sleep'; + try { + if (state) await this.interactions.cancelGeneration(conversationId, state.generation); + if (reason === 'background_sleep' && this.hasConversationActiveWork(conversationId)) return; + const didStop = await this.pool.dispose( + conversationId, + reason, + reason === 'background_sleep' + ? () => !this.hasConversationActiveWork(conversationId) + : () => true, + ); + stopped = reason === 'background_sleep' ? didStop : true; + } finally { + if (stopped) this.releaseConversationBackgroundLease(conversationId); + } + if (reason === 'background_sleep' && !stopped) return; this.registry.forget(conversationId); this.inputs.delete(conversationId); this.states.delete(conversationId); @@ -1008,14 +1070,27 @@ export class PiConversationRuntime implements CodingConversationRuntime { pool: ReturnType; subagents: ReturnType | null; extension: ReturnType | null; + backgroundLeases: { active: number }; } { return { pool: this.pool.getResilienceProofDiagnostics(), subagents: this.subagentScheduler?.getDiagnostics() ?? null, extension: this.extensionHost?.getDiagnostics() ?? null, + backgroundLeases: { active: this.runBackgroundLeases.size }, }; } + hasActiveWork(): boolean { + if (this.runBackgroundLeases.size > 0) return true; + return [...this.states.values()].some(({ snapshot }) => !runIsTerminal(snapshot.run.status)); + } + + private hasConversationActiveWork(conversationId: string): boolean { + if (this.runBackgroundLeases.has(conversationId)) return true; + const snapshot = this.states.get(conversationId)?.snapshot; + return Boolean(snapshot && !runIsTerminal(snapshot.run.status)); + } + markProviderStale(): void { this.pool.markProviderStale(); } @@ -1038,11 +1113,23 @@ export class PiConversationRuntime implements CodingConversationRuntime { async shutdown(): Promise { this.unsubscribePool(); - await this.pool.shutdown(); - await this.extensionHost?.close(); - this.projectors.clear(); - this.projectionChains.clear(); - this.hydrationFlights.clear(); + try { + const results = await Promise.allSettled([ + this.pool.shutdown(), + this.extensionHost?.close(), + ]); + const failure = results.find((result): result is PromiseRejectedResult => ( + result.status === 'rejected' + )); + if (failure) throw failure.reason; + } finally { + for (const conversationId of [...this.runBackgroundLeases.keys()]) { + this.releaseConversationBackgroundLease(conversationId); + } + this.projectors.clear(); + this.projectionChains.clear(); + this.hydrationFlights.clear(); + } } private async queue( @@ -1051,6 +1138,9 @@ export class PiConversationRuntime implements CodingConversationRuntime { ): Promise { await this.waitForProjection(input.conversationId); const snapshot = this.snapshot(input.conversationId); + if (snapshot.run.runId) { + this.acquireRunBackgroundLease(input.conversationId, snapshot.run.runId); + } const images = await this.resolveImages(input.attachments); const queuePosition = snapshot.queue.items.length + 1; const queueId = this.id('queue'); @@ -1170,7 +1260,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { runId, failure instanceof Error ? failure : new Error('Prompt acceptance failed'), ); - this.failRun(conversationId, runId, failure); + await this.failRun(conversationId, runId, failure); throw failure; } } @@ -1251,7 +1341,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { await this.interactions.cancelGeneration(event.conversationId, event.generation); const current = this.snapshot(event.conversationId).run; if (current.runId && !runIsTerminal(current.status)) { - this.failRun( + await this.failRun( event.conversationId, current.runId, event.error, @@ -1309,25 +1399,32 @@ export class PiConversationRuntime implements CodingConversationRuntime { } } if (event.event.type === 'agent_settled' && snapshot.run.runId) { - await this.interactions.cancelRun(event.conversationId, snapshot.run.runId, true); - await this.extensionHost?.clearRun( - event.conversationId, - event.generation, - snapshot.run.runId, - ); - this.extensionUi.endRun(event.conversationId, snapshot.run.runId); + try { + await Promise.allSettled([ + this.interactions.cancelRun(event.conversationId, snapshot.run.runId, true), + this.extensionHost?.clearRun( + event.conversationId, + event.generation, + snapshot.run.runId, + ), + ]); + this.extensionUi.endRun(event.conversationId, snapshot.run.runId); + } finally { + this.releaseRunBackgroundLease(event.conversationId, snapshot.run.runId); + } } }).catch((error) => { this.recordProjectionFailure(event.conversationId, event.generation, error); }); } - private failRun( + private async failRun( conversationId: string, runId: string, error: unknown, generation?: number, - ): void { + releaseBackgroundLease = true, + ): Promise { const current = this.states.get(conversationId)?.snapshot?.run; const snapshotGeneration = this.states.get(conversationId)?.snapshot?.cursor.workerGeneration; if (current?.runId !== runId @@ -1351,10 +1448,18 @@ export class PiConversationRuntime implements CodingConversationRuntime { generation: generation ?? snapshotGeneration, code: publicError.code, }); - void this.interactions.cancelRun(conversationId, runId, true); this.extensionUi.endRun(conversationId, runId); const runGeneration = generation ?? this.pool.getState(conversationId)?.generation; - if (runGeneration) void this.extensionHost?.clearRun(conversationId, runGeneration, runId); + try { + await Promise.allSettled([ + this.interactions.cancelRun(conversationId, runId, true), + runGeneration + ? this.extensionHost?.clearRun(conversationId, runGeneration, runId) + : undefined, + ]); + } finally { + if (releaseBackgroundLease) this.releaseRunBackgroundLease(conversationId, runId); + } } private requestHydration( @@ -1555,6 +1660,35 @@ export class PiConversationRuntime implements CodingConversationRuntime { if (input) input.model = clone(model); } + private acquireRunBackgroundLease(conversationId: string, runId: string): void { + const current = this.runBackgroundLeases.get(conversationId); + if (current?.runId === runId) return; + if (current) { + throw new CodingRuntimeContractError( + 'CODING_RUNTIME_START_FAILED', + 'Conversation already has an active run', + true, + ); + } + const release = this.acquireBackgroundLease?.({ + id: `coding-run:${conversationId}:${runId}`, + kind: 'coding-run', + }) ?? (() => undefined); + this.runBackgroundLeases.set(conversationId, { runId, release }); + } + + private releaseRunBackgroundLease(conversationId: string, runId: string): void { + const current = this.runBackgroundLeases.get(conversationId); + if (!current || current.runId !== runId) return; + this.runBackgroundLeases.delete(conversationId); + current.release(); + } + + private releaseConversationBackgroundLease(conversationId: string): void { + const current = this.runBackgroundLeases.get(conversationId); + if (current) this.releaseRunBackgroundLease(conversationId, current.runId); + } + private id(kind: RuntimeIdKind): string { return this.createRuntimeId(kind); } diff --git a/electron/coding-runtime/pi/worker-pool.ts b/electron/coding-runtime/pi/worker-pool.ts index febc8df..fa960f8 100644 --- a/electron/coding-runtime/pi/worker-pool.ts +++ b/electron/coding-runtime/pi/worker-pool.ts @@ -635,21 +635,27 @@ export class PiWorkerPool { return this.publicState(await record.rebuildFlight); } - async dispose(conversationId: string): Promise { + async dispose( + conversationId: string, + reason: PiWorkerStopReason, + canStop: () => boolean = () => true, + ): Promise { const pendingPrepare = this.prepareFlights.get(conversationId); if (pendingPrepare) await pendingPrepare.catch(() => undefined); let record = this.workers.get(conversationId); - if (!record) return; + if (!record) return false; if (record.rebuildFlight) { record = await record.rebuildFlight.catch(() => record as WorkerRecord); } + if (!canStop()) return false; this.cancelConversationRuns(conversationId, new Error('Conversation worker was disposed')); record.unsubscribeEvent(); record.unsubscribeInvalidation(); this.cancelGenerationResources(record); this.revisions.removeWorker(record.revisionWorkerId); if (this.workers.get(conversationId) === record) this.workers.delete(conversationId); - await this.ensureStoppedAndReleased(record, 'dispose'); + await this.ensureStoppedAndReleased(record, reason); + return true; } private async performShutdown(): Promise { diff --git a/electron/coding-runtime/pi/worker-process.ts b/electron/coding-runtime/pi/worker-process.ts index 00647a8..ba0b239 100644 --- a/electron/coding-runtime/pi/worker-process.ts +++ b/electron/coding-runtime/pi/worker-process.ts @@ -5,6 +5,7 @@ import { } from 'node:child_process'; import { platform } from 'node:os'; import { logger } from '../../utils/logger'; +import type { CodingRuntimeDisposeReason } from '../contracts'; import { PiProcessError, type PiProcessErrorCode } from './process-errors'; import { PiRpcClient, @@ -49,16 +50,13 @@ export type PiWorkerStopResult = { export type PiWorkerProofFailure = 'unexpected_exit' | 'protocol_invalidation'; -export type PiWorkerStopReason = +export type PiWorkerStopReason = CodingRuntimeDisposeReason | 'app_shutdown' | 'idle_eviction' | 'queued_suspension' | 'stale_resource_rebuild' | 'recover' - | 'dispose' - | 'model_reconfiguration' | 'process_capacity_reopen' - | 'fork_replacement' | 'protocol_invalidation' | 'unexpected_exit_cleanup' | 'open_failure' @@ -74,7 +72,11 @@ const PI_WORKER_STOP_REASONS = new Set([ 'queued_suspension', 'stale_resource_rebuild', 'recover', - 'dispose', + 'background_sleep', + 'project_deactivated', + 'project_removed', + 'conversation_deleted', + 'auth_cleanup', 'model_reconfiguration', 'process_capacity_reopen', 'fork_replacement', diff --git a/electron/main/background-lifecycle.ts b/electron/main/background-lifecycle.ts index 2aaa804..e144fb4 100644 --- a/electron/main/background-lifecycle.ts +++ b/electron/main/background-lifecycle.ts @@ -101,6 +101,7 @@ export class BackgroundLifecycleController extends EventEmitter { this.stopping = true; try { await this.options.onSleep(); + if (!this.shouldSleep() || this.leases.size > 0) return; await this.options.onStopRuntime(); this.emit('sleep'); } finally { diff --git a/electron/main/index.ts b/electron/main/index.ts index 49d7c11..a34ac13 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -90,14 +90,17 @@ import { } from '../api/coding-composition'; import { abortFinalAsarResilienceTarget, + disposeFinalAsarResilienceTarget, finishFinalAsarProxyCompositionProof, finishFinalAsarPressureProof, finishFinalAsarResilienceProof, getFinalAsarProxyCompositionStatus, + getFinalAsarResilienceIdleStatus, getFinalAsarResilienceStatus, injectFinalAsarResilienceFailure, releaseFinalAsarProxyCompositionChild, releaseFinalAsarResilienceParents, + restartFinalAsarResilienceOther, runFinalAsarExtensionProof, startFinalAsarProxyCompositionProof, startFinalAsarPressureProof, @@ -524,12 +527,13 @@ async function initialize(): Promise { // Register IPC handlers backgroundLifecycle = new BackgroundLifecycleController({ + ...(isE2EMode ? { idleStopMs: 250 } : {}), onSleep: () => { if (!window.isDestroyed() && !window.webContents.isDestroyed()) { window.webContents.send('lifecycle:sleep'); } }, - onStopRuntime: async () => await codingProducts?.sleep(), + onStopRuntime: async () => await codingProducts?.sleep('background_sleep'), }); const releaseUtilityPreparer = createReleaseUtilityPreparer({ runtimeContext: { @@ -557,6 +561,7 @@ async function initialize(): Promise { projectStore: codingProjectStore, browser: agentBrowser, getLocalProxyCredential: () => getHostApiToken() || undefined, + acquireBackgroundLease: (lease) => backgroundLifecycle!.acquireLease(lease), paths: { ...resolveCodingPiRuntimePaths({ isPackaged: app.isPackaged, @@ -898,6 +903,9 @@ type PiReleaseProofAction = | 'proxy.finish' | 'resilience.start' | 'resilience.status' + | 'resilience.idle-status' + | 'resilience.restart-other' + | 'resilience.dispose-target' | 'resilience.inject-exit' | 'resilience.inject-protocol' | 'resilience.abort-after-exit' @@ -961,6 +969,15 @@ export async function runPiReleaseProofE2E(action: PiReleaseProofAction) { if (action === 'resilience.status') { return { action, packagedMain, resilience: await getFinalAsarResilienceStatus() }; } + if (action === 'resilience.idle-status') { + return { action, packagedMain, resilience: await getFinalAsarResilienceIdleStatus() }; + } + if (action === 'resilience.restart-other') { + return { action, packagedMain, resilience: await restartFinalAsarResilienceOther() }; + } + if (action === 'resilience.dispose-target') { + return { action, packagedMain, resilience: await disposeFinalAsarResilienceTarget() }; + } if (action === 'resilience.inject-exit' || action === 'resilience.inject-protocol') { return { action, diff --git a/electron/services/works-square-runtime.ts b/electron/services/works-square-runtime.ts index fd838be..e40ee9c 100644 --- a/electron/services/works-square-runtime.ts +++ b/electron/services/works-square-runtime.ts @@ -5,7 +5,7 @@ import { getProviderService } from './providers/provider-service'; export type WorksSquareRuntimeContext = { codingProducts?: { - sleep(): Promise; + sleep(reason: 'auth_cleanup'): Promise; }; imageWorkspace?: { closeEventSessions?(options?: { @@ -31,7 +31,7 @@ async function runManagedWorksSquareRuntimeCleanup( accessToken, tolerateRemoteFailure, }))(), - (async () => await ctx.codingProducts?.sleep())(), + (async () => await ctx.codingProducts?.sleep('auth_cleanup'))(), (async () => { const deleted = await getProviderService().deleteAccountApiKey( NIANCODE_USER_MODEL_ACCOUNT_ID, diff --git a/scripts/run-pi-subagent-packaged-smoke.mjs b/scripts/run-pi-subagent-packaged-smoke.mjs index b381c54..9004361 100644 --- a/scripts/run-pi-subagent-packaged-smoke.mjs +++ b/scripts/run-pi-subagent-packaged-smoke.mjs @@ -215,6 +215,26 @@ async function waitForResilienceProof(electronApplication, predicate, message) { throw new Error(`${message}: ${JSON.stringify(latest?.resilience)}`); } +async function waitForResilienceIdleProof(electronApplication, predicate, message) { + const deadline = Date.now() + 30_000; + let latest; + while (Date.now() < deadline) { + latest = await evaluateProof(electronApplication, 'resilience.idle-status'); + if (predicate(latest?.resilience)) return latest; + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error(`${message}: ${JSON.stringify(latest?.resilience)}`); +} + +async function setMainWindowVisible(electronApplication, visible) { + await electronApplication.evaluate(({ BrowserWindow }, shouldShow) => { + const window = BrowserWindow.getAllWindows()[0]; + if (!window) throw new Error('Packaged Main window is unavailable'); + if (shouldShow) window.show(); + else window.hide(); + }, visible); +} + function assertResilienceTerminal( status, expectedRunStatus = 'error', @@ -543,7 +563,7 @@ export async function runPackagedProductProof(options) { await resilienceTarget.click(); const resilienceComposer = page.getByRole('textbox'); await resilienceComposer.waitFor({ state: 'visible', timeout: 30_000 }); - await resilienceComposer.fill('RESILIENCE_TARGET_ACTIVE'); + await resilienceComposer.fill('BACKGROUND_ACTIVE'); try { await page.getByRole('button', { name: '发送' }).click({ timeout: 30_000 }); } catch (error) { @@ -557,6 +577,109 @@ export async function runPackagedProductProof(options) { ); } + const backgroundActive = await waitForResilienceProof( + electronApplication, + (status) => status?.target?.runStatus === 'running' + && status?.other?.runStatus === 'running' + && status?.activeProviderRequests?.parent === 2 + && status?.resources?.backgroundLeases?.active === 2 + && status?.processes?.parent?.length === 2, + 'Packaged background lease proof did not reach two active Main-owned runs', + ); + await setMainWindowVisible(electronApplication, false); + await new Promise((resolveWait) => setTimeout(resolveWait, 600)); + const backgroundHidden = await evaluateProof(electronApplication, 'resilience.status'); + if (backgroundHidden?.resilience?.target?.runStatus !== 'running' + || backgroundHidden?.resilience?.other?.runStatus !== 'running' + || backgroundHidden?.resilience?.activeProviderRequests?.parent !== 2 + || backgroundHidden?.resilience?.resources?.backgroundLeases?.active !== 2 + || backgroundHidden?.resilience?.processes?.parent?.length !== 2) { + throw new Error(`Hidden packaged Pi run was stopped despite active leases: ${JSON.stringify(backgroundHidden?.resilience)}`); + } + await evaluateProof(electronApplication, 'resilience.release-parents'); + await waitForResilienceProof( + electronApplication, + (status) => status?.target?.runStatus === 'idle' + && status?.other?.runStatus === 'idle' + && status?.resources?.backgroundLeases?.active === 0, + 'Hidden packaged Pi runs did not settle and release their background leases', + ); + await setMainWindowVisible(electronApplication, true); + await page.getByText('RESILIENCE_ACTIVE').last().waitFor({ state: 'visible', timeout: 30_000 }); + if (!await resilienceComposer.isEnabled()) { + throw new Error('Packaged composer stayed disabled after the hidden run settled'); + } + await setMainWindowVisible(electronApplication, false); + const backgroundIdle = await waitForResilienceIdleProof( + electronApplication, + (status) => status?.workers?.length === 0 + && status?.resources?.backgroundLeases?.active === 0 + && status?.resources?.pool?.processBudget?.active === 0 + && status?.processes?.parent?.length === 0 + && status?.processes?.child?.length === 0 + && status?.backgroundSleepReasoned === true, + 'Settled hidden packaged Pi workers were not reasoned background-sleep evicted', + ); + await setMainWindowVisible(electronApplication, true); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + if (await page.getByTestId('ai-module-selection-page').count()) { + await page.getByTestId('ai-module-option-programming').click(); + await page.getByTestId('main-layout').waitFor({ state: 'visible', timeout: 10_000 }); + } + await page.evaluate(() => { window.location.hash = '/chat'; }); + await resilienceTarget.waitFor({ state: 'visible', timeout: 30_000 }); + await resilienceTarget.click(); + await resilienceComposer.waitFor({ state: 'visible', timeout: 30_000 }); + + const restartedOther = await evaluateProof(electronApplication, 'resilience.restart-other'); + assertPackagedMain(restartedOther); + if (restartedOther?.resilience?.other?.runStatus !== 'running' + || restartedOther?.resilience?.resources?.backgroundLeases?.active !== 1) { + throw new Error(`Packaged resilience sibling did not restart: ${JSON.stringify(restartedOther?.resilience)}`); + } + await resilienceComposer.fill('RESILIENCE_TARGET_ACTIVE'); + await page.getByRole('button', { name: '发送' }).click({ timeout: 30_000 }); + const intentionalActive = await waitForResilienceProof( + electronApplication, + (status) => status?.target?.runStatus === 'running' + && status?.other?.runStatus === 'running' + && status?.resources?.subagents?.activeChildPermits === 1 + && status?.resources?.subagents?.activeDispatches === 1 + && status?.resources?.extension?.registrations?.child === 1 + && status?.resources?.extension?.writeLeases?.active === 1 + && status?.resources?.backgroundLeases?.active === 2 + && status?.processes?.parent?.length === 2 + && status?.processes?.child?.length === 1, + 'Packaged intentional-dispose parent/child state did not become active', + ); + const intentionalDispose = await evaluateProof(electronApplication, 'resilience.dispose-target'); + assertPackagedMain(intentionalDispose); + if (intentionalDispose?.resilience?.terminal?.observed !== true + || intentionalDispose?.resilience?.terminal?.errorCode !== 'CODING_RUNTIME_START_FAILED' + || intentionalDispose?.resilience?.terminal?.recoverable !== true + || intentionalDispose?.resilience?.other?.runStatus !== 'running' + || intentionalDispose?.resilience?.other?.bindingPreserved !== true + || intentionalDispose?.resilience?.targetBindingPreserved !== true + || intentionalDispose?.resilience?.providerRequestsUnchanged !== true + || intentionalDispose?.resilience?.resources?.backgroundLeases?.active !== 1) { + throw new Error(`Packaged intentional dispose did not converge safely: ${JSON.stringify(intentionalDispose?.resilience)}`); + } + await page.getByText('本地 Agent 已中断,原请求未自动重发。').waitFor({ state: 'visible', timeout: 10_000 }); + if (!await resilienceComposer.isEnabled()) { + throw new Error('Packaged composer stayed disabled after intentional dispose'); + } + const recoveredAfterDispose = await recoverSelectedConversation(page, electronApplication); + if (JSON.stringify(recoveredAfterDispose.resilience.providerRequests) + !== JSON.stringify(intentionalActive.resilience.providerRequests)) { + throw new Error('Recover replayed the uncertain prompt after intentional dispose'); + } + await resilienceComposer.fill('RESILIENCE_RECOVERED_TURN'); + await page.getByRole('button', { name: '发送' }).click({ timeout: 30_000 }); + await page.getByText('RESILIENCE_RECOVERED_COMPLETE').waitFor({ state: 'visible', timeout: 30_000 }); + + await resilienceComposer.fill('RESILIENCE_TARGET_ACTIVE'); + await page.getByRole('button', { name: '发送' }).click({ timeout: 30_000 }); const activeResilience = await waitForResilienceProof( electronApplication, (status) => status?.target?.runStatus === 'running' @@ -565,6 +688,7 @@ export async function runPackagedProductProof(options) { && status?.resources?.subagents?.activeDispatches === 1 && status?.resources?.extension?.registrations?.child === 1 && status?.resources?.extension?.writeLeases?.active === 1 + && status?.resources?.backgroundLeases?.active === 2 && status?.processes?.parent?.length === 2 && status?.processes?.child?.length === 1, 'Packaged resilience parent/child/write-lease fault state did not become active', @@ -721,6 +845,21 @@ export async function runPackagedProductProof(options) { }, resilience: { setup: resilienceStart.resilience, + backgroundLifecycle: { + active: backgroundActive.resilience, + hiddenPastGrace: backgroundHidden.resilience, + idleEvicted: backgroundIdle.resilience, + uiPromptAccepted: true, + answerVisibleAfterShow: true, + composerUnlocked: true, + }, + intentionalDispose: { + active: intentionalActive.resilience, + disposed: intentionalDispose.resilience, + recovered: recoveredAfterDispose.resilience, + siblingContinued: true, + uncertainPromptReplayed: false, + }, active: activeResilience.resilience, exit: exitFailure.resilience, abortAfterExit: abortAfterExit.resilience, diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts index fb0a86a..29ac76f 100644 --- a/shared/coding-conversation-contracts.ts +++ b/shared/coding-conversation-contracts.ts @@ -425,6 +425,16 @@ export interface CodingRuntimeDiagnostics { }>; } +export type CodingRuntimeDisposeReason = + | 'background_sleep' + | 'project_deactivated' + | 'project_removed' + | 'conversation_deleted' + | 'auth_cleanup' + | 'model_reconfiguration' + | 'fork_replacement' + | 'test_injection'; + export interface CodingConversationRuntime { prepare(input: PrepareConversationInput): Promise; getSnapshot(conversationId: string): Promise; @@ -438,7 +448,7 @@ export interface CodingConversationRuntime { compact(conversationId: string): Promise; fork(input: ForkConversationInput): Promise; recover(conversationId: string): Promise; - dispose(conversationId: string): Promise; + dispose(conversationId: string, reason: CodingRuntimeDisposeReason): Promise; listCommands(conversationId: string): Promise; listInteractions(conversationId?: string): Promise; respondInteraction( diff --git a/tests/unit/auth-routes.test.ts b/tests/unit/auth-routes.test.ts index 9ef14ed..1350178 100644 --- a/tests/unit/auth-routes.test.ts +++ b/tests/unit/auth-routes.test.ts @@ -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'); }); diff --git a/tests/unit/background-lifecycle.test.ts b/tests/unit/background-lifecycle.test.ts index 895d1e0..aebc5ef 100644 --- a/tests/unit/background-lifecycle.test.ts +++ b/tests/unit/background-lifecycle.test.ts @@ -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((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(); diff --git a/tests/unit/coding-core-routes.test.ts b/tests/unit/coding-core-routes.test.ts index 1b81c7b..52557e9 100644 --- a/tests/unit/coding-core-routes.test.ts +++ b/tests/unit/coding-core-routes.test.ts @@ -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', diff --git a/tests/unit/pi-background-lifecycle.test.ts b/tests/unit/pi-background-lifecycle.test.ts new file mode 100644 index 0000000..0753cb3 --- /dev/null +++ b/tests/unit/pi-background-lifecycle.test.ts @@ -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( + 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(); + }); +}); diff --git a/tests/unit/pi-conversation-runtime.test.ts b/tests/unit/pi-conversation-runtime.test.ts index 3cfefc9..6261534 100644 --- a/tests/unit/pi-conversation-runtime.test.ts +++ b/tests/unit/pi-conversation-runtime.test.ts @@ -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(); }); }); diff --git a/tests/unit/pi-release-proof-wiring.test.ts b/tests/unit/pi-release-proof-wiring.test.ts index c8a8b56..715bcd4 100644 --- a/tests/unit/pi-release-proof-wiring.test.ts +++ b/tests/unit/pi-release-proof-wiring.test.ts @@ -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')"); + }); }); diff --git a/tests/unit/pi-worker-pool.test.ts b/tests/unit/pi-worker-pool.test.ts index 7c1c05c..b398fd9 100644 --- a/tests/unit/pi-worker-pool.test.ts +++ b/tests/unit/pi-worker-pool.test.ts @@ -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 });