Loading files
15ae7b724This file is pinned to the selected release. Download the original to open it in your design tool.
export type OneShotEngine = 'claude' | 'codex' | 'cursor' | 'opencode' | 'pi' | 'commandcode' | 'kilo'
const ONE_SHOT_ENGINES: readonly OneShotEngine[] = ['claude', 'codex', 'cursor', 'opencode', 'pi', 'commandcode', 'kilo']
export interface DisposableWorker<Options, Result> {
readonly engine: OneShotEngine
readonly createdAt: number
isAlive(): boolean
run(options: Options): Promise<Result>
dispose(): void
onExit(listener: () => void): void
}
export type ActiveEngineCounts = Record<OneShotEngine, number>
export function poolTargetForActiveSessions(active: number): number {
if (!Number.isFinite(active) || active <= 0) return 0
return Math.max(1, Math.ceil(active / 3))
}
interface EngineState<Options, Result> {
active: number
target: number
starting: number
ready: Set<DisposableWorker<Options, Result>>
retryTimer: NodeJS.Timeout | null
/** Consecutive warm-spawn failures. Drives the backoff and silences the log. */
failures: number
}
/**
* Warm-spawn retry backoff.
*
* A flat one-second retry is fine for a worker that crashed and will come back.
* It is a disaster for a condition that never changes on its own — a missing or
* unresolvable binary — because nothing in the loop ever notices it is losing.
* MEASURED before this existed: one engine whose CLI could not be spawned
* produced 69,341 log lines in ten hours, one process fork per second, forever.
*/
const RETRY_BASE_MS = 1_000
const RETRY_CAP_MS = 60_000
/** The attempt whose delay first reaches the cap — the last one that logs. */
const CAP_ANNOUNCE_AT = 7
export interface PoolSnapshot {
enabled: boolean
connected: boolean
claude: { active: number; target: number; starting: number; ready: number }
codex: { active: number; target: number; starting: number; ready: number }
cursor: { active: number; target: number; starting: number; ready: number }
opencode: { active: number; target: number; starting: number; ready: number }
pi: { active: number; target: number; starting: number; ready: number }
commandcode: { active: number; target: number; starting: number; ready: number }
kilo: { active: number; target: number; starting: number; ready: number }
}
/** Maintains disposable, pre-spawned workers. A checked-out worker is never returned to the pool. */
export class DisposableOneShotPool<Options, Result> {
private readonly states: Record<OneShotEngine, EngineState<Options, Result>> = {
claude: { active: 0, target: 0, starting: 0, ready: new Set(), retryTimer: null, failures: 0 },
codex: { active: 0, target: 0, starting: 0, ready: new Set(), retryTimer: null, failures: 0 },
cursor: { active: 0, target: 0, starting: 0, ready: new Set(), retryTimer: null, failures: 0 },
opencode: { active: 0, target: 0, starting: 0, ready: new Set(), retryTimer: null, failures: 0 },
pi: { active: 0, target: 0, starting: 0, ready: new Set(), retryTimer: null, failures: 0 },
commandcode: { active: 0, target: 0, starting: 0, ready: new Set(), retryTimer: null, failures: 0 },
kilo: { active: 0, target: 0, starting: 0, ready: new Set(), retryTimer: null, failures: 0 },
}
private readonly all = new Set<DisposableWorker<Options, Result>>()
private connected = false
private enabled = false
private closed = false
private generation = 0
private unwarmTimer: NodeJS.Timeout | null = null
private sweepTimer: NodeJS.Timeout | null = null
constructor(
private readonly factory: (engine: OneShotEngine) => Promise<DisposableWorker<Options, Result>>,
private readonly graceMs = 5 * 60_000,
private readonly log: (line: string) => void = (line) => console.log(line),
/** How long a warm worker may sit unused before it is replaced. A pooled worker is a real
* process parked on its stdin: with no ceiling it survives for as long as the adapter does.
* One was observed being handed out after 84 minutes, and another idling for 94 — a leak, and
* a stale model session for whoever eventually got it. `run()`'s timeout cannot help here; it
* only arms once a worker is assigned work. */
private readonly maxIdleMs = 10 * 60_000,
) {}
setActiveCounts(counts: ActiveEngineCounts): void {
for (const engine of ONE_SHOT_ENGINES) {
this.states[engine].active = Math.max(0, Math.floor(counts[engine] || 0))
}
this.reconcileAll()
}
setDeviceConnected(connected: boolean): void {
if (this.closed) return
this.connected = connected
if (this.unwarmTimer) {
clearTimeout(this.unwarmTimer)
this.unwarmTimer = null
}
if (connected) {
this.enabled = true
this.startSweep()
this.reconcileAll()
return
}
if (!this.enabled) return
this.unwarmTimer = setTimeout(() => {
this.unwarmTimer = null
if (this.connected || this.closed) return
this.enabled = false
this.reconcileAll()
this.log('[recap-pool] device grace expired · unwarmed')
}, this.graceMs)
}
async run(engine: OneShotEngine, options: Options): Promise<Result> {
const state = this.states[engine]
let worker: DisposableWorker<Options, Result> | undefined
for (const candidate of state.ready) {
state.ready.delete(candidate)
if (candidate.isAlive() && !this.expired(candidate)) { worker = candidate; break }
if (candidate.isAlive()) this.log(`[recap-pool] ${engine} dropped a stale warm worker · age=${Date.now() - candidate.createdAt}ms`)
candidate.dispose()
this.all.delete(candidate)
}
const hit = !!worker
// The direct worker used by a miss is busy, not pool capacity. Refill the ready target now so a
// concurrent recap can still hit a warm worker instead of waiting for this one to finish.
this.reconcile(engine)
const selected = worker ?? await this.createWorker(engine)
const age = Date.now() - selected.createdAt
this.log(`[recap-pool] ${engine} ${hit ? 'hit' : 'miss'} · workerAge=${age}ms · ready=${state.ready.size}/${state.target}`)
try {
return await selected.run(options)
} finally {
selected.dispose()
this.all.delete(selected)
this.reconcile(engine)
}
}
/** Drop idle workers after a runtime config/model change; in-flight work remains isolated. */
recycleReady(): void {
this.generation++
for (const engine of ONE_SHOT_ENGINES) {
const state = this.states[engine]
for (const worker of state.ready) worker.dispose()
state.ready.clear()
}
this.reconcileAll()
}
shutdown(): void {
if (this.closed) return
this.closed = true
this.enabled = false
if (this.unwarmTimer) clearTimeout(this.unwarmTimer)
this.unwarmTimer = null
if (this.sweepTimer) clearInterval(this.sweepTimer)
this.sweepTimer = null
for (const worker of this.all) worker.dispose()
this.all.clear()
for (const engine of ONE_SHOT_ENGINES) {
if (this.states[engine].retryTimer) clearTimeout(this.states[engine].retryTimer)
this.states[engine].retryTimer = null
this.states[engine].ready.clear()
this.states[engine].target = 0
}
}
snapshot(): PoolSnapshot {
const item = (engine: OneShotEngine) => {
const s = this.states[engine]
return { active: s.active, target: s.target, starting: s.starting, ready: s.ready.size }
}
return {
enabled: this.enabled,
connected: this.connected,
claude: item('claude'),
codex: item('codex'),
cursor: item('cursor'),
opencode: item('opencode'),
pi: item('pi'),
commandcode: item('commandcode'),
kilo: item('kilo'),
}
}
private expired(worker: DisposableWorker<Options, Result>): boolean {
return this.maxIdleMs > 0 && Date.now() - worker.createdAt >= this.maxIdleMs
}
/** Retire warm workers that have aged out. Runs on its own timer, because a pool nobody asks for
* a recap from is exactly the case where they would otherwise sit forever. */
private sweepExpired(): void {
for (const engine of ONE_SHOT_ENGINES) {
const state = this.states[engine]
for (const worker of [...state.ready]) {
if (!this.expired(worker)) continue
state.ready.delete(worker)
worker.dispose()
this.all.delete(worker)
this.log(`[recap-pool] ${engine} retired an idle warm worker · age=${Date.now() - worker.createdAt}ms`)
}
}
this.reconcileAll()
}
private startSweep(): void {
if (this.sweepTimer || this.closed || this.maxIdleMs <= 0) return
this.sweepTimer = setInterval(() => this.sweepExpired(), Math.max(1_000, Math.floor(this.maxIdleMs / 4)))
// Never a reason to hold the process open for this.
this.sweepTimer.unref?.()
}
private reconcileAll(): void {
for (const engine of ONE_SHOT_ENGINES) this.reconcile(engine)
}
private reconcile(engine: OneShotEngine): void {
if (this.closed) return
const state = this.states[engine]
const previousTarget = state.target
state.target = this.enabled ? poolTargetForActiveSessions(state.active) : 0
if (state.target !== previousTarget) {
this.log(`[recap-pool] ${engine} target ${previousTarget}→${state.target} · active=${state.active}`)
}
if (state.target === 0 && state.retryTimer) {
clearTimeout(state.retryTimer)
state.retryTimer = null
}
// Going idle ends the streak. The next session of this engine deserves a
// fast first attempt — the thing that was broken may well have been fixed
// in between, and it is the only moment we get to find out cheaply.
if (state.target === 0) state.failures = 0
while (state.ready.size > state.target) {
const worker = state.ready.values().next().value as DisposableWorker<Options, Result> | undefined
if (!worker) break
state.ready.delete(worker)
worker.dispose()
this.all.delete(worker)
}
while (!state.retryTimer && state.ready.size + state.starting < state.target) this.spawnPooled(engine)
}
private spawnPooled(engine: OneShotEngine): void {
const state = this.states[engine]
const generation = this.generation
state.starting++
void this.createWorker(engine)
.then((worker) => {
state.starting--
if (generation !== this.generation || this.closed || !this.enabled || state.ready.size >= state.target || !worker.isAlive()) {
worker.dispose()
this.all.delete(worker)
if (!this.closed && this.enabled && state.ready.size < state.target) this.scheduleRetry(engine)
} else {
state.ready.add(worker)
state.failures = 0
this.log(`[recap-pool] ${engine} warm ready=${state.ready.size}/${state.target}`)
this.reconcile(engine)
}
})
.catch((err) => {
state.starting--
state.failures++
const delay = this.retryDelay(state)
// Every attempt while the backoff is still growing, then ONCE at the
// ceiling. A failure that cannot fix itself should say so and go quiet,
// not narrate the same sentence every second until the log is 9MB.
if (delay < RETRY_CAP_MS || state.failures === CAP_ANNOUNCE_AT) {
this.log(
`[recap-pool] ${engine} warm spawn failed (${state.failures}): ${err instanceof Error ? err.message : String(err)}`
+ (delay >= RETRY_CAP_MS ? ` · backing off to ${RETRY_CAP_MS / 1000}s and staying quiet` : ` · retry in ${delay}ms`),
)
}
this.scheduleRetry(engine)
})
}
private async createWorker(engine: OneShotEngine): Promise<DisposableWorker<Options, Result>> {
const worker = await this.factory(engine)
this.all.add(worker)
worker.onExit(() => {
this.all.delete(worker)
const wasReady = this.states[engine].ready.delete(worker)
if (wasReady) this.scheduleRetry(engine)
})
if (this.closed) {
worker.dispose()
this.all.delete(worker)
throw new Error('recap pool shut down while worker was starting')
}
return worker
}
private scheduleRetry(engine: OneShotEngine): void {
const state = this.states[engine]
if (this.closed || !this.enabled || state.target === 0 || state.retryTimer) return
state.retryTimer = setTimeout(() => {
state.retryTimer = null
this.reconcile(engine)
}, this.retryDelay(state))
}
/** 1s, 2s, 4s … capped at a minute. Doubling is what makes a permanent failure cost nothing. */
private retryDelay(state: EngineState<Options, Result>): number {
const shift = Math.min(Math.max(state.failures - 1, 0), 6)
return Math.min(RETRY_BASE_MS * 2 ** shift, RETRY_CAP_MS)
}
}