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).
This commit is contained in:
chahinebrini 2026-07-23 17:52:37 +02:00
parent 9551c90597
commit 7bf52a0be3
18 changed files with 801 additions and 6 deletions

View File

@ -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",

View File

@ -0,0 +1,41 @@
import type { HealthReport } from '../../core/services/presenceService.js';
/** `agenthub health` — compact hub status + per-agent traffic light. */
const LIGHT: Record<string, string> = {
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)}`);
}
}

View File

@ -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<void>): Promise<vo
export function createProgram(cwd: string): Command {
const program = new Command('agenthub')
.description('Local coordination layer for AI coding agents')
.version('0.10.1')
.version(VERSION)
.option('--server <url>', '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')

View File

@ -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<HealthReport> {
return request<HealthReport>(baseUrl, 'GET', '/health');
},
async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> {
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
},

View File

@ -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({}),
};
}

View File

@ -187,6 +187,23 @@ export const OrgNodeSchema = z.object({
});
export type OrgNode = z.infer<typeof OrgNodeSchema>;
/**
* 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<typeof TaskSchema>;
@ -207,3 +226,4 @@ export type Message = z.infer<typeof MessageSchema>;
export type Ask = z.infer<typeof AskSchema>;
export type Status = z.infer<typeof StatusSchema>;
export type Config = z.infer<typeof ConfigSchema>;
export type WatchdogConfig = z.infer<typeof WatchdogConfigSchema>;

View File

@ -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<string, number>();
const bump = (name: string | undefined, n = 1) => {
if (!name) return;
actionCount.set(name, (actionCount.get(name) ?? 0) + n);
};
const preferredByRole = new Map<string, string>();
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(),
};

View File

@ -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<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,
};
}

View File

@ -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<T>(
export async function startMcpServer(cwd: string): Promise<void> {
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() },

View File

@ -22,6 +22,10 @@ export function sidebarHtml(): string {
</div>
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
</div>
<div class="b2-panel b2-glass" id="b2AgentsPanel">
<h6>Agents</h6>
<div class="b2-agents" id="b2Agents"><div class="budget-empty">no agents yet</div></div>
</div>
<div class="b2-panel b2-glass">
<h6>Live</h6>
<div class="b2-feed" id="b2Feed"></div>
@ -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 = '<div class="budget-empty">no agents yet</div>'; 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 '<div class="b2-agent-row" title="' + esc(a.name) + ': ' + esc(detail) + '">' +
'<span class="b2-light st-' + esc(a.state) + '" aria-hidden="true"></span>' +
'<span class="b2-agent-name">' + esc(a.name) + '</span>' +
'<span class="b2-agent-state">' + esc(detail) + '</span></div>';
}).join('');
};
async function refreshAgentHealth() {
try { window.__b2RenderAgents(await getJSON('/health')); } catch (_) {}
}
refreshAgentHealth();
setInterval(refreshAgentHealth, 5000);
${v1BudgetJs}`;
}

View File

@ -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);

View File

@ -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 {

View File

@ -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<void> {
// 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',

172
src/server/watchdog.ts Normal file
View File

@ -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<string, number>();
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();
}

2
src/version.ts Normal file
View File

@ -0,0 +1,2 @@
/** Single source of truth for the agenthub version (package.json, CLI, MCP, /health). */
export const VERSION = '0.10.2';

View File

@ -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);
});
});

92
tests/health.test.ts Normal file
View File

@ -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<typeof buildApp>;
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<HealthBody> => {
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);
});
});

148
tests/watchdog.test.ts Normal file
View File

@ -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);
});
});