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:
parent
9551c90597
commit
7bf52a0be3
@ -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",
|
||||
|
||||
41
src/cli/commands/health.ts
Normal file
41
src/cli/commands/health.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
|
||||
@ -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({}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
123
src/core/services/presenceService.ts
Normal file
123
src/core/services/presenceService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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() },
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
172
src/server/watchdog.ts
Normal 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
2
src/version.ts
Normal file
@ -0,0 +1,2 @@
|
||||
/** Single source of truth for the agenthub version (package.json, CLI, MCP, /health). */
|
||||
export const VERSION = '0.10.2';
|
||||
57
tests/architectBudget.test.ts
Normal file
57
tests/architectBudget.test.ts
Normal 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
92
tests/health.test.ts
Normal 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
148
tests/watchdog.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user