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:
parent
5576392d4b
commit
c774f352ee
9 changed files with 891 additions and 2 deletions
|
|
@ -22,6 +22,7 @@
|
||||||
import type { AgentMessage } from '../../adapters/claude-messages';
|
import type { AgentMessage } from '../../adapters/claude-messages';
|
||||||
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
|
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
|
||||||
import { loadAnchorsForProject } from '../../stores/anchors.svelte';
|
import { loadAnchorsForProject } from '../../stores/anchors.svelte';
|
||||||
|
import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte';
|
||||||
import { SessionId, ProjectId } from '../../types/ids';
|
import { SessionId, ProjectId } from '../../types/ids';
|
||||||
import AgentPane from '../Agent/AgentPane.svelte';
|
import AgentPane from '../Agent/AgentPane.svelte';
|
||||||
|
|
||||||
|
|
@ -87,6 +88,46 @@
|
||||||
startReinjectionTimer();
|
startReinjectionTimer();
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (reinjectionTimer) clearInterval(reinjectionTimer);
|
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()));
|
let sessionId = $state(SessionId(crypto.randomUUID()));
|
||||||
|
|
@ -102,6 +143,8 @@
|
||||||
contextRefreshPrompt = undefined;
|
contextRefreshPrompt = undefined;
|
||||||
registerSessionProject(sessionId, ProjectId(project.id), providerId);
|
registerSessionProject(sessionId, ProjectId(project.id), providerId);
|
||||||
trackProject(ProjectId(project.id), sessionId);
|
trackProject(ProjectId(project.id), sessionId);
|
||||||
|
// Notify wake scheduler of new session ID
|
||||||
|
if (isManager) updateManagerSession(project.id, sessionId);
|
||||||
onsessionid?.(sessionId);
|
onsessionid?.(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,9 @@
|
||||||
import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte';
|
import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte';
|
||||||
import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge';
|
import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge';
|
||||||
import { recordExternalWrite } from '../../stores/conflicts.svelte';
|
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 { notify, dismissNotification } from '../../stores/notifications.svelte';
|
||||||
|
import { registerManager, unregisterManager, updateManagerConfig } from '../../stores/wake-scheduler.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: ProjectConfig;
|
project: ProjectConfig;
|
||||||
|
|
@ -66,6 +67,32 @@
|
||||||
setStallThreshold(project.id, project.stallThresholdMin ?? null);
|
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
|
// S-1 Phase 2: start filesystem watcher for this project's CWD
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const cwd = project.cwd;
|
const cwd = project.cwd;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
import { getProviders } from '../../providers/registry.svelte';
|
import { getProviders } from '../../providers/registry.svelte';
|
||||||
import type { ProviderId, ProviderSettings } from '../../providers/types';
|
import type { ProviderId, ProviderSettings } from '../../providers/types';
|
||||||
import { ANCHOR_BUDGET_SCALES, ANCHOR_BUDGET_SCALE_LABELS, type AnchorBudgetScale } from '../../types/anchors';
|
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 = [
|
const PROJECT_ICONS = [
|
||||||
'📁', '🚀', '🤖', '🌐', '🔧', '🎮', '📱', '💻',
|
'📁', '🚀', '🤖', '🌐', '🔧', '🎮', '📱', '💻',
|
||||||
|
|
@ -704,6 +705,45 @@
|
||||||
<span class="scale-label">{agent.wakeIntervalMin ?? 3} min</span>
|
<span class="scale-label">{agent.wakeIntervalMin ?? 3} min</span>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/if}
|
||||||
|
|
||||||
<div class="card-field">
|
<div class="card-field">
|
||||||
|
|
@ -1428,6 +1468,41 @@
|
||||||
min-width: 5.5em;
|
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: left-ellipsis */
|
||||||
.cwd-input {
|
.cwd-input {
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
|
|
|
||||||
251
v2/src/lib/stores/wake-scheduler.svelte.ts
Normal file
251
v2/src/lib/stores/wake-scheduler.svelte.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { agentToProject } from '../types/groups';
|
||||||
import { clearAllAgentSessions } from '../stores/agents.svelte';
|
import { clearAllAgentSessions } from '../stores/agents.svelte';
|
||||||
import { clearHealthTracking } from '../stores/health.svelte';
|
import { clearHealthTracking } from '../stores/health.svelte';
|
||||||
import { clearAllConflicts } from '../stores/conflicts.svelte';
|
import { clearAllConflicts } from '../stores/conflicts.svelte';
|
||||||
|
import { clearWakeScheduler } from '../stores/wake-scheduler.svelte';
|
||||||
import { waitForPendingPersistence } from '../agent-dispatcher';
|
import { waitForPendingPersistence } from '../agent-dispatcher';
|
||||||
|
|
||||||
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms';
|
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
|
// Wait for any in-flight persistence before clearing state
|
||||||
await waitForPendingPersistence();
|
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 = {};
|
projectTerminals = {};
|
||||||
clearAllAgentSessions();
|
clearAllAgentSessions();
|
||||||
clearHealthTracking();
|
clearHealthTracking();
|
||||||
clearAllConflicts();
|
clearAllConflicts();
|
||||||
|
clearWakeScheduler();
|
||||||
|
|
||||||
activeGroupId = groupId;
|
activeGroupId = groupId;
|
||||||
activeProjectId = null;
|
activeProjectId = null;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ProviderId } from '../providers/types';
|
import type { ProviderId } from '../providers/types';
|
||||||
import type { AnchorBudgetScale } from './anchors';
|
import type { AnchorBudgetScale } from './anchors';
|
||||||
|
import type { WakeStrategy } from './wake';
|
||||||
import type { ProjectId, GroupId, AgentId } from './ids';
|
import type { ProjectId, GroupId, AgentId } from './ids';
|
||||||
|
|
||||||
export interface ProjectConfig {
|
export interface ProjectConfig {
|
||||||
|
|
@ -69,6 +70,10 @@ export interface GroupAgentConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Auto-wake interval in minutes (Manager only, default 3) */
|
/** Auto-wake interval in minutes (Manager only, default 3) */
|
||||||
wakeIntervalMin?: number;
|
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 {
|
export interface GroupConfig {
|
||||||
|
|
|
||||||
70
v2/src/lib/types/wake.ts
Normal file
70
v2/src/lib/types/wake.ts
Normal 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;
|
||||||
|
}
|
||||||
253
v2/src/lib/utils/wake-scorer.test.ts
Normal file
253
v2/src/lib/utils/wake-scorer.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
163
v2/src/lib/utils/wake-scorer.ts
Normal file
163
v2/src/lib/utils/wake-scorer.ts
Normal 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');
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue