import { listTasks } from './taskService.js'; import { listMessages } from './messageService.js'; import { getRoster } from './rosterService.js'; import { VERSION } from '../../version.js'; /** * Per-agent presence (lastSeen) + the /health report (TSK-0226). * * lastSeen is stamped by the server routes on every announce / claim / review / * log / message action. It is deliberately IN-MEMORY: presence is a live-view * concern of the running hub process, not project data — a restarted hub simply * re-learns presence from the agents' next actions. * * Traffic light per agent: * active (green) — acted within ACTIVE_WINDOW_MS (2 min) * busy (blue) — has an in_progress task (shown as "busy-on-TSK-X") * idle (gray) — seen within STALE_WINDOW_MS (10 min), nothing in flight * stale (red) — not seen for > STALE_WINDOW_MS with an open assignment */ /** An agent that acted less than this long ago counts as active. */ export const ACTIVE_WINDOW_MS = 2 * 60_000; /** Longer than this without any action ⇒ the agent is stale/offline. */ export const STALE_WINDOW_MS = 10 * 60_000; export type AgentLight = 'active' | 'busy' | 'idle' | 'stale'; export interface AgentHealth { name: string; role: string; dispatch: 'loop' | 'architect'; state: AgentLight; /** Busy-on task (in_progress) or — for stale agents — the waiting open task. */ taskId?: string; waitingTaskId?: string; lastSeen?: string; lastSeenAgoSec?: number; inLoop: boolean; loopSince?: string; loopSinceAgoSec?: number; loopExitAt?: string; loopExitAgoSec?: number; loopExitReason?: string; } export interface HealthReport { status: 'ok'; version: string; startedAt: string; uptimeSec: number; counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number }; agents: AgentHealth[]; indexErrors?: Array<{ filePath: string; error: string; at: string }>; } const lastSeen = new Map(); const loops = new Map(); /** Stamp an agent's lastSeen (called by the routes on agent actions). */ export function stampSeen(agent: string, at: number = Date.now()): void { const name = agent?.trim(); if (!name) return; lastSeen.set(name, at); } export function enterLoop(agent: string, at: number = Date.now()): void { const name = agent?.trim(); if (!name) return; lastSeen.set(name, at); loops.set(name, { since: at }); } export function leaveLoop(agent: string, reason: string, at: number = Date.now()): void { const name = agent?.trim(); if (!name) return; lastSeen.set(name, at); loops.set(name, { exitAt: at, exitReason: reason || 'ended' }); } export function isAgentInLoop(agent: string): boolean { return loops.get(agent)?.since !== undefined; } export function agentLoopStatus(agent: string): 'active' | 'inactive' | 'unknown' { const loop = loops.get(agent); if (!loop) return 'unknown'; return loop.since !== undefined ? 'active' : 'inactive'; } /** Test hook: drop all presence state. */ export function resetPresence(): void { lastSeen.clear(); loops.clear(); } /** One agent's traffic light, derived from presence + its task load. */ export function agentLight( name: string, tasks: Array<{ id: string; status?: string; assignedTo?: string; claimedBy?: string }>, now: number = Date.now(), ): Omit { const seen = lastSeen.get(name); const busy = tasks.find((t) => t.status === 'in_progress' && (t.assignedTo === name || t.claimedBy === name)); const waiting = tasks.find((t) => t.status === 'open' && t.assignedTo === name); const seenAgo = seen === undefined ? undefined : now - seen; const loop = loops.get(name); let state: AgentLight; if (seenAgo !== undefined && seenAgo < ACTIVE_WINDOW_MS) { state = busy ? 'busy' : 'active'; } else if (busy) { state = 'busy'; } else if (seenAgo !== undefined && seenAgo < STALE_WINDOW_MS) { state = 'idle'; } else if (waiting) { state = 'stale'; } else { state = 'idle'; } return { state, taskId: busy?.id ?? (state === 'stale' ? waiting?.id : undefined), waitingTaskId: waiting?.id, lastSeen: seen === undefined ? undefined : new Date(seen).toISOString(), lastSeenAgoSec: seenAgo === undefined ? undefined : Math.max(0, Math.round(seenAgo / 1000)), inLoop: loop?.since !== undefined, loopSince: loop?.since === undefined ? undefined : new Date(loop.since).toISOString(), loopSinceAgoSec: loop?.since === undefined ? undefined : Math.max(0, Math.round((now - loop.since) / 1000)), loopExitAt: loop?.exitAt === undefined ? undefined : new Date(loop.exitAt).toISOString(), loopExitAgoSec: loop?.exitAt === undefined ? undefined : Math.max(0, Math.round((now - loop.exitAt) / 1000)), loopExitReason: loop?.exitReason, }; } /** Compact hub health: status + version + uptime + counts + per-agent lights. */ export function computeHealth(cwd: string, startedAtMs: number, now: number = Date.now()): HealthReport { const tasks = listTasks(cwd); const roster = getRoster(cwd); const roleByName = new Map(roster.map((r) => [r.name, r.role])); const dispatchByName = new Map(roster.map((r) => [r.name, r.dispatch])); // Roster agents PLUS anyone who only ever announced themselves (presence-only). const names = new Set([...roster.map((r) => r.name), ...lastSeen.keys()]); const agents: AgentHealth[] = Array.from(names) .sort((a, b) => a.localeCompare(b)) .map((name) => ({ name, role: roleByName.get(name) ?? 'implementer', dispatch: dispatchByName.get(name) ?? 'loop', ...agentLight(name, tasks, now), })); return { status: 'ok', version: VERSION, startedAt: new Date(startedAtMs).toISOString(), uptimeSec: Math.max(0, Math.round((now - startedAtMs) / 1000)), counts: { tasks: tasks.length, open: tasks.filter((t) => t.status === 'open').length, inProgress: tasks.filter((t) => t.status === 'in_progress').length, review: tasks.filter((t) => t.status === 'review').length, unreadMessages: listMessages(cwd).filter((m) => m.status === 'unread').length, }, agents, }; }