hermes/backend/session-sm.ts
Nico ccee249618 v0.6.42: Hermes chat UI — Vue3/TS/Vite, audio STT/TTS, sidebar rail, MCP event loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:35:10 +02:00

141 lines
5.1 KiB
TypeScript

/**
* session-sm.ts — Two-SM architecture for Hermes sessions
*
* CHANNEL SM (shared, per session key — all users see same state)
* FRESH → session exists, 0 messages
* READY → has messages, idle, user can type
* AGENT_RUNNING → agent processing a user message
* HANDOVER_PENDING → handover write in progress
* HANDOVER_DONE → handover written, waiting for action
* RESETTING → deliberate new session in progress
* NO_SESSION → no session file found
*
* CONNECTION SM (per WebSocket — only this user sees it)
* CONNECTING → WS open, waiting for auth
* LOADING_HISTORY → receiving history replay
* SYNCED → connected to channel, seeing live updates
* SWITCHING → leaving one channel, joining another
*/
// ── Channel SM ──────────────────────────────────────────────
export type ChannelState =
| 'FRESH'
| 'READY'
| 'AGENT_RUNNING'
| 'HANDOVER_PENDING'
| 'HANDOVER_DONE'
| 'RESETTING'
| 'NO_SESSION';
const CHANNEL_TRANSITIONS: Record<ChannelState, ChannelState[]> = {
FRESH: ['AGENT_RUNNING', 'RESETTING', 'NO_SESSION'],
READY: ['AGENT_RUNNING', 'HANDOVER_PENDING', 'RESETTING', 'NO_SESSION'],
AGENT_RUNNING: ['READY', 'RESETTING'],
HANDOVER_PENDING: ['HANDOVER_DONE', 'READY'],
HANDOVER_DONE: ['RESETTING', 'READY'],
RESETTING: ['AGENT_RUNNING', 'FRESH', 'NO_SESSION'],
NO_SESSION: ['RESETTING', 'FRESH', 'READY'],
};
const CHANNEL_TIMEOUTS_MS: Partial<Record<ChannelState, number>> = {
AGENT_RUNNING: 300_000,
HANDOVER_PENDING: 120_000,
RESETTING: 30_000,
};
// ── Connection SM ───────────────────────────────────────────
export type ConnectionState =
| 'CONNECTING'
| 'LOADING_HISTORY'
| 'SYNCED'
| 'SWITCHING';
const CONNECTION_TRANSITIONS: Record<ConnectionState, ConnectionState[]> = {
CONNECTING: ['LOADING_HISTORY', 'SYNCED'],
LOADING_HISTORY: ['SYNCED'],
SYNCED: ['SWITCHING'],
SWITCHING: ['LOADING_HISTORY', 'SYNCED'],
};
const CONNECTION_TIMEOUTS_MS: Partial<Record<ConnectionState, number>> = {
SWITCHING: 10_000,
};
// ── Generic SM factory ──────────────────────────────────────
export interface StateMachine<S extends string> {
transition(next: S, payload?: Record<string, unknown>): boolean;
get(): S;
destroy(): void;
}
function createSM<S extends string>(
id: string,
label: string,
initial: S,
transitions: Record<S, S[]>,
timeouts: Partial<Record<S, number>>,
onStateChange: (event: Record<string, unknown>) => void,
timeoutTarget?: S,
): StateMachine<S> {
let state: S = initial;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
function clearPendingTimeout() {
if (timeoutHandle !== null) { clearTimeout(timeoutHandle); timeoutHandle = null; }
}
function transition(next: S, payload: Record<string, unknown> = {}): boolean {
const allowed = transitions[state];
if (!allowed?.includes(next)) {
console.warn(`[${label}:${id}] invalid transition: ${state} -> ${next} (ignored)`);
return false;
}
clearPendingTimeout();
const prev = state;
state = next;
console.log(`[${label}:${id}] ${prev} -> ${state}${payload.reason ? ' (' + payload.reason + ')' : ''}`);
const ms = timeouts[state];
if (ms && timeoutTarget) {
timeoutHandle = setTimeout(() => {
console.warn(`[${label}:${id}] timeout in ${state}, forcing -> ${timeoutTarget}`);
transition(timeoutTarget, { reason: 'timeout' });
}, ms);
}
onStateChange({ type: `${label}_state`, state, prev, ...payload });
return true;
}
return {
transition,
get: () => state,
destroy: clearPendingTimeout,
};
}
// ── Factory functions ───────────────────────────────────────
export function createChannelSM(
sessionKey: string,
onStateChange: (event: Record<string, unknown>) => void,
initial: ChannelState = 'NO_SESSION',
): StateMachine<ChannelState> {
return createSM(sessionKey, 'channel', initial, CHANNEL_TRANSITIONS, CHANNEL_TIMEOUTS_MS, onStateChange, 'READY');
}
export function createConnectionSM(
clientId: string,
onStateChange: (event: Record<string, unknown>) => void,
): StateMachine<ConnectionState> {
return createSM(clientId, 'connection', 'CONNECTING', CONNECTION_TRANSITIONS, CONNECTION_TIMEOUTS_MS, onStateChange, 'SYNCED');
}
// ── Backward compat (temporary, remove after migration) ─────
export type SmState = ChannelState | ConnectionState;
export type SessionSM = StateMachine<ChannelState>;