- 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).
149 lines
6.0 KiB
TypeScript
149 lines
6.0 KiB
TypeScript
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);
|
|
});
|
|
});
|