diff --git a/.project-docs/30-worklog/tasks/20260901-agent-received-stall-8b6d4c21.md b/.project-docs/30-worklog/tasks/20260901-agent-received-stall-8b6d4c21.md new file mode 100644 index 0000000..eae4691 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-agent-received-stall-8b6d4c21.md @@ -0,0 +1,53 @@ +# Task: Diagnose local Agent received-message stall + +## Identity + +- Task ID: 20260901-agent-received-stall-8b6d4c21 +- Mode: Feature +- Branch: codex/20260901-agent-received-stall-8b6d4c21-agent-received-stall +- Worktree: D:\Datas\OthersProjects\makelore-worktrees\agent-received-stall-8b6d4c21 +- Base commit: 850947c092892cb647c4191b6d8bbf37a763e1ad +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose the installed-app state where a prompt is acknowledged locally but remains optimistic indefinitely. +- Fix the background-sleep versus Agent Server start race without replaying accepted prompts. +- Add focused regression coverage for both lifecycle race windows. + +## Intent And Constraints + +- Preserve the single shared Pi Agent Server and per-conversation logical thread architecture. +- Background sleep must not stop work that begins while idle worker cleanup is in flight. +- A start racing an already-started stop must wait for the stop and then create a fresh server process. +- Do not auto-replay an accepted or uncertain prompt. + +## Outcome + +- Confirmed installed-state evidence: the accepted UI state was newer than the latest Pi session write, and no Agent Server process remained live, so the prompt had not reached Provider generation. +- Confirmed regression: `PiAgentServerProcess.start()` returned immediately when the old child still existed during an in-flight stop; after that stop completed, no server remained. The real-process race test failed before the fix and passed afterward. +- Confirmed regression: composition checked for active work only before asynchronous background cleanup, then stopped the shared Agent Server even when a new run began during cleanup. The composition race test failed before the fix and passed afterward. +- Fixed both lifecycle windows: background sleep rechecks active work after worker cleanup, and a start racing a stop waits for that stop before creating a fresh server. +- The installed-app symptom maps directly to these two confirmed races, although the exact renderer/Main interleaving of the reported occurrence was not captured live. +- Accepted and uncertain prompts are still never auto-replayed. + +## Verification + +- Installed-state red loop: accepted screenshot timestamp is newer than the latest Pi session write while no Agent Server process is live. +- Red phase: the two focused race tests both failed against the original implementation. +- `pnpm exec vitest run tests/unit/pi-agent-server-process-real.test.ts tests/unit/coding-composition-background-sleep.test.ts --maxWorkers=1` — 2 files, 4 tests passed. +- `pnpm exec vitest run tests/unit/background-lifecycle.test.ts tests/unit/pi-background-lifecycle.test.ts tests/unit/pi-agent-server-process-real.test.ts tests/unit/coding-composition-background-sleep.test.ts --maxWorkers=1` — 4 files, 9 tests passed. +- `pnpm run typecheck` — passed. +- `pnpm run lint:check` — passed with 5 pre-existing warnings and no errors. +- `pnpm test` — 216 files passed; 1,764 tests passed and 2 skipped. +- `pnpm run build:vite` — Renderer, Main, Preload, and utility production builds passed. +- Existing Electron E2E fixtures mock prompt acceptance and do not exercise the real Main-owned Agent Server lifecycle; the real-process regression test is the relevant product-path coverage. + +## Follow-ups + +- Integrate the feature commit into `main`, then produce/reinstall a Windows package before validating the original installed-app reproduction; the currently installed binary does not contain this source fix. + +## Promotion Candidates + +- None. The change enforces existing background-lease and single-Agent-Server architecture rather than changing canonical product behavior. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 1210947..8d55ac3 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -498,6 +498,7 @@ export function createCodingComposition( await Promise.allSettled(conversationIds.map((conversationId) => ( runtime.dispose(conversationId, reason) ))); + if (reason === 'background_sleep' && runtime.hasActiveWork()) return; await agentServer.stop(); }, async shutdown() { diff --git a/electron/coding-runtime/pi/agent-server-process.ts b/electron/coding-runtime/pi/agent-server-process.ts index 15c6075..41ed00b 100644 --- a/electron/coding-runtime/pi/agent-server-process.ts +++ b/electron/coding-runtime/pi/agent-server-process.ts @@ -490,6 +490,7 @@ export class PiAgentServerProcess { } start(): Promise { + if (this.stopFlight) return this.stopFlight.then(() => this.start()); if (this.startFlight) return this.startFlight; if (this.child && !this.failure) return Promise.resolve(); this.startFlight = this.startServer().finally(() => { diff --git a/tests/unit/coding-composition-background-sleep.test.ts b/tests/unit/coding-composition-background-sleep.test.ts new file mode 100644 index 0000000..d969cd3 --- /dev/null +++ b/tests/unit/coding-composition-background-sleep.test.ts @@ -0,0 +1,62 @@ +// @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 type { AgentBrowserModule } from '../../electron/agent-browser'; +import { createCodingComposition } from '../../electron/api/coding-composition'; +import { PiAgentServerProcess } from '../../electron/coding-runtime/pi/agent-server-process'; +import { createMemoryCodingProjectStorage } from '../../electron/coding-projects/project-store'; + +const roots: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('coding composition background sleep', () => { + it('does not stop the shared Agent Server when work starts during worker cleanup', async () => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-sleep-race-project-')); + const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-sleep-race-user-')); + roots.push(projectPath, userDataDir); + const composition = createCodingComposition({ + storage: createMemoryCodingProjectStorage(), + browser: { close: vi.fn(async () => undefined) } as unknown as AgentBrowserModule, + paths: { + executablePath: process.execPath, + cliPath: path.join(projectPath, 'unused-cli.js'), + serverPath: path.join(projectPath, 'unused-server.mjs'), + userDataDir, + bundledSkillsDir: path.resolve('resources/coding-skills'), + }, + }); + const stopAgentServer = vi.spyOn(PiAgentServerProcess.prototype, 'stop') + .mockResolvedValue(undefined); + let activeWork = false; + const hasActiveWork = vi.spyOn(composition.runtime, 'hasActiveWork') + .mockImplementation(() => activeWork); + const getDiagnostics = vi.spyOn(composition.runtime, 'getDiagnostics') + .mockReturnValue({ + workers: [{ conversationId: 'conversation-a' }], + } as ReturnType); + const dispose = vi.spyOn(composition.runtime, 'dispose').mockImplementation(async () => { + activeWork = true; + }); + + try { + await composition.sleep('background_sleep'); + + expect(dispose).toHaveBeenCalledWith('conversation-a', 'background_sleep'); + expect(hasActiveWork).toHaveBeenCalledTimes(2); + expect(stopAgentServer).not.toHaveBeenCalled(); + } finally { + dispose.mockRestore(); + getDiagnostics.mockRestore(); + hasActiveWork.mockRestore(); + stopAgentServer.mockRestore(); + await composition.shutdown(); + } + }); +}); diff --git a/tests/unit/pi-agent-server-process-real.test.ts b/tests/unit/pi-agent-server-process-real.test.ts index f835674..17a95f6 100644 --- a/tests/unit/pi-agent-server-process-real.test.ts +++ b/tests/unit/pi-agent-server-process-real.test.ts @@ -133,6 +133,30 @@ describe('Pi Agent Server real process', () => { } }, 10_000); + it('restarts when start races with a graceful background stop', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-agent-server-restart-')); + roots.push(root); + const layout = await materializePackagedAgentServerLayout(root); + const server = new PiAgentServerProcess({ + executablePath: electronExecutable, + ...layout, + }); + + try { + await server.start(); + const firstProcessId = server.processId; + expect(firstProcessId).toBeTypeOf('number'); + + const stopping = server.stop(); + const restarting = server.start(); + await Promise.all([stopping, restarting]); + expect(server.processId).toBeTypeOf('number'); + expect(server.processId).not.toBe(firstProcessId); + } finally { + await server.stop(); + } + }, 20_000); + it('hosts isolated Conversation threads in one long-lived process', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-agent-server-')); roots.push(root);