idle-window.ts
1 import { listSessions } from '@/lib/server/sessions/session-repository' 2 import type { Session } from '@/types' 3 import { log } from '@/lib/server/logger' 4 5 const TAG = 'idle-window' 6 7 const DEFAULT_IDLE_THRESHOLD_MS = 120_000 // 2 minutes 8 const DAILY_GUARANTEE_MS = 24 * 60 * 60 * 1000 9 10 type IdleCallback = () => void | Promise<void> 11 12 interface IdleWindowState { 13 callbacks: IdleCallback[] 14 lastDrainedAt: number 15 } 16 17 const state: IdleWindowState = { 18 callbacks: [], 19 lastDrainedAt: 0, 20 } 21 22 /** 23 * Returns true when no user activity is detected recently 24 * and no runs are currently executing. 25 */ 26 export async function isIdleWindow(options?: { thresholdMs?: number }): Promise<boolean> { 27 const threshold = options?.thresholdMs ?? DEFAULT_IDLE_THRESHOLD_MS 28 const now = Date.now() 29 const sessions = listSessions() 30 31 for (const session of Object.values(sessions) as unknown as Session[]) { 32 if (!session?.id) continue 33 const lastActive = session.lastActiveAt || 0 34 if (lastActive > 0 && now - lastActive < threshold) return false 35 } 36 37 // Check for running runs via the session-run-manager (lazy import to avoid circular deps) 38 try { 39 const { getSessionExecutionState } = await import('@/lib/server/runtime/session-run-manager') 40 for (const session of Object.values(sessions) as unknown as Session[]) { 41 if (!session?.id) continue 42 const exec = getSessionExecutionState(session.id) as { runningRunId?: string } 43 if (exec?.runningRunId) return false 44 } 45 } catch { 46 // If session-run-manager isn't available, skip this check 47 } 48 49 return true 50 } 51 52 /** 53 * Register a callback to run during the next idle window. 54 * If no idle window occurs within 24h, the callback runs anyway (daily guarantee). 55 */ 56 export function onNextIdleWindow(callback: IdleCallback): void { 57 state.callbacks.push(callback) 58 } 59 60 /** 61 * Called from the daemon health check interval. 62 * Drains queued callbacks when the system is idle, 63 * or forces drain if the daily guarantee has elapsed. 64 */ 65 export async function drainIdleWindowCallbacks(): Promise<void> { 66 if (state.callbacks.length === 0) return 67 68 const now = Date.now() 69 const forceDrain = now - state.lastDrainedAt >= DAILY_GUARANTEE_MS 70 if (!forceDrain && !(await isIdleWindow())) return 71 72 const batch = state.callbacks.splice(0) 73 state.lastDrainedAt = now 74 75 for (const cb of batch) { 76 try { 77 await cb() 78 } catch (err) { 79 log.warn(TAG, 'Callback failed:', err instanceof Error ? err.message : String(err)) 80 } 81 } 82 } 83 84 /** Returns the number of pending callbacks (for diagnostics). */ 85 export function pendingIdleCallbackCount(): number { 86 return state.callbacks.length 87 }