agenthub/src/core/services/presenceService.ts
chahinebrini 6d088648da feat(hub): Korrektheits- und Realtime-Härtung — Alias, Lifecycle, Presence, Architekten-Pulse (TSK-0242/0245/0249)
Drei vom Architekten abgenommene Tasks, gebündelt als Checkpoint:

- TSK-0242: Agent-Alias-Mapping (kimi-ah → kimi kanonisiert, Rollen → preferredAgent),
  reopenTask räumt claimedBy ab, fsWatch reindiziert direkte Datei-Edits,
  work-Default 300s → 50s, task_list mit Limit.
- TSK-0245: zwei Agent-Klassen (dispatch loop|architect). Watchdog mahnt
  architekt-getriebene Agenten nur noch EINMAL statt im Minutentakt;
  `task dispatch` startet sie explizit, `task record` trägt extern
  erledigte Arbeit mit origin=external nach.
- TSK-0249: Lifecycle wird serverseitig erzwungen (open→review scheitert mit
  klarer Meldung), claimedBy/doneBy überleben bis done, Presence pro Agent,
  Review-Watchdog, GET /architect/pulse (1.4 kB statt 34 kB, since-Cursor,
  omitted statt stillem Abschneiden), unbekannter Agent → 400 statt 500.

Alle Punkte live am laufenden Hub nachgemessen, nicht aus Agenten-Logs übernommen.
Tests: 242 → 279 grün, tsc sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:48:24 +02:00

169 lines
6.1 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;
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<string, number>();
const loops = new Map<string, { since?: number; exitAt?: number; exitReason?: string }>();
/** 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<AgentHealth, 'name' | 'role' | 'dispatch'> {
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<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',
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,
};
}