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>
177 lines
6.6 KiB
TypeScript
177 lines
6.6 KiB
TypeScript
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']);
|
|
});
|
|
});
|