Am 09.08. stellte sich heraus, dass fuenf Asks auf pending standen, zwei seit einer Woche. Vier Agenten waren blockiert — darunter einer, der einen fertigen Windows-Build fuer einen externen Tester nicht ausliefern durfte. Der Architekt hat die ganze Zeit das Board gepollt und nichts davon gesehen. Aufgefallen ist es nur, weil ein Agent den Stau selbst benannt hat. TEIL 1 — SICHTBARKEIT (Umsetzung: codex) Asks sind ein eigener, BLOCKIERENDER Kanal, den keine der Oberflaechen erwaehnte, die ein Architekt regelmaessig ansieht: - core/status.ts nennt jetzt Anzahl, IDs und Alter des aeltesten Asks. Das Alter ist der eigentliche Alarm: "einer offen" ist normal, "seit sechs Tagen offen" ist ein Notfall. - /health liefert pendingAsks + oldestAskAgeSec — der billigste Statuscheck des Architekten; was dort nicht steht, existiert fuer ihn nicht. - Der Watchdog kannte Asks gar nicht. Er meldete brav "silent for 16m", also das Symptom, waehrend die Ursache unsichtbar blieb. Jetzt alarmiert er bei ueberfaelligen Asks mit steigender Dringlichkeit, und die irrefuehrende "silent"-Meldung wird bei einem offenen Ask ersetzt durch "wartet seit Xh auf ASK-NNNN — bitte antworten, nicht neu starten". Genau diese Verwechslung fuehrte dazu, dass wartende Agenten fuer tot gehalten und ihre Sessions neu gestartet wurden. - watch --await-ask weckt den Architekten bei neuen Asks (mit --new-only). - Das Board zeigt offene Asks mit Alter und wartendem Agenten. TEIL 2 — DIE FLUT (Umsetzung: claude) Der Watchdog erinnerte einen nicht beanspruchten Task unbegrenzt im Basisintervall weiter — auch an Agenten, von denen er WUSSTE, dass sie nicht im work-Loop sind. Ergebnis: ueber 1500 ungelesene Nachrichten fuer einen einzigen Agenten, dessen Loop daran nicht mehr anlief. Ein Alarm, der den Empfaenger handlungsunfaehig macht, ist schlimmer als kein Alarm. - Ist der Assignee nachweislich aus dem Loop ausgestiegen (nicht: nie gesehen), wird KEINE Erinnerung mehr in sein Postfach geschrieben. Eine Nachricht ist ein dauerhaftes Artefakt; in ein Postfach zu schreiben, das niemand liest, baut nur den Rueckstau auf, der spaeter seinen eigenen Loop blockiert. Stattdessen genau EIN Hinweis an den Architekten — ein Session-Neustart ist eine menschliche Entscheidung. - Das ephemere SSE-Reemit bleibt in beiden Zweigen: es kostet nichts und erreicht womoeglich einen Agenten, der gerade neu verbindet. - Fuer erreichbare Agenten waechst der Abstand exponentiell (Basis x 2^n, gedeckelt bei 8x) statt konstant zu bleiben. - Der Zaehler wird zurueckgesetzt, sobald der Task beansprucht wird, damit ein spaeteres Reopen nicht in einem halb stummgeschalteten Zustand startet. Der bestehende Test "does not spam" pruefte, dass nach erneutem Ablauf der BASIS-Schwelle eine zweite Erinnerung kommt — also genau das Verhalten, das die Flut erzeugt hat. Er ist auf den Backoff angepasst; seine Absicht (Anti-Spam) gilt jetzt ueber die Lebensdauer eines Tasks statt nur ueber ein Cooldown-Fenster. 316 Tests gruen, tsc --noEmit sauber. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
5.2 KiB
TypeScript
124 lines
5.2 KiB
TypeScript
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; pendingAsks: number; oldestAskAgeSec?: number };
|
|
agents: Array<{
|
|
name: string; role: string; state: string; taskId?: string; lastSeen?: string; lastSeenAgoSec?: number;
|
|
inLoop: boolean; loopSince?: string; loopExitReason?: string;
|
|
}>;
|
|
}
|
|
|
|
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, pendingAsks: 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('counts pending Asks and exposes the oldest age', async () => {
|
|
await app.inject({
|
|
method: 'POST', url: '/asks',
|
|
payload: { from: 'codex', to: 'claude', question: 'Need architecture input' },
|
|
});
|
|
|
|
const h = await getHealth();
|
|
expect(h.counts.pendingAsks).toBe(1);
|
|
expect(h.counts.oldestAskAgeSec).toBeTypeOf('number');
|
|
expect(h.counts.oldestAskAgeSec).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
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('shows work-loop entry and the latest exit reason', async () => {
|
|
await app.inject({ method: 'POST', url: '/agents/codex/loop', payload: { active: true } });
|
|
let codex = (await getHealth()).agents.find((a) => a.name === 'codex')!;
|
|
expect(codex.inLoop).toBe(true);
|
|
expect(codex.loopSince).toBeDefined();
|
|
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/agents/codex/loop',
|
|
payload: { active: false, reason: 'client timeout' },
|
|
});
|
|
codex = (await getHealth()).agents.find((a) => a.name === 'codex')!;
|
|
expect(codex.inLoop).toBe(false);
|
|
expect(codex.loopExitReason).toBe('client timeout');
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|