feat(wake): add auto-wake Manager scheduler with 3 selectable strategies

New wake system for Manager agents: persistent (resume prompt), on-demand
(fresh session), smart (threshold-gated). 6 wake signals from tribunal S-3
hybrid. Pure scorer function (24 tests), Svelte 5 rune scheduler store,
SettingsTab UI (strategy button + threshold slider), AgentSession integration.
This commit is contained in:
Hibryda 2026-03-12 00:30:41 +01:00
parent 5576392d4b
commit c774f352ee
9 changed files with 891 additions and 2 deletions

View file

@ -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<typeof setInterval> | 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);
}

View file

@ -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;

View file

@ -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 @@
<span class="scale-label">{agent.wakeIntervalMin ?? 3} min</span>
</div>
</div>
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
Wake Strategy
</span>
<div class="wake-strategy-row">
{#each WAKE_STRATEGIES as strat}
<button
class="strategy-btn"
class:active={(agent.wakeStrategy ?? 'smart') === strat}
title={WAKE_STRATEGY_DESCRIPTIONS[strat]}
onclick={() => updateAgent(activeGroupId, agent.id, { wakeStrategy: strat })}
>{WAKE_STRATEGY_LABELS[strat]}</button>
{/each}
</div>
<span class="setting-hint">{WAKE_STRATEGY_DESCRIPTIONS[agent.wakeStrategy ?? 'smart']}</span>
</div>
{#if (agent.wakeStrategy ?? 'smart') === 'smart'}
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg>
Wake Threshold
</span>
<div class="scale-slider">
<input
type="range"
min="0"
max="1"
step="0.1"
value={agent.wakeThreshold ?? 0.5}
oninput={e => updateAgent(activeGroupId, agent.id, { wakeThreshold: parseFloat((e.target as HTMLInputElement).value) })}
/>
<span class="scale-label">{((agent.wakeThreshold ?? 0.5) * 100).toFixed(0)}%</span>
</div>
<span class="setting-hint">Only wakes when signal score exceeds this level</span>
</div>
{/if}
{/if}
<div class="card-field">
@ -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;

View file

@ -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<typeof setInterval> | 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<Map<string, ManagerRegistration>>(new Map());
let pendingWakes = $state<Map<string, WakeEvent>>(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<void> {
// 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,
});
}

View file

@ -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<void> {
// 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;

View file

@ -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 {

70
v2/src/lib/types/wake.ts Normal file
View file

@ -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<WakeStrategy, string> = {
persistent: 'Persistent',
'on-demand': 'On-demand',
smart: 'Smart',
};
export const WAKE_STRATEGY_DESCRIPTIONS: Record<WakeStrategy, string> = {
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;
}

View file

@ -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> = {}): 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> = {}): 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);
});
});

View file

@ -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');
}