Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions __tests__/query-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
13 changes: 9 additions & 4 deletions src/mcp/query-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
bompus marked this conversation as resolved.
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. */
Expand Down Expand Up @@ -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;
Expand Down