diff --git a/package.json b/package.json index 212491f..271aa79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "0.10.1", + "version": "0.10.2", "description": "Local coordination layer for AI coding agents", "type": "module", "main": "./dist/index.js", diff --git a/src/cli/commands/health.ts b/src/cli/commands/health.ts new file mode 100644 index 0000000..96929d6 --- /dev/null +++ b/src/cli/commands/health.ts @@ -0,0 +1,41 @@ +import type { HealthReport } from '../../core/services/presenceService.js'; + +/** `agenthub health` — compact hub status + per-agent traffic light. */ + +const LIGHT: Record = { + active: '●', // green — acted <2min ago + busy: '●', // blue — working a task + idle: '○', // gray — nothing in flight + stale: '●', // red — >10min silent with an open assignment +}; + +function fmtDuration(sec: number): string { + if (sec < 60) return `${sec}s`; + const m = Math.floor(sec / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 48) return `${h}h`; + return `${Math.floor(h / 24)}d`; +} + +function fmtAgo(sec: number | undefined): string { + return sec === undefined ? 'never seen' : `${fmtDuration(sec)} ago`; +} + +export function printHealth(h: HealthReport): void { + const c = h.counts; + console.log( + `hub: ${h.status} · v${h.version} · up ${fmtDuration(h.uptimeSec)} · ` + + `${c.tasks} tasks (${c.open} open, ${c.inProgress} in progress, ${c.review} review) · ${c.unreadMessages} unread`, + ); + if (h.agents.length === 0) { + console.log('no agents yet'); + return; + } + const width = Math.max(...h.agents.map((a) => a.name.length)); + for (const a of h.agents) { + const light = LIGHT[a.state] ?? '○'; + const task = a.taskId ? ` ${a.taskId}${a.state === 'stale' ? ' open' : ''} ·` : ''; + console.log(`${a.name.padEnd(width)} ${light} ${a.state}${task} ${fmtAgo(a.lastSeenAgoSec)}`); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 4104a7a..74a84d8 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,8 @@ import { loadConfig, saveConfig } from '../core/config.js'; import { findProjectRoot } from '../core/paths.js'; import { discoverServer, resolveReachableServerUrl } from '../discovery.js'; import { remoteClient, RemoteError } from './remoteClient.js'; +import { printHealth } from './commands/health.js'; +import { VERSION } from '../version.js'; interface ResolvedContext { serverUrl?: string; @@ -138,7 +140,7 @@ async function runRemote(serverUrl: string, fn: () => Promise): Promise', 'AgentHub server URL (env: AGENTHUB_SERVER)'); program @@ -167,6 +169,21 @@ export function createProgram(cwd: string): Command { } }); + program + .command('health') + .description('Hub health: status, uptime and per-agent traffic lights') + .action(async () => { + const { serverUrl } = await resolveContext(program, cwd); + if (!serverUrl) { + console.error('agenthub health needs a running server. Start one with: agenthub server start --host 0.0.0.0'); + process.exit(1); + return; + } + await runRemote(serverUrl, async () => { + printHealth(await remoteClient.getHealth(serverUrl)); + }); + }); + const memoryCmd = new Command('memory').description('Manage memory entries'); memoryCmd .command('add') diff --git a/src/cli/remoteClient.ts b/src/cli/remoteClient.ts index 8063f36..25cc30c 100644 --- a/src/cli/remoteClient.ts +++ b/src/cli/remoteClient.ts @@ -2,6 +2,7 @@ import type { Task, Handoff, Decision, Memory, Message, Ask, ActivityItem } from import type { IndexEntry } from '../core/index.js'; import type { InboxMessage } from '../core/services/messageService.js'; import type { TaskLogEntry } from '../core/services/taskLogService.js'; +import type { HealthReport } from '../core/services/presenceService.js'; export class RemoteError extends Error { constructor(public status: number, message: string) { @@ -38,6 +39,10 @@ export const remoteClient = { return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body); }, + async getHealth(baseUrl: string): Promise { + return request(baseUrl, 'GET', '/health'); + }, + async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise { await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action }); }, diff --git a/src/core/config.ts b/src/core/config.ts index 6eefe85..4902d98 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { dirname } from 'path'; import { getConfigPath } from './paths.js'; -import { ConfigSchema, type Config } from './schema.js'; +import { ConfigSchema, WatchdogConfigSchema, type Config } from './schema.js'; export function defaultConfig(projectName: string): Config { return { @@ -15,6 +15,7 @@ export function defaultConfig(projectName: string): Config { tester: { preferredAgent: 'codex' }, }, serverUrl: undefined, + watchdog: WatchdogConfigSchema.parse({}), }; } diff --git a/src/core/schema.ts b/src/core/schema.ts index 768810d..e24db87 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -187,6 +187,23 @@ export const OrgNodeSchema = z.object({ }); export type OrgNode = z.infer; +/** + * Server-side watchdog tuning (TSK-0226). All values optional — the defaults + * below apply when the section (or a single key) is absent from the config. + */ +export const WatchdogConfigSchema = z.object({ + /** Master switch; false disables the watchdog loop entirely. */ + enabled: z.boolean().default(true), + /** How often the watchdog scans tasks. */ + intervalMs: z.number().int().positive().default(60_000), + /** OPEN + assigned but unclaimed for this long (high/critical) → re-notify. */ + unclaimedHighMs: z.number().int().positive().default(3 * 60_000), + /** OPEN + assigned but unclaimed for this long (medium/low) → re-notify. */ + unclaimedDefaultMs: z.number().int().positive().default(10 * 60_000), + /** IN_PROGRESS with no task-log line for this long → alert the architect. */ + staleInProgressMs: z.number().int().positive().default(15 * 60_000), +}).default({}); + export const ConfigSchema = z.object({ version: z.literal('1'), projectName: z.string().min(1), @@ -197,6 +214,8 @@ export const ConfigSchema = z.object({ /** Team org chart (arbitrary depth). When present, /team renders this tree. */ org: z.array(OrgNodeSchema).optional(), serverUrl: z.string().url().optional(), + /** Watchdog thresholds; absent config ⇒ sane defaults. */ + watchdog: WatchdogConfigSchema, }); export type Task = z.infer; @@ -207,3 +226,4 @@ export type Message = z.infer; export type Ask = z.infer; export type Status = z.infer; export type Config = z.infer; +export type WatchdogConfig = z.infer; diff --git a/src/core/services/budgetService.ts b/src/core/services/budgetService.ts index 44482bf..7dd985c 100644 --- a/src/core/services/budgetService.ts +++ b/src/core/services/budgetService.ts @@ -1,4 +1,7 @@ import { getTask, listTasks } from './taskService.js'; +import { listHandoffs } from './handoffService.js'; +import { listMessages } from './messageService.js'; +import { loadConfig } from '../config.js'; import { getRoster, inferKind, type RosterEntry } from './rosterService.js'; /** @@ -39,6 +42,14 @@ export const MAX_ACTIVE_MIN_PER_TASK = 45; */ export const MAX_LIVE_ESTIMATE_MIN_PER_TASK = 12; +/** + * Estimated tokens per architect/coordination action (review, handoff, + * message, done/reopen decision). The architect rarely OWNS tasks, so without + * this their work was invisible in the report (TSK-0226). ≈ 30s of active + * work per action — deliberately rough and flagged estimated like the rest. + */ +export const TOKENS_PER_COORDINATION_ACTION = 1500; + /** USD → EUR display rate (rough, labelled approximate in the UI). */ const USD_TO_EUR = 0.92; @@ -121,6 +132,9 @@ export interface AgentBudget { /** costEur / budgetEur, clamped 0..1 (only when a budget is set). */ budgetUsed?: number; taskCount: number; + /** Coordination actions attributed to this agent (reviews, handoffs, + * messages, approvals) — the counter behind the activity estimate. */ + actions: number; } export interface BudgetReport { @@ -153,6 +167,7 @@ export function computeBudget(cwd: string): BudgetReport { costEur: 0, budgetEur: r?.budgetEur, taskCount: 0, + actions: 0, }; acc.set(name, a); } @@ -161,7 +176,8 @@ export function computeBudget(cwd: string): BudgetReport { // Seed rostered agents so they show even at zero. for (const r of roster) ensure(r.name); - for (const t of listTasks(cwd)) { + const tasks = listTasks(cwd); + for (const t of tasks) { const owner = t.assignedTo; if (!owner) continue; const { tokens, estimated } = tokensForTask( @@ -183,6 +199,50 @@ export function computeBudget(cwd: string): BudgetReport { } } + // ── Architect / coordination activity (TSK-0226) ───────────────────────── + // Reviews, handoffs, messages and done/reopen decisions rarely leave the + // architect as a task OWNER — count them as actions and estimate activity + // tokens so the architect's work shows up like implementer work. + const actionCount = new Map(); + const bump = (name: string | undefined, n = 1) => { + if (!name) return; + actionCount.set(name, (actionCount.get(name) ?? 0) + n); + }; + const preferredByRole = new Map(); + try { + for (const [role, cfg] of Object.entries(loadConfig(cwd).roles)) preferredByRole.set(role, cfg.preferredAgent); + } catch { + /* no config → role fallback stays empty */ + } + for (const t of tasks) { + // A pending review is architect work in flight (reviewer ≠ assignee by construction). + if (t.reviewer) bump(t.reviewer); + // Approval: done by someone other than the task owner (e.g. architect approves). + // doneBy isn't indexed — read the entity file for done tasks only. + if (t.status === 'done') { + try { + const { task } = getTask(cwd, t.id); + if (task.doneBy && task.doneBy !== t.assignedTo) bump(task.doneBy); + } catch { + /* unreadable entity → skip */ + } + } + } + for (const h of listHandoffs(cwd)) { + bump(h.fromAgent ?? (h.fromRole ? preferredByRole.get(h.fromRole) : undefined)); + } + for (const m of listMessages(cwd)) { + bump(m.from); + } + for (const [name, count] of actionCount) { + const a = ensure(name); + const tokens = count * TOKENS_PER_COORDINATION_ACTION; + a.actions += count; + a.tokens += tokens; + a.estimatedTokens += tokens; + a.estimated = true; + } + const agents = Array.from(acc.values()); for (const a of agents) { a.costEur = costEur(a.tokens, a.model); @@ -201,7 +261,7 @@ export function computeBudget(cwd: string): BudgetReport { totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated }, assumptions: { tokensPerActiveMin: TOKENS_PER_ACTIVE_MIN, - note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (live states capped at ${MAX_LIVE_ESTIMATE_MIN_PER_TASK} min/task, done fallbacks at ${MAX_ACTIVE_MIN_PER_TASK} min/task). Cost is a directional estimate at blended model rates, not billing.`, + note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (live states capped at ${MAX_LIVE_ESTIMATE_MIN_PER_TASK} min/task, done fallbacks at ${MAX_ACTIVE_MIN_PER_TASK} min/task) plus coordination actions (reviews/handoffs/messages/approvals ≈ ${TOKENS_PER_COORDINATION_ACTION} tok each, see 'actions'). Cost is a directional estimate at blended model rates, not billing.`, }, generatedAt: new Date().toISOString(), }; diff --git a/src/core/services/presenceService.ts b/src/core/services/presenceService.ts new file mode 100644 index 0000000..3c0176a --- /dev/null +++ b/src/core/services/presenceService.ts @@ -0,0 +1,123 @@ +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(); + +/** 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 { + 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([...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, + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 06e38a5..45d369b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -25,6 +25,7 @@ import { addMemory, searchMemory } from '../core/services/memoryService.js'; import { createDecision } from '../core/services/decisionService.js'; import { getStatus } from '../core/services/statusService.js'; import { discoverServer as discoverHubServer } from '../discovery.js'; +import { VERSION } from '../version.js'; /** * AgentHub MCP server (TSK-0030). @@ -165,7 +166,7 @@ function waitForTask( export async function startMcpServer(cwd: string): Promise { const { root, serverUrl } = resolveContext(cwd); const remote = !!serverUrl; - const server = new McpServer({ name: 'agenthub', version: '0.10.1' }); + const server = new McpServer({ name: 'agenthub', version: VERSION }); server.tool('agenthub_hello', 'Announce yourself to the hub (presence). Run once when you start.', { agent: z.string(), role: z.string().optional() }, diff --git a/src/server/board/sidebar.ts b/src/server/board/sidebar.ts index b1a47f5..ab89398 100644 --- a/src/server/board/sidebar.ts +++ b/src/server/board/sidebar.ts @@ -22,6 +22,10 @@ export function sidebarHtml(): string {
no agent activity yet
+
+
Agents
+
no agents yet
+
Live
@@ -61,5 +65,28 @@ window.__b2FeedPush = function (html) { feed.insertBefore(div, feed.firstChild); while (feed.children.length > 5) feed.removeChild(feed.lastChild); }; +// ── Agent health traffic lights (GET /health) ───────────────────────────── +// active (green) <2min since last action · busy (blue, on TSK-X) · +// idle (gray) · stale/offline (red, >10min with an open assignment). +window.__b2RenderAgents = function (report) { + var box = document.getElementById('b2Agents'); + if (!box) return; + var agents = report && report.agents ? report.agents : []; + if (!agents.length) { box.innerHTML = '
no agents yet
'; return; } + box.innerHTML = agents.map(function (a) { + var detail = a.state === 'busy' && a.taskId ? 'busy · ' + a.taskId + : a.state === 'stale' && a.taskId ? 'stale · ' + a.taskId + ' open' + : a.state; + return '
' + + '' + + '' + esc(a.name) + '' + + '' + esc(detail) + '
'; + }).join(''); +}; +async function refreshAgentHealth() { + try { window.__b2RenderAgents(await getJSON('/health')); } catch (_) {} +} +refreshAgentHealth(); +setInterval(refreshAgentHealth, 5000); ${v1BudgetJs}`; } diff --git a/src/server/board/styles.ts b/src/server/board/styles.ts index dbaf633..e216a08 100644 --- a/src/server/board/styles.ts +++ b/src/server/board/styles.ts @@ -323,6 +323,17 @@ export function boardV2Css(): string { .b2-feed .pulse { color: var(--b2-green); animation: b2-blink 1.6s infinite; } .b2-feed .f-time { color: var(--b2-dim); font-size: 10px; margin-left: auto; flex: 0 0 auto; } + /* ── Agent health lights ──────────────────────────────────────────── */ + .b2-agents { margin-top: 8px; display: grid; gap: 2px; } + .b2-agent-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font: 12px/1.3 var(--mono); } + .b2-light { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; background: #94a3b8; } + .b2-light.st-active { background: var(--b2-green); box-shadow: 0 0 0 3px rgba(52,211,153,.16); } + .b2-light.st-busy { background: var(--accent); box-shadow: 0 0 0 3px rgba(56,189,248,.16); } + .b2-light.st-idle { background: #94a3b8; } + .b2-light.st-stale { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,.16); } + .b2-agent-name { color: var(--text); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .b2-agent-state { margin-left: auto; color: var(--b2-muted); font-size: 10.5px; white-space: nowrap; } + /* ── Modals (ported v1) ───────────────────────────────────────────── */ .modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start; justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62); diff --git a/src/server/index.ts b/src/server/index.ts index 6ce97f7..6e7b1bf 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,6 +4,7 @@ import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js' import { startMdnsAdvertise, type MdnsHandle } from './mdns.js'; import { startEntityWatcher } from './fsWatch.js'; import { startStatusAutoRefresh } from './statusRefresh.js'; +import { startWatchdog } from './watchdog.js'; export function buildApp(cwd: string) { const app = Fastify({ logger: false }); @@ -32,11 +33,14 @@ export async function startServer(cwd: string, options: { port?: number; host?: const stopWatcher = startEntityWatcher(cwd); // Keep status/latest.md fresh on every change so agents never read a stale snapshot. const stopStatusRefresh = startStatusAutoRefresh(cwd); + // Stuck-task watchdog: re-notify unclaimed priority tasks, flag silent ones. + const stopWatchdog = startWatchdog(cwd); app.addHook('onClose', async () => { broadcaster?.stop(); mdns?.stop(); stopWatcher(); stopStatusRefresh(); + stopWatchdog(); }); try { diff --git a/src/server/routes.ts b/src/server/routes.ts index 15a2433..51393ed 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -11,6 +11,7 @@ import { getStatus, updateStatus } from '../core/services/statusService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; import { computeBudget } from '../core/services/budgetService.js'; import { getRoster } from '../core/services/rosterService.js'; +import { computeHealth, stampSeen } from '../core/services/presenceService.js'; import { loadConfig, saveConfig } from '../core/config.js'; import { renderActivityHtml } from './activity.js'; import { renderBoardHtml } from './board/index.js'; @@ -46,6 +47,9 @@ function wantsHtml(request: { headers: { accept?: string } }): boolean { } export async function registerRoutes(app: FastifyInstance, cwd: string): Promise { + // Uptime anchor for /health (buildApp ≈ server start). + const startedAtMs = Date.now(); + // Default dynamic responses to no-store so board reloads/fetches never reuse // stale task JSON or HTML. Static asset routes override this with cacheable // headers, and the SSE route writes its own raw no-cache header. @@ -53,6 +57,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise reply.header('Cache-Control', 'no-store, must-revalidate'); }); + // Hub health: status + version + uptime + compact counts + per-agent lights. + app.get('/health', async () => computeHealth(cwd, startedAtMs)); + // Static, self-contained Trello-like board. Polls /tasks, /handoffs and // /decisions on the same origin; no build step, no deps. Cached once — the // markup is constant, only the data it fetches changes. @@ -188,6 +195,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise app.post('/announce', async (request, reply) => { const { agent, role, action } = request.body as { agent?: string; role?: string; action?: string }; if (!agent) return badRequest(reply, 'agent is required'); + stampSeen(agent); const ev: AgentHubEvent = { type: 'agent', action: action === 'left' ? 'left' : 'joined', @@ -314,6 +322,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Invalid log line'); } + if (agent) stampSeen(agent); eventBus.publishLog({ taskId: id, ...entry }); return entry; }); @@ -358,6 +367,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Cannot claim task'); } + stampSeen(agent); } break; case 'done': @@ -366,9 +376,12 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise doneTokens: patch.doneTokens as number | undefined, doneDuration: patch.doneDuration as number | undefined, }); + if (task.doneBy) stampSeen(task.doneBy); break; case 'review': task = reviewTask(cwd, id, patch.reviewer); + // The submitter (assignee) is the acting agent here. + if (task.assignedTo) stampSeen(task.assignedTo); break; case 'cancelled': task = cancelTask(cwd, id); @@ -487,6 +500,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Invalid message'); } + stampSeen(message.from); emitChange( { type: 'message', diff --git a/src/server/watchdog.ts b/src/server/watchdog.ts new file mode 100644 index 0000000..963f58b --- /dev/null +++ b/src/server/watchdog.ts @@ -0,0 +1,172 @@ +import { loadConfig } from '../core/config.js'; +import type { Config, WatchdogConfig } from '../core/schema.js'; +import { listTasks, getTask } from '../core/services/taskService.js'; +import { readTaskLog, appendTaskLog } from '../core/services/taskLogService.js'; +import { createMessage } from '../core/services/messageService.js'; +import { emitChange, eventBus } from './events.js'; + +/** + * Server-side stuck-task watchdog (TSK-0226). + * + * Nobody should have to wake agents manually or spot priority inversions by + * hand — the hub re-notifies autonomously: + * + * (a) OPEN + assigned but unclaimed for > 3 min (high/critical) or > 10 min + * (medium/low) ⇒ re-emit the task event (SSE) so a waiting `work` loop + * re-wakes, send the assignee an `unread` reminder message (work loops + * wake on unread mail), and log a board-visible warn line on the task. + * (b) IN_PROGRESS with no task-log line for > 15 min ⇒ alert the architect + * (visibility only — reassignment stays an architect decision). + * + * Anti-spam: a given task is re-alerted only after its threshold has elapsed + * again since the last alert (per task + alert kind). + * + * All thresholds + the interval are configurable via the `watchdog` section of + * agenthub.config.json; absent config ⇒ the schema defaults (see schema.ts). + */ + +/** last alert timestamp per `${taskId}:${kind}` — in-memory, per hub process. */ +const lastAlertAt = new Map(); + +function architectName(config: Config): string { + return config.roles.architect?.preferredAgent ?? 'claude'; +} + +function thresholdFor(priority: string | undefined, cfg: WatchdogConfig): number { + return priority === 'high' || priority === 'critical' ? cfg.unclaimedHighMs : cfg.unclaimedDefaultMs; +} + +function cooledDown(key: string, threshold: number, now: number): boolean { + const last = lastAlertAt.get(key); + return last === undefined || now - last >= threshold; +} + +/** Board-visible alert: a warn line on the task's live console + SSE fan-out. */ +function logAlert(cwd: string, id: string, text: string): void { + try { + const entry = appendTaskLog(cwd, id, { text, agent: 'watchdog', level: 'warn' }); + eventBus.publishLog({ taskId: id, ...entry }); + } catch { + /* best-effort */ + } +} + +/** + * One watchdog scan. Exported for tests; `startWatchdog` drives it on a timer. + * Returns the number of alerts raised (for tests/diagnostics). + */ +export function runWatchdogScan(cwd: string, now: number = Date.now()): number { + const config = loadConfig(cwd); + const cfg = config.watchdog; + let alerts = 0; + + for (const t of listTasks(cwd)) { + // (a) OPEN + addressed to an agent, but never claimed. + if (t.status === 'open' && t.assignedTo && !t.claimedBy) { + let priority: string | undefined; + let title: string | undefined = t.title; + try { + const full = getTask(cwd, t.id).task; + priority = full.priority; + title = full.title; + } catch { + /* fall back to the index row */ + } + const threshold = thresholdFor(priority, cfg); + const waitingMs = now - Date.parse(t.updatedAt || t.createdAt); + const key = `${t.id}:unclaimed`; + if (waitingMs >= threshold && cooledDown(key, threshold, now)) { + lastAlertAt.set(key, now); + alerts++; + // 1. Re-emit the task event so SSE subscribers / work loops re-wake. + emitChange( + { + type: 'task', + action: 'updated', + id: t.id, + title, + status: t.status, + role: t.role, + assignedTo: t.assignedTo, + claimedBy: t.claimedBy, + reviewer: t.reviewer, + }, + t.updatedAt, + ); + // 2. Unread reminder message — work loops wake on unread mail. + try { + createMessage(cwd, { + from: 'agenthub', + to: t.assignedTo, + text: `Reminder: ${t.id} wartet auf dich`, + taskId: t.id, + }); + } catch { + /* best-effort */ + } + // 3. Board-visible alert. + logAlert(cwd, t.id, `Watchdog: unclaimed for ${Math.round(waitingMs / 60_000)}m — re-notified ${t.assignedTo}`); + } + continue; + } + + // (b) IN_PROGRESS but silent — no task-log line for too long. + if (t.status === 'in_progress') { + const log = readTaskLog(cwd, t.id, 1); + const lastActivity = log.length > 0 ? Date.parse(log[log.length - 1].ts) : Date.parse(t.updatedAt || t.createdAt); + const silentMs = now - (isNaN(lastActivity) ? now : lastActivity); + const key = `${t.id}:stale`; + if (silentMs >= cfg.staleInProgressMs && cooledDown(key, cfg.staleInProgressMs, now)) { + lastAlertAt.set(key, now); + alerts++; + const architect = architectName(config); + try { + createMessage(cwd, { + from: 'agenthub', + to: architect, + text: `Watchdog: ${t.id} is in_progress by ${t.claimedBy ?? t.assignedTo ?? '?'} but silent for ${Math.round(silentMs / 60_000)}m — no auto-reassign, your call.`, + taskId: t.id, + }); + } catch { + /* best-effort */ + } + logAlert(cwd, t.id, `Watchdog: silent for ${Math.round(silentMs / 60_000)}m — alerted ${architect}`); + } + } + } + + return alerts; +} + +/** + * Start the watchdog loop. Returns a stop function (clears the timer). + * The interval is unref'd so it can never keep a process alive on its own. + */ +export function startWatchdog(cwd: string): () => void { + let timer: NodeJS.Timeout | undefined; + let cfg: WatchdogConfig; + try { + cfg = loadConfig(cwd).watchdog; + } catch { + return () => { /* no config, no watchdog */ }; + } + if (!cfg.enabled) return () => { /* disabled via config */ }; + + timer = setInterval(() => { + try { + runWatchdogScan(cwd); + } catch { + /* the watchdog must never crash the server */ + } + }, cfg.intervalMs); + timer.unref?.(); + + return () => { + if (timer) clearInterval(timer); + }; +} + +/** Test hook: drop all cooldown state. */ +export function resetWatchdog(): void { + lastAlertAt.clear(); +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..0c63845 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,2 @@ +/** Single source of truth for the agenthub version (package.json, CLI, MCP, /health). */ +export const VERSION = '0.10.2'; diff --git a/tests/architectBudget.test.ts b/tests/architectBudget.test.ts new file mode 100644 index 0000000..fc26b6e --- /dev/null +++ b/tests/architectBudget.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { init } from '../src/cli/commands/init.js'; +import { createTask, reviewTask, doneTask } from '../src/core/services/taskService.js'; +import { createHandoff } from '../src/core/services/handoffService.js'; +import { createMessage } from '../src/core/services/messageService.js'; +import { computeBudget, TOKENS_PER_COORDINATION_ACTION } from '../src/core/services/budgetService.js'; + +/** Architect coordination work must be visible in the budget report (TSK-0226). */ +describe('budget: architect activity', () => { + let cwd: string; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'ah-budget-')); + init(cwd, { projectName: 'budget-test', yes: true }); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('counts reviews, handoffs and messages as architect activity', () => { + // Implementer-owned task; the architect only coordinates around it. + createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' }); + reviewTask(cwd, 'TSK-0001', 'claude'); + createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', fromAgent: 'claude', toAgent: 'codex', taskId: 'TSK-0001', summary: 'scope' }); + createMessage(cwd, { from: 'claude', to: 'codex', text: 'please review' }); + + const report = computeBudget(cwd); + const claude = report.agents.find((a) => a.name === 'claude'); + expect(claude).toBeDefined(); + // reviewer + handoff + message = 3 actions, all estimated (never silent fabrication). + expect(claude!.actions).toBe(3); + expect(claude!.tokens).toBe(3 * TOKENS_PER_COORDINATION_ACTION); + expect(claude!.estimated).toBe(true); + expect(claude!.estimatedTokens).toBe(claude!.tokens); + expect(claude!.costEur).toBeGreaterThan(0); + }); + + it('a done approval by someone other than the owner counts as an action', () => { + createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' }); + reviewTask(cwd, 'TSK-0001', 'claude'); + // Architect approves: doneBy = claude, owner = codex. + doneTask(cwd, 'TSK-0001', { doneBy: 'claude', doneTokens: 8000 }); + + const report = computeBudget(cwd); + const claude = report.agents.find((a) => a.name === 'claude')!; + // review + approval; the implementer keeps the real task tokens. + expect(claude.actions).toBe(2); + const codex = report.agents.find((a) => a.name === 'codex')!; + expect(codex.realTokens).toBe(8000); + // No double count: claude's action estimate does not include the 8000. + expect(claude.tokens).toBe(2 * TOKENS_PER_COORDINATION_ACTION); + }); +}); diff --git a/tests/health.test.ts b/tests/health.test.ts new file mode 100644 index 0000000..c1461cb --- /dev/null +++ b/tests/health.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { buildApp } from '../src/server/index.js'; +import { init } from '../src/cli/commands/init.js'; +import { resetPresence } from '../src/core/services/presenceService.js'; +import { VERSION } from '../src/version.js'; + +interface HealthBody { + status: string; + version: string; + startedAt: string; + uptimeSec: number; + counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number }; + agents: Array<{ name: string; role: string; state: string; taskId?: string; lastSeen?: string; lastSeenAgoSec?: number }>; +} + +describe('GET /health + lastSeen stamping (TSK-0226)', () => { + let cwd: string; + let app: ReturnType; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'ah-health-')); + init(cwd, { projectName: 'health-test', yes: true }); + resetPresence(); + app = buildApp(cwd); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + const getHealth = async (): Promise => { + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(200); + return JSON.parse(res.payload) as HealthBody; + }; + + it('returns the compact hub health shape', async () => { + const h = await getHealth(); + expect(h.status).toBe('ok'); + expect(h.version).toBe(VERSION); + expect(typeof h.uptimeSec).toBe('number'); + expect(h.uptimeSec).toBeGreaterThanOrEqual(0); + expect(typeof h.startedAt).toBe('string'); + expect(h.counts).toMatchObject({ tasks: 0, open: 0, inProgress: 0, review: 0, unreadMessages: 0 }); + expect(Array.isArray(h.agents)).toBe(true); + // The init roster seeds the default preferred agents. + expect(h.agents.map((a) => a.name)).toContain('claude'); + }); + + it('stamps lastSeen on announce → agent shows active', async () => { + await app.inject({ method: 'POST', url: '/announce', payload: { agent: 'kimi', role: 'implementer' } }); + const h = await getHealth(); + const kimi = h.agents.find((a) => a.name === 'kimi'); + expect(kimi).toBeDefined(); + expect(kimi!.state).toBe('active'); + expect(typeof kimi!.lastSeen).toBe('string'); + expect(kimi!.lastSeenAgoSec).toBeLessThan(120); + }); + + it('stamps lastSeen on message send and counts the unread message', async () => { + await app.inject({ method: 'POST', url: '/messages', payload: { from: 'codex', to: 'claude', text: 'ping' } }); + const h = await getHealth(); + const codex = h.agents.find((a) => a.name === 'codex'); + expect(codex!.state).toBe('active'); + expect(h.counts.unreadMessages).toBe(1); + }); + + it('stamps lastSeen on claim → agent shows busy-on-TSK-X', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer', assignedTo: 'codex' } }); + await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } }); + const h = await getHealth(); + const codex = h.agents.find((a) => a.name === 'codex'); + expect(codex!.state).toBe('busy'); + expect(codex!.taskId).toBe('TSK-0001'); + expect(h.counts.inProgress).toBe(1); + }); + + it('stamps lastSeen on task-log lines and review submissions', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer', assignedTo: 'kimi' } }); + await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } }); + await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'working', agent: 'kimi' } }); + await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); + const h = await getHealth(); + const kimi = h.agents.find((a) => a.name === 'kimi'); + expect(kimi!.state).toBe('active'); + expect(kimi!.lastSeen).toBeDefined(); + expect(h.counts.review).toBe(1); + }); +}); diff --git a/tests/watchdog.test.ts b/tests/watchdog.test.ts new file mode 100644 index 0000000..ee9ec58 --- /dev/null +++ b/tests/watchdog.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { init } from '../src/cli/commands/init.js'; +import { loadConfig, saveConfig } from '../src/core/config.js'; +import { createTask, claimTask, getTask } from '../src/core/services/taskService.js'; +import { listMessages } from '../src/core/services/messageService.js'; +import { readTaskLog, appendTaskLog } from '../src/core/services/taskLogService.js'; +import { startWatchdog, resetWatchdog } from '../src/server/watchdog.js'; +import { eventBus, type AgentHubEvent } from '../src/server/events.js'; + +/** + * Watchdog thresholds (TSK-0226). Fake timers drive both the scan interval and + * the task ages (Date is mocked, so createdAt/updatedAt advance with the clock). + * Thresholds are shrunk via the `watchdog` config section to keep the test fast. + */ +describe('watchdog', () => { + let cwd: string; + let stop: (() => void) | undefined; + let events: AgentHubEvent[]; + const onChange = (e: AgentHubEvent) => events.push(e); + + // Small thresholds: interval 1s, high-prio unclaimed 2s, default 5s, stale 4s. + const INTERVAL = 1_000; + const HIGH = 2_000; + const DEFAULT = 5_000; + const STALE = 4_000; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'ah-watchdog-')); + init(cwd, { projectName: 'watchdog-test', yes: true }); + const config = loadConfig(cwd); + config.watchdog = { + enabled: true, + intervalMs: INTERVAL, + unclaimedHighMs: HIGH, + unclaimedDefaultMs: DEFAULT, + staleInProgressMs: STALE, + }; + saveConfig(cwd, config); + resetWatchdog(); + events = []; + eventBus.on('change', onChange); + vi.useFakeTimers(); + }); + + afterEach(() => { + stop?.(); + stop = undefined; + eventBus.off('change', onChange); + vi.useRealTimers(); + rmSync(cwd, { recursive: true, force: true }); + }); + + const remindersFor = (agent: string) => + listMessages(cwd).filter((m) => m.to === agent && m.text.startsWith('Reminder:')); + + it('re-notifies an unclaimed high-priority task: SSE re-emit + unread reminder + warn log', async () => { + createTask(cwd, { title: 'Urgent fix', priority: 'high', assignedTo: 'codex' }); + stop = startWatchdog(cwd); + + // Below the threshold: nothing happens. + await vi.advanceTimersByTimeAsync(INTERVAL); + expect(remindersFor('codex')).toHaveLength(0); + + // Past the 2s high-priority threshold (next tick at t=2s fires the alert). + await vi.advanceTimersByTimeAsync(INTERVAL + 100); + + // 1. Reminder message lands as UNREAD so the assignee's work loop wakes. + const reminders = remindersFor('codex'); + expect(reminders).toHaveLength(1); + expect(reminders[0].status).toBe('unread'); + expect(reminders[0].text).toBe('Reminder: TSK-0001 wartet auf dich'); + expect(reminders[0].taskId).toBe('TSK-0001'); + + // 2. The task event is re-emitted on the bus (SSE fan-out). + expect(events.some((e) => e.type === 'task' && e.id === 'TSK-0001' && e.assignedTo === 'codex')).toBe(true); + + // 3. Board-visible warn line on the task's live console. + const log = readTaskLog(cwd, 'TSK-0001'); + expect(log.some((l) => l.level === 'warn' && l.text.includes('Watchdog'))).toBe(true); + }); + + it('does not spam: re-alerts only after the threshold elapses again', async () => { + createTask(cwd, { title: 'Urgent fix', priority: 'critical', assignedTo: 'codex' }); + stop = startWatchdog(cwd); + + await vi.advanceTimersByTimeAsync(HIGH + 100); // first alert at t≈2s + expect(remindersFor('codex')).toHaveLength(1); + + // One more interval tick — still inside the cooldown window. + await vi.advanceTimersByTimeAsync(INTERVAL); + expect(remindersFor('codex')).toHaveLength(1); + + // Threshold elapsed again since the last alert → second reminder. + await vi.advanceTimersByTimeAsync(HIGH); + expect(remindersFor('codex')).toHaveLength(2); + }); + + it('uses the longer threshold for medium/low priority tasks', async () => { + createTask(cwd, { title: 'Routine', priority: 'medium', assignedTo: 'kimi' }); + stop = startWatchdog(cwd); + + // Past the high-priority threshold but below the default one: no alert. + await vi.advanceTimersByTimeAsync(HIGH + INTERVAL); + expect(remindersFor('kimi')).toHaveLength(0); + + // Past the 5s default threshold. + await vi.advanceTimersByTimeAsync(DEFAULT); + expect(remindersFor('kimi')).toHaveLength(1); + }); + + it('alerts the architect about a silent in_progress task — without reassigning', async () => { + createTask(cwd, { title: 'WIP', priority: 'high', assignedTo: 'kimi' }); + claimTask(cwd, 'TSK-0001', 'kimi'); + stop = startWatchdog(cwd); + + // No task-log lines at all; silent past the stale threshold. + await vi.advanceTimersByTimeAsync(STALE + INTERVAL); + + // Architect (init default: claude) got the alert; the assignee got none. + const architectAlerts = listMessages(cwd).filter((m) => m.to === 'claude' && m.text.startsWith('Watchdog:')); + expect(architectAlerts).toHaveLength(1); + expect(architectAlerts[0].status).toBe('unread'); + expect(architectAlerts[0].text).toContain('TSK-0001'); + expect(remindersFor('kimi')).toHaveLength(0); + + // Visibility only: the task still belongs to kimi (no auto-reassign). + const { task } = getTask(cwd, 'TSK-0001'); + expect(task.status).toBe('in_progress'); + expect(task.assignedTo).toBe('kimi'); + expect(task.claimedBy).toBe('kimi'); + }); + + it('a fresh task-log line keeps an in_progress task from being flagged stale', async () => { + createTask(cwd, { title: 'Chatty WIP', priority: 'high', assignedTo: 'kimi' }); + claimTask(cwd, 'TSK-0001', 'kimi'); + stop = startWatchdog(cwd); + + // Log activity well inside the stale window, repeatedly: never flagged. + for (let i = 0; i < 4; i++) { + await vi.advanceTimersByTimeAsync(STALE - 2 * INTERVAL); // 2s < 4s stale window + appendTaskLog(cwd, 'TSK-0001', { text: `progress ${i}`, agent: 'kimi' }); + } + expect(listMessages(cwd).filter((m) => m.text.startsWith('Watchdog:'))).toHaveLength(0); + }); +});