agenthub/tests/watchdog.test.ts
chahinebrini 6d088648da feat(hub): Korrektheits- und Realtime-Härtung — Alias, Lifecycle, Presence, Architekten-Pulse (TSK-0242/0245/0249)
Drei vom Architekten abgenommene Tasks, gebündelt als Checkpoint:

- TSK-0242: Agent-Alias-Mapping (kimi-ah → kimi kanonisiert, Rollen → preferredAgent),
  reopenTask räumt claimedBy ab, fsWatch reindiziert direkte Datei-Edits,
  work-Default 300s → 50s, task_list mit Limit.
- TSK-0245: zwei Agent-Klassen (dispatch loop|architect). Watchdog mahnt
  architekt-getriebene Agenten nur noch EINMAL statt im Minutentakt;
  `task dispatch` startet sie explizit, `task record` trägt extern
  erledigte Arbeit mit origin=external nach.
- TSK-0249: Lifecycle wird serverseitig erzwungen (open→review scheitert mit
  klarer Meldung), claimedBy/doneBy überleben bis done, Presence pro Agent,
  Review-Watchdog, GET /architect/pulse (1.4 kB statt 34 kB, since-Cursor,
  omitted statt stillem Abschneiden), unbekannter Agent → 400 statt 500.

Alle Punkte live am laufenden Hub nachgemessen, nicht aus Agenten-Logs übernommen.
Tests: 242 → 279 grün, tsc sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:48:24 +02:00

199 lines
8.1 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, reviewTask } 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';
import { enterLoop, leaveLoop, resetPresence } from '../src/core/services/presenceService.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;
const REVIEW = 3_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,
staleReviewMs: REVIEW,
};
saveConfig(cwd, config);
resetWatchdog();
resetPresence();
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 when an assigned task targets an agent known to be out of the work loop', async () => {
createTask(cwd, { title: 'Unheard task', priority: 'high', assignedTo: 'codex' });
enterLoop('codex');
leaveLoop('codex', 'client timeout');
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(HIGH + 100);
const alerts = listMessages(cwd).filter(
(m) => m.to === 'claude' && m.text.includes('not in agenthub_work'),
);
expect(alerts).toHaveLength(1);
expect(alerts[0].taskId).toBe('TSK-0001');
});
it('routes architect-dispatched agents to the architect once and never re-notifies the agent', async () => {
const config = loadConfig(cwd);
config.agents = {
...(config.agents ?? {}),
claude: { role: 'architect', dispatch: 'loop' },
backyard: { role: 'implementer', dispatch: 'architect' },
};
saveConfig(cwd, config);
createTask(cwd, { title: 'Backend task', priority: 'high', assignedTo: 'backyard' });
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(HIGH + 100);
expect(remindersFor('backyard')).toHaveLength(0);
const notices = () => listMessages(cwd).filter((m) => m.to === 'claude' && m.text.includes('wird von dir gestartet'));
expect(notices()).toHaveLength(1);
await vi.advanceTimersByTimeAsync(HIGH * 2);
expect(notices()).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);
});
it('alerts the architect when a review waits too long', async () => {
createTask(cwd, { title: 'Review me', assignedTo: 'kimi' });
claimTask(cwd, 'TSK-0001', 'kimi');
reviewTask(cwd, 'TSK-0001');
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(REVIEW + INTERVAL);
const alerts = listMessages(cwd).filter(
(m) => m.to === 'claude' && m.text.includes('waited in review'),
);
expect(alerts).toHaveLength(1);
expect(alerts[0].taskId).toBe('TSK-0001');
});
});