- 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).
124 lines
4.4 KiB
TypeScript
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,
|
|
};
|
|
}
|