agenthub/src/core/services/presenceService.ts
chahinebrini 7bf52a0be3 feat(server): watchdog re-notify, agent health ampel, architect budget visibility
- Watchdog (configurable, ~60s): re-emits SSE + unread reminder + board
  warn log for unclaimed assigned tasks (3min high/critical, 10min else);
  alerts the architect on silent in_progress tasks (>15min, no auto-reassign);
  per-task cooldown against alert spam.
- GET /health (status/version/uptime/counts + per-agent traffic light) with
  lastSeen stamped on announce/claim/review/log/message; board sidebar shows
  the agent ampel; new 'agenthub health' CLI command.
- Budget: architect coordination actions (reviews, handoffs, messages,
  approvals) surface as estimated activity tokens + an 'actions' counter.
- Version bump 0.10.2 (single source: src/version.ts).
2026-07-23 17:52:37 +02:00

124 lines
4.4 KiB
TypeScript

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;
state: AgentLight;
/** Busy-on task (in_progress) or — for stale agents — the waiting open task. */
taskId?: string;
lastSeen?: string;
lastSeenAgoSec?: number;
}
export interface HealthReport {
status: 'ok';
version: string;
startedAt: string;
uptimeSec: number;
counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number };
agents: AgentHealth[];
}
const lastSeen = new Map<string, number>();
/** 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);
}
/** Test hook: drop all presence state. */
export function resetPresence(): void {
lastSeen.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(),
): Pick<AgentHealth, 'state' | 'taskId' | 'lastSeen' | 'lastSeenAgoSec'> {
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;
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),
lastSeen: seen === undefined ? undefined : new Date(seen).toISOString(),
lastSeenAgoSec: seenAgo === undefined ? undefined : Math.max(0, Math.round(seenAgo / 1000)),
};
}
/** 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]));
// Roster agents PLUS anyone who only ever announced themselves (presence-only).
const names = new Set<string>([...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',
...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,
};
}