// Wake scheduler — manages per-manager wake timers and signal evaluation // Supports 3 strategies: persistent, on-demand, smart (threshold-gated) import type { WakeStrategy, WakeContext, WakeProjectSnapshot, WakeTaskSummary } from '../types/wake'; import type { AgentId } from '../types/ids'; import { evaluateWakeSignals, shouldWake } from '../utils/wake-scorer'; import { getAllProjectHealth, getHealthAggregates } from './health.svelte'; import { getAllWorkItems } from './workspace.svelte'; import { listTasks } from '../adapters/bttask-bridge'; import { getAgentSession } from './agents.svelte'; import type { GroupId } from '../types/ids'; // --- Types --- interface ManagerRegistration { agentId: AgentId; groupId: GroupId; sessionId: string; strategy: WakeStrategy; intervalMs: number; threshold: number; timerId: ReturnType | null; /** Burn rate samples for anomaly detection: [timestamp, totalRate] */ burnRateSamples: Array<[number, number]>; } export interface WakeEvent { agentId: AgentId; strategy: WakeStrategy; context: WakeContext; /** For persistent: resume with context. For on-demand/smart: fresh session with context. */ mode: 'resume' | 'fresh'; } // --- State --- let registrations = $state>(new Map()); let pendingWakes = $state>(new Map()); // --- Public API --- /** Register a Manager agent for wake scheduling */ export function registerManager( agentId: AgentId, groupId: GroupId, sessionId: string, strategy: WakeStrategy, intervalMin: number, threshold: number, ): void { // Unregister first to clear any existing timer unregisterManager(agentId); const reg: ManagerRegistration = { agentId, groupId, sessionId, strategy, intervalMs: intervalMin * 60 * 1000, threshold, timerId: null, burnRateSamples: [], }; registrations.set(agentId, reg); startTimer(reg); } /** Unregister a Manager agent and stop its timer */ export function unregisterManager(agentId: string): void { const reg = registrations.get(agentId); if (reg?.timerId) { clearInterval(reg.timerId); } registrations.delete(agentId); pendingWakes.delete(agentId); } /** Update wake config for an already-registered manager */ export function updateManagerConfig( agentId: string, strategy: WakeStrategy, intervalMin: number, threshold: number, ): void { const reg = registrations.get(agentId); if (!reg) return; const needsRestart = reg.strategy !== strategy || reg.intervalMs !== intervalMin * 60 * 1000; reg.strategy = strategy; reg.intervalMs = intervalMin * 60 * 1000; reg.threshold = threshold; if (needsRestart) { if (reg.timerId) clearInterval(reg.timerId); startTimer(reg); } } /** Update session ID for a registered manager (e.g., after session reset) */ export function updateManagerSession(agentId: string, sessionId: string): void { const reg = registrations.get(agentId); if (reg) { reg.sessionId = sessionId; } } /** Get pending wake event for a manager (consumed by AgentSession) */ export function getWakeEvent(agentId: string): WakeEvent | undefined { return pendingWakes.get(agentId); } /** Consume (clear) a pending wake event after AgentSession handles it */ export function consumeWakeEvent(agentId: string): void { pendingWakes.delete(agentId); } /** Get all registered managers (for debugging/UI) */ export function getRegisteredManagers(): Array<{ agentId: string; strategy: WakeStrategy; intervalMin: number; threshold: number; hasPendingWake: boolean; }> { const result: Array<{ agentId: string; strategy: WakeStrategy; intervalMin: number; threshold: number; hasPendingWake: boolean; }> = []; for (const [id, reg] of registrations) { result.push({ agentId: id, strategy: reg.strategy, intervalMin: reg.intervalMs / 60_000, threshold: reg.threshold, hasPendingWake: pendingWakes.has(id), }); } return result; } /** Force a manual wake evaluation for a manager (for testing/UI) */ export function forceWake(agentId: string): void { const reg = registrations.get(agentId); if (reg) { evaluateAndEmit(reg); } } /** Clear all registrations (for workspace teardown) */ export function clearWakeScheduler(): void { for (const reg of registrations.values()) { if (reg.timerId) clearInterval(reg.timerId); } registrations = new Map(); pendingWakes = new Map(); } // --- Internal --- function startTimer(reg: ManagerRegistration): void { reg.timerId = setInterval(() => { evaluateAndEmit(reg); }, reg.intervalMs); } async function evaluateAndEmit(reg: ManagerRegistration): Promise { // Don't queue a new wake if one is already pending if (pendingWakes.has(reg.agentId)) return; // For persistent strategy, skip if session is actively running a query if (reg.strategy === 'persistent') { const session = getAgentSession(reg.sessionId); if (session && session.status === 'running') return; } // Build project snapshots from health store const healthItems = getAllProjectHealth(); const workItems = getAllWorkItems(); const projectSnapshots: WakeProjectSnapshot[] = healthItems.map(h => { const workItem = workItems.find(w => w.id === h.projectId); return { projectId: h.projectId, projectName: workItem?.name ?? String(h.projectId), activityState: h.activityState, idleMinutes: Math.floor(h.idleDurationMs / 60_000), burnRatePerHour: h.burnRatePerHour, contextPressurePercent: h.contextPressure !== null ? Math.round(h.contextPressure * 100) : null, fileConflicts: h.fileConflictCount + h.externalConflictCount, attentionScore: h.attentionScore, attentionReason: h.attentionReason, }; }); // Fetch task summary (best-effort) let taskSummary: WakeTaskSummary | undefined; try { const tasks = await listTasks(reg.groupId); taskSummary = { total: tasks.length, todo: tasks.filter(t => t.status === 'todo').length, inProgress: tasks.filter(t => t.status === 'progress').length, blocked: tasks.filter(t => t.status === 'blocked').length, review: tasks.filter(t => t.status === 'review').length, done: tasks.filter(t => t.status === 'done').length, }; } catch { // bttask may not be available — continue without task data } // Compute average burn rate for anomaly detection const aggregates = getHealthAggregates(); const now = Date.now(); reg.burnRateSamples.push([now, aggregates.totalBurnRatePerHour]); // Keep 1 hour of samples const hourAgo = now - 3_600_000; reg.burnRateSamples = reg.burnRateSamples.filter(([ts]) => ts > hourAgo); const averageBurnRate = reg.burnRateSamples.length > 1 ? reg.burnRateSamples.reduce((sum, [, r]) => sum + r, 0) / reg.burnRateSamples.length : undefined; // Evaluate signals const evaluation = evaluateWakeSignals({ projects: projectSnapshots, taskSummary, averageBurnRate, }); // Check if we should actually wake based on strategy if (!shouldWake(evaluation, reg.strategy, reg.threshold)) return; // Build wake context const context: WakeContext = { evaluation, projectSnapshots, taskSummary, }; // Determine mode const mode: 'resume' | 'fresh' = reg.strategy === 'persistent' ? 'resume' : 'fresh'; pendingWakes.set(reg.agentId, { agentId: reg.agentId, strategy: reg.strategy, context, mode, }); }