Idle TTL eviction per (chat,agent) + LRU cap (never a busy backend); pure lifecycle-decisions.ts (TDD). Crash recovery lifts openchamber's health-monitor + busy-aware-restart + stale-grace state machine into opencode-server.ts (+ port reclaim) and warm-acp.ts; opencode crash -> fresh sessions, ACP -> re-session/new. F.1 turn-guard + U.6 usage preserved (their tests pass). Orphan worktree reaper (1h grace, superset-style dirty/unpushed preflight, Paseo soft-delete) + close hooks + diff re-baseline after apply_pending. 35 new tests + DB-opt-in reconnect test; 215 coder tests pass; tsc + build clean. Completes v2.6. Follow-ups out of scope: apps/server close-hook caller, 3.7 DiffPanel staging hint, live smokes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
234 lines
7.9 KiB
TypeScript
234 lines
7.9 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { AgentPool, OPENCODE_POOL_KEY } from '../agent-pool.js';
|
|
import type {
|
|
AgentBackend,
|
|
AgentSessionHandle,
|
|
EnsureSessionOpts,
|
|
PromptCtx,
|
|
TurnResult,
|
|
} from '../agent-backend.js';
|
|
|
|
/**
|
|
* v2.6 Phase 3 — AgentPool lifecycle unit test (T.1). No DB / no child process:
|
|
* a fake AgentBackend records dispose + reports busy/health, so we exercise
|
|
* get-or-create, idle eviction, the LRU cap, the busy-never-evict rule, closeChat,
|
|
* and dispose-drains directly. The pure decisions are covered separately in
|
|
* backends/__tests__/lifecycle-decisions.test.ts; this verifies the wiring.
|
|
*/
|
|
|
|
class FakeBackend implements AgentBackend {
|
|
disposed = 0;
|
|
closedSessions = 0;
|
|
private busyFlag = false;
|
|
tickHealthCalls = 0;
|
|
|
|
constructor(public readonly name = 'fake') {}
|
|
|
|
setBusy(b: boolean): void {
|
|
this.busyFlag = b;
|
|
}
|
|
|
|
// — AgentBackend —
|
|
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
|
|
return {
|
|
sessionId,
|
|
agent: opts.agent,
|
|
backend: 'acp_warm',
|
|
chatId: opts.chatId,
|
|
worktreeId: opts.worktreeId,
|
|
agentSessionId: 'fake-session',
|
|
serverPort: null,
|
|
};
|
|
}
|
|
async prompt(_h: AgentSessionHandle, _input: string, _ctx: PromptCtx): Promise<TurnResult> {
|
|
return { ok: true };
|
|
}
|
|
async closeSession(): Promise<void> {
|
|
this.closedSessions++;
|
|
}
|
|
async dispose(): Promise<void> {
|
|
this.disposed++;
|
|
}
|
|
health(): 'up' | 'down' {
|
|
return 'up';
|
|
}
|
|
isBusy(): boolean {
|
|
return this.busyFlag;
|
|
}
|
|
async tickHealth(): Promise<void> {
|
|
this.tickHealthCalls++;
|
|
}
|
|
}
|
|
|
|
describe('AgentPool — get/register/touch (3.1)', () => {
|
|
it('register then get returns the same backend', () => {
|
|
const pool = new AgentPool();
|
|
const b = new FakeBackend();
|
|
pool.register('chat-1', 'goose', b);
|
|
expect(pool.get('chat-1', 'goose')).toBe(b);
|
|
expect(pool.get('chat-1', 'qwen')).toBeUndefined();
|
|
});
|
|
|
|
it('peek does NOT exist for a missing key', () => {
|
|
const pool = new AgentPool();
|
|
expect(pool.peek('nope', 'goose')).toBeUndefined();
|
|
});
|
|
|
|
it('health reports size + busy count', () => {
|
|
const pool = new AgentPool();
|
|
const a = new FakeBackend();
|
|
const b = new FakeBackend();
|
|
b.setBusy(true);
|
|
pool.register('c1', 'goose', a);
|
|
pool.register('c2', 'qwen', b);
|
|
expect(pool.health()).toEqual({ size: 2, busy: 1 });
|
|
});
|
|
});
|
|
|
|
describe('AgentPool.sweep — idle TTL eviction (3.1)', () => {
|
|
it('evicts an idle backend past the TTL and disposes it', async () => {
|
|
const pool = new AgentPool({ idleTtlMs: 1_000, maxLive: 100 });
|
|
const b = new FakeBackend();
|
|
pool.register('c1', 'goose', b);
|
|
// Sweep with now far past the registration → idle → evicted.
|
|
const { evicted } = await pool.sweep(Date.now() + 10_000);
|
|
expect(evicted).toEqual(['c1:goose']);
|
|
expect(b.disposed).toBe(1);
|
|
expect(pool.get('c1', 'goose')).toBeUndefined();
|
|
});
|
|
|
|
it('never evicts a busy backend even past the TTL', async () => {
|
|
const pool = new AgentPool({ idleTtlMs: 1_000, maxLive: 100 });
|
|
const b = new FakeBackend();
|
|
b.setBusy(true);
|
|
pool.register('c1', 'goose', b);
|
|
const { evicted } = await pool.sweep(Date.now() + 10_000);
|
|
expect(evicted).toEqual([]);
|
|
expect(b.disposed).toBe(0);
|
|
expect(pool.get('c1', 'goose')).toBe(b);
|
|
});
|
|
|
|
it('touch keeps a backend warm so the TTL measures from the last turn', async () => {
|
|
const pool = new AgentPool({ idleTtlMs: 5_000, maxLive: 100 });
|
|
const b = new FakeBackend();
|
|
pool.register('c1', 'goose', b);
|
|
const base = Date.now();
|
|
// 4s later, touch — resets activity. A sweep at +6s from base is only +2s from
|
|
// the touch → still within TTL → not evicted.
|
|
vi.spyOn(Date, 'now').mockReturnValue(base + 4_000);
|
|
pool.touch('c1', 'goose');
|
|
vi.restoreAllMocks();
|
|
const { evicted } = await pool.sweep(base + 6_000);
|
|
expect(evicted).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('AgentPool.sweep — LRU cap (3.4)', () => {
|
|
it('evicts the least-recently-used beyond the cap', async () => {
|
|
const pool = new AgentPool({ idleTtlMs: 1_000_000, maxLive: 2 });
|
|
const base = 1_000_000;
|
|
const mk = (key: string, regAt: number) => {
|
|
vi.spyOn(Date, 'now').mockReturnValue(regAt);
|
|
const b = new FakeBackend(key);
|
|
const [chat, agent] = key.split(':');
|
|
pool.register(chat!, agent!, b);
|
|
vi.restoreAllMocks();
|
|
return b;
|
|
};
|
|
const a = mk('c1:goose', base + 100);
|
|
const b = mk('c2:goose', base + 300);
|
|
const c = mk('c3:goose', base + 200);
|
|
// 3 entries, cap 2, all within idle TTL → LRU (oldest = a@+100) evicted.
|
|
const { evicted } = await pool.sweep(base + 1_000);
|
|
expect(evicted).toEqual(['c1:goose']);
|
|
expect(a.disposed).toBe(1);
|
|
expect(b.disposed).toBe(0);
|
|
expect(c.disposed).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('AgentPool.sweep — proactive health probe (3.2)', () => {
|
|
it('drives each backend tickHealth before eviction', async () => {
|
|
const pool = new AgentPool({ idleTtlMs: 1_000_000, maxLive: 100 });
|
|
const b = new FakeBackend();
|
|
pool.register('c1', 'opencode', b);
|
|
await pool.sweep(Date.now());
|
|
expect(b.tickHealthCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('AgentPool.closeChat — chat-close teardown (3.3)', () => {
|
|
it('disposes only the matching chat keys, leaving others + the shared server', async () => {
|
|
const pool = new AgentPool();
|
|
const goose = new FakeBackend('goose');
|
|
const qwen = new FakeBackend('qwen');
|
|
const other = new FakeBackend('other-chat');
|
|
const ocServer = new FakeBackend('opencode-server');
|
|
pool.register('chat-1', 'goose', goose);
|
|
pool.register('chat-1', 'qwen', qwen);
|
|
pool.register('chat-2', 'goose', other);
|
|
pool.register(OPENCODE_POOL_KEY, 'opencode', ocServer);
|
|
|
|
const removed = await pool.closeChat('chat-1');
|
|
expect(removed.sort()).toEqual(['chat-1:goose', 'chat-1:qwen']);
|
|
expect(goose.disposed).toBe(1);
|
|
expect(qwen.disposed).toBe(1);
|
|
// other chat + shared opencode server untouched.
|
|
expect(other.disposed).toBe(0);
|
|
expect(ocServer.disposed).toBe(0);
|
|
expect(pool.peek('chat-2', 'goose')).toBe(other);
|
|
expect(pool.peek(OPENCODE_POOL_KEY, 'opencode')).toBe(ocServer);
|
|
});
|
|
|
|
it('does not dispose a busy backend on closeChat', async () => {
|
|
const pool = new AgentPool();
|
|
const b = new FakeBackend();
|
|
b.setBusy(true);
|
|
pool.register('chat-1', 'goose', b);
|
|
const removed = await pool.closeChat('chat-1');
|
|
expect(removed).toEqual([]);
|
|
expect(b.disposed).toBe(0);
|
|
});
|
|
|
|
it('does not match a chat id that is a prefix of another', async () => {
|
|
// 'chat-1' must not match 'chat-10' — keys are `${chatId}:${agent}` so the
|
|
// colon delimiter prevents the prefix collision.
|
|
const pool = new AgentPool();
|
|
const a = new FakeBackend();
|
|
const b = new FakeBackend();
|
|
pool.register('chat-1', 'goose', a);
|
|
pool.register('chat-10', 'goose', b);
|
|
await pool.closeChat('chat-1');
|
|
expect(a.disposed).toBe(1);
|
|
expect(b.disposed).toBe(0);
|
|
expect(pool.peek('chat-10', 'goose')).toBe(b);
|
|
});
|
|
});
|
|
|
|
describe('AgentPool.dispose — drain all (T.1)', () => {
|
|
it('disposes every backend and clears the map', async () => {
|
|
const pool = new AgentPool();
|
|
const a = new FakeBackend();
|
|
const b = new FakeBackend();
|
|
pool.register('c1', 'goose', a);
|
|
pool.register('c2', 'qwen', b);
|
|
await pool.dispose();
|
|
expect(a.disposed).toBe(1);
|
|
expect(b.disposed).toBe(1);
|
|
expect(pool.health()).toEqual({ size: 0, busy: 0 });
|
|
});
|
|
|
|
it('tolerates a backend whose dispose throws', async () => {
|
|
const pool = new AgentPool();
|
|
const good = new FakeBackend();
|
|
const bad = new FakeBackend();
|
|
bad.dispose = async () => {
|
|
throw new Error('boom');
|
|
};
|
|
pool.register('c1', 'goose', bad);
|
|
pool.register('c2', 'qwen', good);
|
|
await expect(pool.dispose()).resolves.toBeUndefined();
|
|
expect(good.disposed).toBe(1);
|
|
});
|
|
});
|