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>
169 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
}
|