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",
|
"name": "agenthub",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"description": "Local coordination layer for AI coding agents",
|
"description": "Local coordination layer for AI coding agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"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 { findProjectRoot } from '../core/paths.js';
|
||||||
import { discoverServer, resolveReachableServerUrl } from '../discovery.js';
|
import { discoverServer, resolveReachableServerUrl } from '../discovery.js';
|
||||||
import { remoteClient, RemoteError } from './remoteClient.js';
|
import { remoteClient, RemoteError } from './remoteClient.js';
|
||||||
|
import { printHealth } from './commands/health.js';
|
||||||
|
import { VERSION } from '../version.js';
|
||||||
|
|
||||||
interface ResolvedContext {
|
interface ResolvedContext {
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
@ -138,7 +140,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
|
|||||||
export function createProgram(cwd: string): Command {
|
export function createProgram(cwd: string): Command {
|
||||||
const program = new Command('agenthub')
|
const program = new Command('agenthub')
|
||||||
.description('Local coordination layer for AI coding agents')
|
.description('Local coordination layer for AI coding agents')
|
||||||
.version('0.10.1')
|
.version(VERSION)
|
||||||
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||||
|
|
||||||
program
|
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');
|
const memoryCmd = new Command('memory').description('Manage memory entries');
|
||||||
memoryCmd
|
memoryCmd
|
||||||
.command('add')
|
.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 { IndexEntry } from '../core/index.js';
|
||||||
import type { InboxMessage } from '../core/services/messageService.js';
|
import type { InboxMessage } from '../core/services/messageService.js';
|
||||||
import type { TaskLogEntry } from '../core/services/taskLogService.js';
|
import type { TaskLogEntry } from '../core/services/taskLogService.js';
|
||||||
|
import type { HealthReport } from '../core/services/presenceService.js';
|
||||||
|
|
||||||
export class RemoteError extends Error {
|
export class RemoteError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
@ -38,6 +39,10 @@ export const remoteClient = {
|
|||||||
return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
|
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> {
|
async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> {
|
||||||
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
|
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
import { getConfigPath } from './paths.js';
|
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 {
|
export function defaultConfig(projectName: string): Config {
|
||||||
return {
|
return {
|
||||||
@ -15,6 +15,7 @@ export function defaultConfig(projectName: string): Config {
|
|||||||
tester: { preferredAgent: 'codex' },
|
tester: { preferredAgent: 'codex' },
|
||||||
},
|
},
|
||||||
serverUrl: undefined,
|
serverUrl: undefined,
|
||||||
|
watchdog: WatchdogConfigSchema.parse({}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -187,6 +187,23 @@ export const OrgNodeSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type OrgNode = z.infer<typeof OrgNodeSchema>;
|
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({
|
export const ConfigSchema = z.object({
|
||||||
version: z.literal('1'),
|
version: z.literal('1'),
|
||||||
projectName: z.string().min(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. */
|
/** Team org chart (arbitrary depth). When present, /team renders this tree. */
|
||||||
org: z.array(OrgNodeSchema).optional(),
|
org: z.array(OrgNodeSchema).optional(),
|
||||||
serverUrl: z.string().url().optional(),
|
serverUrl: z.string().url().optional(),
|
||||||
|
/** Watchdog thresholds; absent config ⇒ sane defaults. */
|
||||||
|
watchdog: WatchdogConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Task = z.infer<typeof TaskSchema>;
|
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 Ask = z.infer<typeof AskSchema>;
|
||||||
export type Status = z.infer<typeof StatusSchema>;
|
export type Status = z.infer<typeof StatusSchema>;
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
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 { 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';
|
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;
|
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). */
|
/** USD → EUR display rate (rough, labelled approximate in the UI). */
|
||||||
const USD_TO_EUR = 0.92;
|
const USD_TO_EUR = 0.92;
|
||||||
|
|
||||||
@ -121,6 +132,9 @@ export interface AgentBudget {
|
|||||||
/** costEur / budgetEur, clamped 0..1 (only when a budget is set). */
|
/** costEur / budgetEur, clamped 0..1 (only when a budget is set). */
|
||||||
budgetUsed?: number;
|
budgetUsed?: number;
|
||||||
taskCount: number;
|
taskCount: number;
|
||||||
|
/** Coordination actions attributed to this agent (reviews, handoffs,
|
||||||
|
* messages, approvals) — the counter behind the activity estimate. */
|
||||||
|
actions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BudgetReport {
|
export interface BudgetReport {
|
||||||
@ -153,6 +167,7 @@ export function computeBudget(cwd: string): BudgetReport {
|
|||||||
costEur: 0,
|
costEur: 0,
|
||||||
budgetEur: r?.budgetEur,
|
budgetEur: r?.budgetEur,
|
||||||
taskCount: 0,
|
taskCount: 0,
|
||||||
|
actions: 0,
|
||||||
};
|
};
|
||||||
acc.set(name, a);
|
acc.set(name, a);
|
||||||
}
|
}
|
||||||
@ -161,7 +176,8 @@ export function computeBudget(cwd: string): BudgetReport {
|
|||||||
// Seed rostered agents so they show even at zero.
|
// Seed rostered agents so they show even at zero.
|
||||||
for (const r of roster) ensure(r.name);
|
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;
|
const owner = t.assignedTo;
|
||||||
if (!owner) continue;
|
if (!owner) continue;
|
||||||
const { tokens, estimated } = tokensForTask(
|
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());
|
const agents = Array.from(acc.values());
|
||||||
for (const a of agents) {
|
for (const a of agents) {
|
||||||
a.costEur = costEur(a.tokens, a.model);
|
a.costEur = costEur(a.tokens, a.model);
|
||||||
@ -201,7 +261,7 @@ export function computeBudget(cwd: string): BudgetReport {
|
|||||||
totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated },
|
totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated },
|
||||||
assumptions: {
|
assumptions: {
|
||||||
tokensPerActiveMin: TOKENS_PER_ACTIVE_MIN,
|
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(),
|
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 { createDecision } from '../core/services/decisionService.js';
|
||||||
import { getStatus } from '../core/services/statusService.js';
|
import { getStatus } from '../core/services/statusService.js';
|
||||||
import { discoverServer as discoverHubServer } from '../discovery.js';
|
import { discoverServer as discoverHubServer } from '../discovery.js';
|
||||||
|
import { VERSION } from '../version.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AgentHub MCP server (TSK-0030).
|
* AgentHub MCP server (TSK-0030).
|
||||||
@ -165,7 +166,7 @@ function waitForTask<T>(
|
|||||||
export async function startMcpServer(cwd: string): Promise<void> {
|
export async function startMcpServer(cwd: string): Promise<void> {
|
||||||
const { root, serverUrl } = resolveContext(cwd);
|
const { root, serverUrl } = resolveContext(cwd);
|
||||||
const remote = !!serverUrl;
|
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.',
|
server.tool('agenthub_hello', 'Announce yourself to the hub (presence). Run once when you start.',
|
||||||
{ agent: z.string(), role: z.string().optional() },
|
{ agent: z.string(), role: z.string().optional() },
|
||||||
|
|||||||
@ -22,6 +22,10 @@ export function sidebarHtml(): string {
|
|||||||
</div>
|
</div>
|
||||||
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
|
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></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">
|
<div class="b2-panel b2-glass">
|
||||||
<h6>Live</h6>
|
<h6>Live</h6>
|
||||||
<div class="b2-feed" id="b2Feed"></div>
|
<div class="b2-feed" id="b2Feed"></div>
|
||||||
@ -61,5 +65,28 @@ window.__b2FeedPush = function (html) {
|
|||||||
feed.insertBefore(div, feed.firstChild);
|
feed.insertBefore(div, feed.firstChild);
|
||||||
while (feed.children.length > 5) feed.removeChild(feed.lastChild);
|
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}`;
|
${v1BudgetJs}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -323,6 +323,17 @@ export function boardV2Css(): string {
|
|||||||
.b2-feed .pulse { color: var(--b2-green); animation: b2-blink 1.6s infinite; }
|
.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; }
|
.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) ───────────────────────────────────────────── */
|
/* ── Modals (ported v1) ───────────────────────────────────────────── */
|
||||||
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start;
|
.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);
|
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 { startMdnsAdvertise, type MdnsHandle } from './mdns.js';
|
||||||
import { startEntityWatcher } from './fsWatch.js';
|
import { startEntityWatcher } from './fsWatch.js';
|
||||||
import { startStatusAutoRefresh } from './statusRefresh.js';
|
import { startStatusAutoRefresh } from './statusRefresh.js';
|
||||||
|
import { startWatchdog } from './watchdog.js';
|
||||||
|
|
||||||
export function buildApp(cwd: string) {
|
export function buildApp(cwd: string) {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
@ -32,11 +33,14 @@ export async function startServer(cwd: string, options: { port?: number; host?:
|
|||||||
const stopWatcher = startEntityWatcher(cwd);
|
const stopWatcher = startEntityWatcher(cwd);
|
||||||
// Keep status/latest.md fresh on every change so agents never read a stale snapshot.
|
// Keep status/latest.md fresh on every change so agents never read a stale snapshot.
|
||||||
const stopStatusRefresh = startStatusAutoRefresh(cwd);
|
const stopStatusRefresh = startStatusAutoRefresh(cwd);
|
||||||
|
// Stuck-task watchdog: re-notify unclaimed priority tasks, flag silent ones.
|
||||||
|
const stopWatchdog = startWatchdog(cwd);
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
broadcaster?.stop();
|
broadcaster?.stop();
|
||||||
mdns?.stop();
|
mdns?.stop();
|
||||||
stopWatcher();
|
stopWatcher();
|
||||||
stopStatusRefresh();
|
stopStatusRefresh();
|
||||||
|
stopWatchdog();
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { getStatus, updateStatus } from '../core/services/statusService.js';
|
|||||||
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
||||||
import { computeBudget } from '../core/services/budgetService.js';
|
import { computeBudget } from '../core/services/budgetService.js';
|
||||||
import { getRoster } from '../core/services/rosterService.js';
|
import { getRoster } from '../core/services/rosterService.js';
|
||||||
|
import { computeHealth, stampSeen } from '../core/services/presenceService.js';
|
||||||
import { loadConfig, saveConfig } from '../core/config.js';
|
import { loadConfig, saveConfig } from '../core/config.js';
|
||||||
import { renderActivityHtml } from './activity.js';
|
import { renderActivityHtml } from './activity.js';
|
||||||
import { renderBoardHtml } from './board/index.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> {
|
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
|
// Default dynamic responses to no-store so board reloads/fetches never reuse
|
||||||
// stale task JSON or HTML. Static asset routes override this with cacheable
|
// stale task JSON or HTML. Static asset routes override this with cacheable
|
||||||
// headers, and the SSE route writes its own raw no-cache header.
|
// 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');
|
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
|
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and
|
||||||
// /decisions on the same origin; no build step, no deps. Cached once — the
|
// /decisions on the same origin; no build step, no deps. Cached once — the
|
||||||
// markup is constant, only the data it fetches changes.
|
// 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) => {
|
app.post('/announce', async (request, reply) => {
|
||||||
const { agent, role, action } = request.body as { agent?: string; role?: string; action?: string };
|
const { agent, role, action } = request.body as { agent?: string; role?: string; action?: string };
|
||||||
if (!agent) return badRequest(reply, 'agent is required');
|
if (!agent) return badRequest(reply, 'agent is required');
|
||||||
|
stampSeen(agent);
|
||||||
const ev: AgentHubEvent = {
|
const ev: AgentHubEvent = {
|
||||||
type: 'agent',
|
type: 'agent',
|
||||||
action: action === 'left' ? 'left' : 'joined',
|
action: action === 'left' ? 'left' : 'joined',
|
||||||
@ -314,6 +322,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid log line');
|
return badRequest(reply, err instanceof Error ? err.message : 'Invalid log line');
|
||||||
}
|
}
|
||||||
|
if (agent) stampSeen(agent);
|
||||||
eventBus.publishLog({ taskId: id, ...entry });
|
eventBus.publishLog({ taskId: id, ...entry });
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
@ -358,6 +367,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return badRequest(reply, err instanceof Error ? err.message : 'Cannot claim task');
|
return badRequest(reply, err instanceof Error ? err.message : 'Cannot claim task');
|
||||||
}
|
}
|
||||||
|
stampSeen(agent);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'done':
|
case 'done':
|
||||||
@ -366,9 +376,12 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
doneTokens: patch.doneTokens as number | undefined,
|
doneTokens: patch.doneTokens as number | undefined,
|
||||||
doneDuration: patch.doneDuration as number | undefined,
|
doneDuration: patch.doneDuration as number | undefined,
|
||||||
});
|
});
|
||||||
|
if (task.doneBy) stampSeen(task.doneBy);
|
||||||
break;
|
break;
|
||||||
case 'review':
|
case 'review':
|
||||||
task = reviewTask(cwd, id, patch.reviewer);
|
task = reviewTask(cwd, id, patch.reviewer);
|
||||||
|
// The submitter (assignee) is the acting agent here.
|
||||||
|
if (task.assignedTo) stampSeen(task.assignedTo);
|
||||||
break;
|
break;
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
task = cancelTask(cwd, id);
|
task = cancelTask(cwd, id);
|
||||||
@ -487,6 +500,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid message');
|
return badRequest(reply, err instanceof Error ? err.message : 'Invalid message');
|
||||||
}
|
}
|
||||||
|
stampSeen(message.from);
|
||||||
emitChange(
|
emitChange(
|
||||||
{
|
{
|
||||||
type: 'message',
|
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