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;