diff --git a/v2/src/lib/components/Workspace/AgentSession.svelte b/v2/src/lib/components/Workspace/AgentSession.svelte index 9c26677..94d518e 100644 --- a/v2/src/lib/components/Workspace/AgentSession.svelte +++ b/v2/src/lib/components/Workspace/AgentSession.svelte @@ -22,6 +22,7 @@ import type { AgentMessage } from '../../adapters/claude-messages'; import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte'; import { loadAnchorsForProject } from '../../stores/anchors.svelte'; + import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte'; import { SessionId, ProjectId } from '../../types/ids'; import AgentPane from '../Agent/AgentPane.svelte'; @@ -87,6 +88,46 @@ startReinjectionTimer(); onDestroy(() => { if (reinjectionTimer) clearInterval(reinjectionTimer); + if (wakeCheckTimer) clearInterval(wakeCheckTimer); + }); + + // Wake scheduler integration — poll for wake events (Manager agents only) + let wakeCheckTimer: ReturnType | null = null; + const isManager = $derived(project.isAgent && project.agentRole === 'manager'); + + function startWakeCheck() { + if (wakeCheckTimer) clearInterval(wakeCheckTimer); + if (!isManager) return; + wakeCheckTimer = setInterval(() => { + if (contextRefreshPrompt) return; // Don't queue if already has a pending prompt + const event = getWakeEvent(project.id); + if (!event) return; + + if (event.mode === 'fresh') { + // On-demand / Smart: reset session, inject wake context as initial prompt + handleNewSession(); + contextRefreshPrompt = buildWakePrompt(event.context.evaluation.summary); + } else { + // Persistent: resume existing session with wake context + contextRefreshPrompt = buildWakePrompt(event.context.evaluation.summary); + } + + consumeWakeEvent(project.id); + }, 5_000); // Check every 5s + } + + function buildWakePrompt(summary: string): string { + return `[Auto-Wake] You have been woken by the auto-wake scheduler. Here is the current fleet status:\n\n${summary}\n\nCheck your inbox with \`btmsg inbox\` and review the task board with \`bttask board\`. Take action on any urgent items above.`; + } + + // Start wake check when component mounts (for managers) + $effect(() => { + if (isManager) { + startWakeCheck(); + } else if (wakeCheckTimer) { + clearInterval(wakeCheckTimer); + wakeCheckTimer = null; + } }); let sessionId = $state(SessionId(crypto.randomUUID())); @@ -102,6 +143,8 @@ contextRefreshPrompt = undefined; registerSessionProject(sessionId, ProjectId(project.id), providerId); trackProject(ProjectId(project.id), sessionId); + // Notify wake scheduler of new session ID + if (isManager) updateManagerSession(project.id, sessionId); onsessionid?.(sessionId); } diff --git a/v2/src/lib/components/Workspace/ProjectBox.svelte b/v2/src/lib/components/Workspace/ProjectBox.svelte index b0afd19..1cf321b 100644 --- a/v2/src/lib/components/Workspace/ProjectBox.svelte +++ b/v2/src/lib/components/Workspace/ProjectBox.svelte @@ -19,8 +19,9 @@ import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte'; import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge'; import { recordExternalWrite } from '../../stores/conflicts.svelte'; - import { ProjectId } from '../../types/ids'; + import { ProjectId, type AgentId, type GroupId } from '../../types/ids'; import { notify, dismissNotification } from '../../stores/notifications.svelte'; + import { registerManager, unregisterManager, updateManagerConfig } from '../../stores/wake-scheduler.svelte'; interface Props { project: ProjectConfig; @@ -66,6 +67,32 @@ setStallThreshold(project.id, project.stallThresholdMin ?? null); }); + // Register Manager agents with the wake scheduler + $effect(() => { + if (!(project.isAgent && project.agentRole === 'manager')) return; + const groupId = activeGroup?.id; + if (!groupId || !mainSessionId) return; + + // Find the agent config to get wake settings + const agentConfig = activeGroup?.agents?.find(a => a.id === project.id); + const strategy = agentConfig?.wakeStrategy ?? 'smart'; + const intervalMin = agentConfig?.wakeIntervalMin ?? 3; + const threshold = agentConfig?.wakeThreshold ?? 0.5; + + registerManager( + project.id as unknown as AgentId, + groupId as unknown as GroupId, + mainSessionId, + strategy, + intervalMin, + threshold, + ); + + return () => { + unregisterManager(project.id); + }; + }); + // S-1 Phase 2: start filesystem watcher for this project's CWD $effect(() => { const cwd = project.cwd; diff --git a/v2/src/lib/components/Workspace/SettingsTab.svelte b/v2/src/lib/components/Workspace/SettingsTab.svelte index dba7400..c63487f 100644 --- a/v2/src/lib/components/Workspace/SettingsTab.svelte +++ b/v2/src/lib/components/Workspace/SettingsTab.svelte @@ -24,6 +24,7 @@ import { getProviders } from '../../providers/registry.svelte'; import type { ProviderId, ProviderSettings } from '../../providers/types'; import { ANCHOR_BUDGET_SCALES, ANCHOR_BUDGET_SCALE_LABELS, type AnchorBudgetScale } from '../../types/anchors'; + import { WAKE_STRATEGIES, WAKE_STRATEGY_LABELS, WAKE_STRATEGY_DESCRIPTIONS, type WakeStrategy } from '../../types/wake'; const PROJECT_ICONS = [ '📁', '🚀', '🤖', '🌐', '🔧', '🎮', '📱', '💻', @@ -704,6 +705,45 @@ {agent.wakeIntervalMin ?? 3} min + +
+ + + Wake Strategy + +
+ {#each WAKE_STRATEGIES as strat} + + {/each} +
+ {WAKE_STRATEGY_DESCRIPTIONS[agent.wakeStrategy ?? 'smart']} +
+ + {#if (agent.wakeStrategy ?? 'smart') === 'smart'} +
+ + + Wake Threshold + +
+ updateAgent(activeGroupId, agent.id, { wakeThreshold: parseFloat((e.target as HTMLInputElement).value) })} + /> + {((agent.wakeThreshold ?? 0.5) * 100).toFixed(0)}% +
+ Only wakes when signal score exceeds this level +
+ {/if} {/if}
@@ -1428,6 +1468,41 @@ min-width: 5.5em; } + .wake-strategy-row { + display: flex; + gap: 0; + border-radius: 0.25rem; + overflow: hidden; + border: 1px solid var(--ctp-surface1); + } + + .strategy-btn { + flex: 1; + padding: 0.25rem 0.5rem; + border: none; + background: var(--ctp-surface0); + color: var(--ctp-overlay1); + font-size: 0.7rem; + font-weight: 500; + cursor: pointer; + transition: background 0.12s, color 0.12s; + } + + .strategy-btn:not(:last-child) { + border-right: 1px solid var(--ctp-surface1); + } + + .strategy-btn:hover { + background: var(--ctp-surface1); + color: var(--ctp-subtext1); + } + + .strategy-btn.active { + background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0)); + color: var(--ctp-blue); + font-weight: 600; + } + /* CWD input: left-ellipsis */ .cwd-input { direction: rtl; diff --git a/v2/src/lib/stores/wake-scheduler.svelte.ts b/v2/src/lib/stores/wake-scheduler.svelte.ts new file mode 100644 index 0000000..e03e0b2 --- /dev/null +++ b/v2/src/lib/stores/wake-scheduler.svelte.ts @@ -0,0 +1,251 @@ +// 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, + }); +} diff --git a/v2/src/lib/stores/workspace.svelte.ts b/v2/src/lib/stores/workspace.svelte.ts index 6cb10a6..8ce65bd 100644 --- a/v2/src/lib/stores/workspace.svelte.ts +++ b/v2/src/lib/stores/workspace.svelte.ts @@ -4,6 +4,7 @@ import { agentToProject } from '../types/groups'; import { clearAllAgentSessions } from '../stores/agents.svelte'; import { clearHealthTracking } from '../stores/health.svelte'; import { clearAllConflicts } from '../stores/conflicts.svelte'; +import { clearWakeScheduler } from '../stores/wake-scheduler.svelte'; import { waitForPendingPersistence } from '../agent-dispatcher'; export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms'; @@ -91,11 +92,12 @@ export async function switchGroup(groupId: string): Promise { // Wait for any in-flight persistence before clearing state await waitForPendingPersistence(); - // Teardown: clear terminal tabs, agent sessions, and health tracking for the old group + // Teardown: clear terminal tabs, agent sessions, health tracking, and wake schedulers for the old group projectTerminals = {}; clearAllAgentSessions(); clearHealthTracking(); clearAllConflicts(); + clearWakeScheduler(); activeGroupId = groupId; activeProjectId = null; diff --git a/v2/src/lib/types/groups.ts b/v2/src/lib/types/groups.ts index efe7988..9cb387c 100644 --- a/v2/src/lib/types/groups.ts +++ b/v2/src/lib/types/groups.ts @@ -1,5 +1,6 @@ import type { ProviderId } from '../providers/types'; import type { AnchorBudgetScale } from './anchors'; +import type { WakeStrategy } from './wake'; import type { ProjectId, GroupId, AgentId } from './ids'; export interface ProjectConfig { @@ -69,6 +70,10 @@ export interface GroupAgentConfig { enabled: boolean; /** Auto-wake interval in minutes (Manager only, default 3) */ wakeIntervalMin?: number; + /** Wake strategy: persistent (always-on), on-demand (fresh session), smart (threshold-gated) */ + wakeStrategy?: WakeStrategy; + /** Wake threshold 0..1 for smart strategy (default 0.5) */ + wakeThreshold?: number; } export interface GroupConfig { diff --git a/v2/src/lib/types/wake.ts b/v2/src/lib/types/wake.ts new file mode 100644 index 0000000..7898561 --- /dev/null +++ b/v2/src/lib/types/wake.ts @@ -0,0 +1,70 @@ +import type { ProjectId as ProjectIdType } from './ids'; + +/** How the Manager agent session is managed between wake events */ +export type WakeStrategy = 'persistent' | 'on-demand' | 'smart'; + +export const WAKE_STRATEGIES: WakeStrategy[] = ['persistent', 'on-demand', 'smart']; + +export const WAKE_STRATEGY_LABELS: Record = { + persistent: 'Persistent', + 'on-demand': 'On-demand', + smart: 'Smart', +}; + +export const WAKE_STRATEGY_DESCRIPTIONS: Record = { + persistent: 'Manager stays running, receives periodic context refreshes', + 'on-demand': 'Manager wakes on every interval, gets fresh context each time', + smart: 'Manager only wakes when signal score exceeds threshold', +}; + +/** Individual wake signal with score and description */ +export interface WakeSignal { + id: string; + score: number; // 0..1 + reason: string; +} + +/** Aggregated wake evaluation result */ +export interface WakeEvaluation { + /** Total score (max of individual signals, not sum) */ + score: number; + /** All triggered signals sorted by score descending */ + signals: WakeSignal[]; + /** Whether the wake should fire (always true for persistent/on-demand, threshold-gated for smart) */ + shouldWake: boolean; + /** Human-readable summary for the Manager prompt */ + summary: string; +} + +/** Context passed to the Manager when waking */ +export interface WakeContext { + /** Wake evaluation that triggered this event */ + evaluation: WakeEvaluation; + /** Per-project health snapshot */ + projectSnapshots: WakeProjectSnapshot[]; + /** Task board summary (if available) */ + taskSummary?: WakeTaskSummary; +} + +/** Per-project health snapshot included in wake context */ +export interface WakeProjectSnapshot { + projectId: ProjectIdType; + projectName: string; + activityState: string; + idleMinutes: number; + burnRatePerHour: number; + contextPressurePercent: number | null; + fileConflicts: number; + attentionScore: number; + attentionReason: string | null; +} + +/** Task board summary included in wake context */ +export interface WakeTaskSummary { + total: number; + todo: number; + inProgress: number; + blocked: number; + review: number; + done: number; +} diff --git a/v2/src/lib/utils/wake-scorer.test.ts b/v2/src/lib/utils/wake-scorer.test.ts new file mode 100644 index 0000000..15640cc --- /dev/null +++ b/v2/src/lib/utils/wake-scorer.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect } from 'vitest'; +import { evaluateWakeSignals, shouldWake, type WakeScorerInput } from './wake-scorer'; +import type { WakeProjectSnapshot, WakeTaskSummary } from '../types/wake'; + +function makeProject(overrides: Partial = {}): WakeProjectSnapshot { + return { + projectId: 'proj-1' as any, + projectName: 'TestProject', + activityState: 'running', + idleMinutes: 0, + burnRatePerHour: 0.50, + contextPressurePercent: 30, + fileConflicts: 0, + attentionScore: 0, + attentionReason: null, + ...overrides, + }; +} + +function makeInput(overrides: Partial = {}): WakeScorerInput { + return { + projects: [makeProject()], + ...overrides, + }; +} + +describe('wake-scorer — evaluateWakeSignals', () => { + it('always includes PeriodicFloor signal', () => { + const result = evaluateWakeSignals(makeInput()); + const periodic = result.signals.find(s => s.id === 'PeriodicFloor'); + expect(periodic).toBeDefined(); + expect(periodic!.score).toBe(0.1); + }); + + it('returns PeriodicFloor as top signal when no issues', () => { + const result = evaluateWakeSignals(makeInput()); + expect(result.score).toBe(0.1); + expect(result.signals[0].id).toBe('PeriodicFloor'); + }); + + it('detects AttentionSpike when projects have attention score > 0', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ attentionScore: 100, attentionReason: 'Stalled — 20 min' }), + makeProject({ projectName: 'Proj2', attentionScore: 0 }), + ], + })); + expect(result.score).toBe(1.0); + const spike = result.signals.find(s => s.id === 'AttentionSpike'); + expect(spike).toBeDefined(); + expect(spike!.reason).toContain('1 project'); + expect(spike!.reason).toContain('TestProject'); + }); + + it('AttentionSpike reports multiple projects', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ attentionScore: 100, attentionReason: 'Stalled' }), + makeProject({ projectName: 'B', attentionScore: 80, attentionReason: 'Error' }), + ], + })); + const spike = result.signals.find(s => s.id === 'AttentionSpike'); + expect(spike!.reason).toContain('2 projects'); + }); + + it('detects ContextPressureCluster when 2+ projects above 75%', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ contextPressurePercent: 80 }), + makeProject({ projectName: 'B', contextPressurePercent: 85 }), + ], + })); + const cluster = result.signals.find(s => s.id === 'ContextPressureCluster'); + expect(cluster).toBeDefined(); + expect(cluster!.score).toBe(0.9); + }); + + it('does not trigger ContextPressureCluster with only 1 project above 75%', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ contextPressurePercent: 80 }), + makeProject({ projectName: 'B', contextPressurePercent: 50 }), + ], + })); + const cluster = result.signals.find(s => s.id === 'ContextPressureCluster'); + expect(cluster).toBeUndefined(); + }); + + it('detects BurnRateAnomaly when current rate is 3x+ average', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [makeProject({ burnRatePerHour: 6.0 })], + averageBurnRate: 1.5, + })); + const anomaly = result.signals.find(s => s.id === 'BurnRateAnomaly'); + expect(anomaly).toBeDefined(); + expect(anomaly!.score).toBe(0.8); + expect(anomaly!.reason).toContain('4.0x'); + }); + + it('does not trigger BurnRateAnomaly when rate is below 3x', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [makeProject({ burnRatePerHour: 2.0 })], + averageBurnRate: 1.5, + })); + const anomaly = result.signals.find(s => s.id === 'BurnRateAnomaly'); + expect(anomaly).toBeUndefined(); + }); + + it('does not trigger BurnRateAnomaly when averageBurnRate is 0', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [makeProject({ burnRatePerHour: 5.0 })], + averageBurnRate: 0, + })); + const anomaly = result.signals.find(s => s.id === 'BurnRateAnomaly'); + expect(anomaly).toBeUndefined(); + }); + + it('detects TaskQueuePressure when 3+ tasks blocked', () => { + const result = evaluateWakeSignals(makeInput({ + taskSummary: { total: 10, todo: 2, inProgress: 2, blocked: 4, review: 1, done: 1 }, + })); + const pressure = result.signals.find(s => s.id === 'TaskQueuePressure'); + expect(pressure).toBeDefined(); + expect(pressure!.score).toBe(0.7); + }); + + it('does not trigger TaskQueuePressure when fewer than 3 blocked', () => { + const result = evaluateWakeSignals(makeInput({ + taskSummary: { total: 10, todo: 2, inProgress: 4, blocked: 2, review: 1, done: 1 }, + })); + const pressure = result.signals.find(s => s.id === 'TaskQueuePressure'); + expect(pressure).toBeUndefined(); + }); + + it('detects ReviewBacklog when 5+ tasks in review', () => { + const result = evaluateWakeSignals(makeInput({ + taskSummary: { total: 10, todo: 0, inProgress: 0, blocked: 0, review: 5, done: 5 }, + })); + const backlog = result.signals.find(s => s.id === 'ReviewBacklog'); + expect(backlog).toBeDefined(); + expect(backlog!.score).toBe(0.6); + }); + + it('does not trigger ReviewBacklog when fewer than 5 in review', () => { + const result = evaluateWakeSignals(makeInput({ + taskSummary: { total: 10, todo: 2, inProgress: 2, blocked: 0, review: 4, done: 2 }, + })); + const backlog = result.signals.find(s => s.id === 'ReviewBacklog'); + expect(backlog).toBeUndefined(); + }); + + it('signals are sorted by score descending', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ attentionScore: 100, attentionReason: 'Stalled', contextPressurePercent: 80 }), + makeProject({ projectName: 'B', contextPressurePercent: 85, attentionScore: 0 }), + ], + taskSummary: { total: 10, todo: 0, inProgress: 0, blocked: 5, review: 0, done: 5 }, + })); + for (let i = 1; i < result.signals.length; i++) { + expect(result.signals[i - 1].score).toBeGreaterThanOrEqual(result.signals[i].score); + } + }); + + it('score is the maximum signal score', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ attentionScore: 100, attentionReason: 'Error', contextPressurePercent: 80 }), + makeProject({ projectName: 'B', contextPressurePercent: 85 }), + ], + })); + expect(result.score).toBe(1.0); // AttentionSpike + }); + + it('summary includes fleet stats', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ activityState: 'running' }), + makeProject({ projectName: 'B', activityState: 'idle' }), + makeProject({ projectName: 'C', activityState: 'stalled' }), + ], + })); + expect(result.summary).toContain('1 running'); + expect(result.summary).toContain('1 idle'); + expect(result.summary).toContain('1 stalled'); + }); + + it('summary includes task summary when provided', () => { + const result = evaluateWakeSignals(makeInput({ + taskSummary: { total: 15, todo: 3, inProgress: 4, blocked: 2, review: 1, done: 5 }, + })); + expect(result.summary).toContain('15 total'); + expect(result.summary).toContain('2 blocked'); + }); + + it('handles empty project list', () => { + const result = evaluateWakeSignals(makeInput({ projects: [] })); + expect(result.score).toBe(0.1); // Only PeriodicFloor + expect(result.signals).toHaveLength(1); + }); + + it('handles null contextPressurePercent gracefully', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ contextPressurePercent: null }), + makeProject({ projectName: 'B', contextPressurePercent: null }), + ], + })); + const cluster = result.signals.find(s => s.id === 'ContextPressureCluster'); + expect(cluster).toBeUndefined(); + }); +}); + +describe('wake-scorer — shouldWake', () => { + const lowEval = { + score: 0.1, + signals: [{ id: 'PeriodicFloor', score: 0.1, reason: 'Periodic' }], + shouldWake: true, + summary: 'test', + }; + + const highEval = { + score: 0.8, + signals: [{ id: 'BurnRateAnomaly', score: 0.8, reason: 'Spike' }], + shouldWake: true, + summary: 'test', + }; + + it('persistent always wakes', () => { + expect(shouldWake(lowEval, 'persistent', 0.5)).toBe(true); + expect(shouldWake(highEval, 'persistent', 0.5)).toBe(true); + }); + + it('on-demand always wakes', () => { + expect(shouldWake(lowEval, 'on-demand', 0.5)).toBe(true); + expect(shouldWake(highEval, 'on-demand', 0.5)).toBe(true); + }); + + it('smart wakes only when score >= threshold', () => { + expect(shouldWake(lowEval, 'smart', 0.5)).toBe(false); + expect(shouldWake(highEval, 'smart', 0.5)).toBe(true); + }); + + it('smart with threshold 0 always wakes', () => { + expect(shouldWake(lowEval, 'smart', 0)).toBe(true); + }); + + it('smart with threshold 1.0 only wakes on max signal', () => { + expect(shouldWake(highEval, 'smart', 1.0)).toBe(false); + const maxEval = { ...highEval, score: 1.0 }; + expect(shouldWake(maxEval, 'smart', 1.0)).toBe(true); + }); +}); diff --git a/v2/src/lib/utils/wake-scorer.ts b/v2/src/lib/utils/wake-scorer.ts new file mode 100644 index 0000000..ae65356 --- /dev/null +++ b/v2/src/lib/utils/wake-scorer.ts @@ -0,0 +1,163 @@ +// Wake signal scorer — pure function +// Evaluates fleet health signals to determine if the Manager should wake +// Signal IDs from tribunal S-3 hybrid: AttentionSpike, ContextPressureCluster, +// BurnRateAnomaly, TaskQueuePressure, ReviewBacklog, PeriodicFloor + +import type { WakeSignal, WakeEvaluation, WakeProjectSnapshot, WakeTaskSummary } from '../types/wake'; + +// --- Signal weights (0..1, higher = more urgent) --- + +const WEIGHT_ATTENTION_SPIKE = 1.0; +const WEIGHT_CONTEXT_PRESSURE_CLUSTER = 0.9; +const WEIGHT_BURN_RATE_ANOMALY = 0.8; +const WEIGHT_TASK_QUEUE_PRESSURE = 0.7; +const WEIGHT_REVIEW_BACKLOG = 0.6; +const WEIGHT_PERIODIC_FLOOR = 0.1; + +// --- Thresholds --- + +const CONTEXT_PRESSURE_HIGH = 0.75; +const CONTEXT_PRESSURE_CLUSTER_MIN = 2; // 2+ projects above threshold +const BURN_RATE_SPIKE_MULTIPLIER = 3; // 3x average = anomaly +const TASK_BLOCKED_CRITICAL = 3; // 3+ blocked tasks = pressure +const REVIEW_BACKLOG_CRITICAL = 5; // 5+ tasks in review = backlog + +export interface WakeScorerInput { + projects: WakeProjectSnapshot[]; + taskSummary?: WakeTaskSummary; + /** Average burn rate over last hour (for anomaly detection) */ + averageBurnRate?: number; +} + +/** Evaluate all wake signals and produce a wake evaluation */ +export function evaluateWakeSignals(input: WakeScorerInput): WakeEvaluation { + const signals: WakeSignal[] = []; + + // Signal 1: AttentionSpike — any project in attention queue (score > 0) + const attentionProjects = input.projects.filter(p => p.attentionScore > 0); + if (attentionProjects.length > 0) { + const top = attentionProjects.sort((a, b) => b.attentionScore - a.attentionScore)[0]; + signals.push({ + id: 'AttentionSpike', + score: WEIGHT_ATTENTION_SPIKE, + reason: `${attentionProjects.length} project${attentionProjects.length > 1 ? 's' : ''} need attention: ${top.projectName} (${top.attentionReason ?? 'urgent'})`, + }); + } + + // Signal 2: ContextPressureCluster — 2+ projects above 75% context + const highContextProjects = input.projects.filter( + p => p.contextPressurePercent !== null && p.contextPressurePercent > CONTEXT_PRESSURE_HIGH * 100, + ); + if (highContextProjects.length >= CONTEXT_PRESSURE_CLUSTER_MIN) { + signals.push({ + id: 'ContextPressureCluster', + score: WEIGHT_CONTEXT_PRESSURE_CLUSTER, + reason: `${highContextProjects.length} projects above ${CONTEXT_PRESSURE_HIGH * 100}% context pressure`, + }); + } + + // Signal 3: BurnRateAnomaly — current total burn rate >> average + if (input.averageBurnRate !== undefined && input.averageBurnRate > 0) { + const currentTotal = input.projects.reduce((sum, p) => sum + p.burnRatePerHour, 0); + if (currentTotal > input.averageBurnRate * BURN_RATE_SPIKE_MULTIPLIER) { + signals.push({ + id: 'BurnRateAnomaly', + score: WEIGHT_BURN_RATE_ANOMALY, + reason: `Burn rate $${currentTotal.toFixed(2)}/hr is ${(currentTotal / input.averageBurnRate).toFixed(1)}x average ($${input.averageBurnRate.toFixed(2)}/hr)`, + }); + } + } + + // Signal 4: TaskQueuePressure — too many blocked tasks + if (input.taskSummary) { + if (input.taskSummary.blocked >= TASK_BLOCKED_CRITICAL) { + signals.push({ + id: 'TaskQueuePressure', + score: WEIGHT_TASK_QUEUE_PRESSURE, + reason: `${input.taskSummary.blocked} blocked tasks on the board`, + }); + } + } + + // Signal 5: ReviewBacklog — too many tasks waiting for review + if (input.taskSummary) { + if (input.taskSummary.review >= REVIEW_BACKLOG_CRITICAL) { + signals.push({ + id: 'ReviewBacklog', + score: WEIGHT_REVIEW_BACKLOG, + reason: `${input.taskSummary.review} tasks pending review`, + }); + } + } + + // Signal 6: PeriodicFloor — always present (lowest priority) + signals.push({ + id: 'PeriodicFloor', + score: WEIGHT_PERIODIC_FLOOR, + reason: 'Periodic check-in', + }); + + // Sort by score descending + signals.sort((a, b) => b.score - a.score); + + const topScore = signals[0]?.score ?? 0; + + // Build summary for Manager prompt + const summary = buildWakeSummary(signals, input); + + return { + score: topScore, + signals, + shouldWake: true, // Caller (scheduler) gates this based on strategy + threshold + summary, + }; +} + +/** Check if wake should fire based on strategy and threshold */ +export function shouldWake( + evaluation: WakeEvaluation, + strategy: 'persistent' | 'on-demand' | 'smart', + threshold: number, +): boolean { + if (strategy === 'persistent' || strategy === 'on-demand') return true; + // Smart: only wake if score exceeds threshold + return evaluation.score >= threshold; +} + +function buildWakeSummary(signals: WakeSignal[], input: WakeScorerInput): string { + const parts: string[] = []; + + // Headline + const urgentSignals = signals.filter(s => s.score >= 0.5); + if (urgentSignals.length > 0) { + parts.push(`**Wake reason:** ${urgentSignals.map(s => s.reason).join('; ')}`); + } else { + parts.push('**Wake reason:** Periodic check-in (no urgent signals)'); + } + + // Fleet snapshot + const running = input.projects.filter(p => p.activityState === 'running').length; + const idle = input.projects.filter(p => p.activityState === 'idle').length; + const stalled = input.projects.filter(p => p.activityState === 'stalled').length; + const totalBurn = input.projects.reduce((sum, p) => sum + p.burnRatePerHour, 0); + parts.push(`\n**Fleet:** ${running} running, ${idle} idle, ${stalled} stalled | $${totalBurn.toFixed(2)}/hr`); + + // Project details (only those needing attention) + const needsAttention = input.projects.filter(p => p.attentionScore > 0); + if (needsAttention.length > 0) { + parts.push('\n**Needs attention:**'); + for (const p of needsAttention) { + const ctx = p.contextPressurePercent !== null ? ` | ctx ${p.contextPressurePercent}%` : ''; + const conflicts = p.fileConflicts > 0 ? ` | ${p.fileConflicts} conflicts` : ''; + parts.push(`- ${p.projectName}: ${p.activityState}${p.idleMinutes > 0 ? ` (${p.idleMinutes}m idle)` : ''}${ctx}${conflicts} — ${p.attentionReason ?? 'check needed'}`); + } + } + + // Task summary + if (input.taskSummary) { + const ts = input.taskSummary; + parts.push(`\n**Tasks:** ${ts.total} total (${ts.todo} todo, ${ts.inProgress} in progress, ${ts.blocked} blocked, ${ts.review} in review, ${ts.done} done)`); + } + + return parts.join('\n'); +}