import { describe, it, expect } from 'vitest'; import { selectIdleEvictionTargets, selectLruEvictionTargets, decideRestart, selectOrphanWorktreeTargets, DEFAULT_IDLE_TTL_MS, DEFAULT_MAX_LIVE_BACKENDS, type PoolEntrySnapshot, } from '../lifecycle-decisions.js'; /** * v2.6 Phase 3 — pure lifecycle decisions. No DB, no children, no timers; `now` * is injected. Models prune.ts:selectPruneTargets — the caller acts on the keys. */ const NOW = 1_000_000_000_000; function entry(key: string, ageMs: number, busy = false): PoolEntrySnapshot { return { key, lastActiveAt: NOW - ageMs, busy }; } describe('selectIdleEvictionTargets (3.1)', () => { it('evicts entries idle past the TTL', () => { const entries = [ entry('a:opencode', DEFAULT_IDLE_TTL_MS + 1), entry('b:goose', DEFAULT_IDLE_TTL_MS - 1), ]; expect(selectIdleEvictionTargets(entries, NOW)).toEqual(['a:opencode']); }); it('never evicts a busy entry even when idle past the TTL', () => { const entries = [entry('a:opencode', DEFAULT_IDLE_TTL_MS * 10, /* busy */ true)]; expect(selectIdleEvictionTargets(entries, NOW)).toEqual([]); }); it('respects a custom TTL', () => { const entries = [entry('a:goose', 5_000), entry('b:qwen', 500)]; expect(selectIdleEvictionTargets(entries, NOW, 1_000)).toEqual(['a:goose']); }); it('treats exactly-at-TTL as evictable (>=)', () => { expect(selectIdleEvictionTargets([entry('a:x', 1_000)], NOW, 1_000)).toEqual(['a:x']); }); it('returns empty for an empty pool', () => { expect(selectIdleEvictionTargets([], NOW)).toEqual([]); }); }); describe('selectLruEvictionTargets (3.4)', () => { it('returns nothing when at or under the cap', () => { const entries = [entry('a:x', 10), entry('b:y', 20)]; expect(selectLruEvictionTargets(entries, 2)).toEqual([]); expect(selectLruEvictionTargets(entries, 5)).toEqual([]); }); it('evicts the least-recently-used beyond the cap', () => { // oldest first: c (300ms ago) is LRU, then a (100ms), then b (10ms). const entries = [entry('a:x', 100), entry('b:y', 10), entry('c:z', 300)]; expect(selectLruEvictionTargets(entries, 2)).toEqual(['c:z']); }); it('evicts multiple LRU entries to reach the cap', () => { const entries = [ entry('a:x', 100), entry('b:y', 10), entry('c:z', 300), entry('d:w', 200), ]; // cap 1: must remove 3, oldest-first c(300), d(200), a(100). expect(selectLruEvictionTargets(entries, 1)).toEqual(['c:z', 'd:w', 'a:x']); }); it('never evicts a busy entry even if it is the LRU', () => { // c is LRU but busy → it cannot be evicted; fall to the next-oldest (a). const entries = [entry('a:x', 100), entry('b:y', 10), entry('c:z', 300, true)]; expect(selectLruEvictionTargets(entries, 2)).toEqual(['a:x']); }); it('can transiently exceed the cap when too many are busy', () => { // cap 1, but both old entries busy → only the single idle one is evictable. const entries = [entry('a:x', 100, true), entry('c:z', 300, true), entry('b:y', 10)]; expect(selectLruEvictionTargets(entries, 1)).toEqual(['b:y']); }); it('uses the default cap when omitted', () => { const entries = Array.from({ length: DEFAULT_MAX_LIVE_BACKENDS + 1 }, (_, i) => entry(`k${String(i).padStart(2, '0')}:a`, (i + 1) * 1000), ); const evicted = selectLruEvictionTargets(entries); // exactly one over the default cap → evict the single LRU (largest age). expect(evicted).toHaveLength(1); expect(evicted[0]).toBe(`k${String(DEFAULT_MAX_LIVE_BACKENDS).padStart(2, '0')}:a`); }); }); describe('decideRestart (3.2, busy-aware)', () => { const base = { consecutiveFailures: 0, busy: false, unhealthyBusySince: 0, now: NOW, failureThreshold: 3, staleBusyGraceMs: 120_000, }; it('does nothing when healthy', () => { expect(decideRestart({ ...base, processExited: false, healthy: true })) .toEqual({ action: 'none', reason: 'healthy' }); }); it('restarts immediately when the process exited', () => { expect(decideRestart({ ...base, processExited: true, busy: true })) .toEqual({ action: 'restart', reason: 'process-exited' }); }); it('waits below the failure threshold', () => { expect(decideRestart({ ...base, processExited: false, consecutiveFailures: 2 })) .toEqual({ action: 'wait', reason: 'below-threshold' }); }); it('restarts at the threshold when idle', () => { expect(decideRestart({ ...base, processExited: false, consecutiveFailures: 3 })) .toEqual({ action: 'restart', reason: 'threshold' }); }); it('defers a restart while busy within the grace window', () => { expect(decideRestart({ ...base, processExited: false, consecutiveFailures: 5, busy: true, unhealthyBusySince: NOW - 1_000, })).toEqual({ action: 'wait', reason: 'busy-grace' }); }); it('force-restarts a busy backend after the stale-busy grace', () => { expect(decideRestart({ ...base, processExited: false, consecutiveFailures: 5, busy: true, unhealthyBusySince: NOW - 120_001, })).toEqual({ action: 'restart', reason: 'stale-busy-grace' }); }); it('waits (busy-grace) when busy + threshold but the window just started', () => { // unhealthyBusySince === 0 means the caller is about to stamp it this cycle. expect(decideRestart({ ...base, processExited: false, consecutiveFailures: 5, busy: true, unhealthyBusySince: 0, })).toEqual({ action: 'wait', reason: 'busy-grace' }); }); }); describe('selectOrphanWorktreeTargets (3.4)', () => { it('skips dirs tracked by a live worktrees row', () => { const onDisk = [{ path: '/wt/sess-a', mtimeMs: NOW - 10_000_000 }]; expect(selectOrphanWorktreeTargets(onDisk, new Set(['/wt/sess-a']), NOW, 1000)).toEqual([]); }); it('reaps an untracked dir older than the grace', () => { const onDisk = [{ path: '/wt/sess-orphan', mtimeMs: NOW - 5000 }]; expect(selectOrphanWorktreeTargets(onDisk, new Set(), NOW, 1000)).toEqual(['/wt/sess-orphan']); }); it('never reaps a dir younger than the grace (mid-create race)', () => { const onDisk = [{ path: '/wt/sess-fresh', mtimeMs: NOW - 500 }]; expect(selectOrphanWorktreeTargets(onDisk, new Set(), NOW, 1000)).toEqual([]); }); it('mixes tracked, fresh, and orphaned correctly', () => { const onDisk = [ { path: '/wt/sess-live', mtimeMs: NOW - 10_000 }, { path: '/wt/sess-fresh', mtimeMs: NOW - 100 }, { path: '/wt/sess-orphan', mtimeMs: NOW - 10_000 }, ]; expect(selectOrphanWorktreeTargets(onDisk, new Set(['/wt/sess-live']), NOW, 1000)) .toEqual(['/wt/sess-orphan']); }); });