diff --git a/__tests__/query-pool.test.ts b/__tests__/query-pool.test.ts index 5cf2f582a..c5ab411ee 100644 --- a/__tests__/query-pool.test.ts +++ b/__tests__/query-pool.test.ts @@ -268,6 +268,36 @@ describe('QueryPool', () => { await pool.destroy(); }); + it('retires idle workers down to one with a default project, and to none without one', async () => { + const make = (root: string | null) => { + let created = 0; + const pool = new QueryPool({ root, size: 2, idleRetireMs: 1_000, createWorker: () => { created++; return new FakeWorker((m) => ({ result: ok(`r:${m.toolName}`) })); } }); + return { pool, created: () => created }; + }; + const retire = (pool: QueryPool) => (pool as unknown as { retireIdle(now: number): void }).retireIdle(Date.now() + 60_000); + + const rooted = make('/x'); + await sleep(5); + expect(rooted.pool.liveWorkers).toBe(1); + retire(rooted.pool); + expect(rooted.pool.liveWorkers).toBe(1); // stays warm for the default project + await rooted.pool.destroy(); + + const rootless = make(null); + rootless.pool.warm(); + await sleep(5); + expect(await rootless.pool.run('codegraph_explore', {})).toEqual(ok('r:codegraph_explore')); + expect(rootless.pool.ready).toBe(true); + retire(rootless.pool); + expect(rootless.pool.liveWorkers).toBe(0); + expect(rootless.pool.ready).toBe(false); // calls run in-process again + rootless.pool.warm(); // the next call that names a project starts one again + expect(rootless.created()).toBe(2); + await sleep(5); + expect(rootless.pool.ready).toBe(true); + await rootless.pool.destroy(); + }); + it('retires a failed cold start and serves queued work on its replacement', async () => { const workers: FakeWorker[] = []; const pool = new QueryPool({ diff --git a/src/mcp/query-pool.ts b/src/mcp/query-pool.ts index 09ac847ed..46e62ba0e 100644 --- a/src/mcp/query-pool.ts +++ b/src/mcp/query-pool.ts @@ -224,22 +224,27 @@ export class QueryPool { } /** - * Terminate workers idle for `idleRetireMs`, oldest idle first, keeping one. + * Terminate workers idle for `idleRetireMs`, oldest idle first. A pool with a + * default project keeps one warm; a pool without one retires them all, since + * {@link warm} starts another on the next call that names a project. * A retired worker leaves `workers` before it is terminated, so its exit is * not counted as a crash (onWorkerGone ignores workers it no longer owns). */ private retireIdle(now = Date.now()): void { if (this.destroyed) return; + const keep = this.root === null ? 0 : 1; const stale = this.idle .filter((w) => now - (this.idleSince.get(w) ?? now) >= this.idleRetireMs) .sort((a, b) => (this.idleSince.get(a) ?? 0) - (this.idleSince.get(b) ?? 0)); for (const w of stale) { - if (this.workers.size <= 1) break; + if (this.workers.size <= keep) break; this.workers.delete(w); this.idle = this.idle.filter((x) => x !== w); this.idleSince.delete(w); try { void w.terminate(); } catch { /* already gone */ } } + // An empty pool is cold again: serve in-process until the next worker is ready. + if (this.workers.size === 0) this.everReady = false; } /** Put a worker back on the idle stack and note when it went idle. */ @@ -272,8 +277,8 @@ export class QueryPool { * hitting that window was the recurring #662 test flake (and a real * first-call stall for agents). The pool exists for CONCURRENT load, which * by definition arrives after warm-up; the pre-pool in-process path is - * strictly better while nothing is warm. Stays true for the pool's - * lifetime — later crash-respawn gaps are covered by retry + backstop. + * strictly better while nothing is warm. Stays true until idle retirement + * empties the pool — crash-respawn gaps are covered by retry + backstop. */ get ready(): boolean { return this.everReady && !this.destroyed;