agenthub/tests/checkinService.test.ts
chahinebrini 1f3126b3b5 feat(hub): Check-in-Kanal + Review bindet den Agenten — der Wurzelfix (TSK-0274/0273, DEC-0035)
WURZEL: Ein Agent empfängt SSE-Events ausschließlich, solange er in
agenthub_work blockiert. Während er einen Task AUSFÜHRT, ist er vollständig
taub — kein Reopen, kein Cancel, keine Nachricht erreicht ihn. Ein echter
Interrupt in einen laufenden Agenten-Turn existiert nicht. Heartbeat,
Watchdog, Polling-Fallback und Presence-Tracking waren allesamt Umgehungen
dieser einen Tatsache; deshalb kam das Problem jede Session zurück.

LÖSUNG — der einzige reale Kanal: die Momente, in denen der Agent von sich
aus mit dem Hub spricht.

- checkinService.resolvePending(): zustandslos aus Tasks + Messages
  abgeleitet, nichts, was ein Neustart verliert. Erkennt, dass dem Agenten
  die Arbeit entzogen wurde (reopen/cancel/fremder Claim), und liefert
  ungelesene Nachrichten + offene Zuweisungen mit.
- agenthub_task_log gibt den pending-Block zurück; der Tool-Text macht ihn
  verbindlich (pending.interrupted ⇒ sofort aufhören, nicht einreichen).
- Neu: agenthub_checkin(agent, taskId) für lange Strecken ohne Log-Zeile,
  plus GET /agents/:agent/pending.
- resolvePending degradiert auf reinen Namensvergleich, wenn keine Config da
  ist — ein Check-in darf nie an Konfiguration scheitern.

DEC-0035: `review` bindet den Agenten wie `in_progress`. Vorher galt er in
der Sekunde des Einreichens als frei, griff sich die nächste Task, und ein
Reopen prallte danach am Ein-Task-Guard ab (so ging der Reopen von TSK-0218
verloren). Der Loop bleibt aktiv — wach warten, nichts Neues anfangen.

Außerdem: in_progress → open erlaubt, damit der Architekt eine festhängende
Arbeit überhaupt entziehen kann (vorher 400, Agent blieb dauerhaft blockiert).

SICHTBARKEIT: /health und /agent-health zeigen pro Agent pendingCount
(wartende Tasks) und deafForSec (wie lange ohne Check-in bei laufender
Arbeit). Ungelesene Nachrichten stehen separat — der dreistellige
Altbestand einzelner Agenten hätte das Signal sonst erschlagen.

Tests: 279 → 290. tests/singleClaim (b) auf den neuen Vertrag umgeschrieben
(+ (b2): ein Reopen gewinnt gegen die nächste Task).

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

136 lines
4.8 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createTask, claimTask, reviewTask, reopenTask, cancelTask, assignTask } from '../src/core/services/taskService.js';
import { createMessage } from '../src/core/services/messageService.js';
import { resolvePending, hasPending } from '../src/core/services/checkinService.js';
/**
* TSK-0274 — der Check-in-Kanal.
*
* Kern der Sache: ein Agent, der einen Task AUSFÜHRT, empfängt keine Events.
* Diese Tests sichern den einzigen Weg ab, auf dem er trotzdem erfährt, dass
* sich etwas an SEINER Arbeit geändert hat.
*/
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'agenthub-checkin-'));
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
function openTaskFor(agent: string, title = 'test task') {
const task = createTask(cwd, { title: `${agent}: ${title}`, role: 'implementer' });
assignTask(cwd, task.id, agent);
return task.id;
}
describe('resolvePending — Reopen unter laufender Arbeit', () => {
it('meldet interrupted, wenn der gehaltene Task zurückgegeben wurde', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
reviewTask(cwd, id);
reopenTask(cwd, id);
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted).toBeDefined();
expect(pending.interrupted?.taskId).toBe(id);
expect(pending.interrupted?.status).toBe('open');
// Die Handlungsanweisung muss unmissverständlich sein — der Agent soll
// NICHT weiterbauen und NICHT einreichen.
expect(pending.interrupted?.action).toMatch(/STOPP/);
expect(hasPending(pending)).toBe(true);
});
it('meldet interrupted, wenn der Architekt eine laufende Arbeit entzieht (in_progress → open)', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
reopenTask(cwd, id); // Entzug ohne Umweg über review
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted?.taskId).toBe(id);
expect(pending.interrupted?.status).toBe('open');
});
it('meldet interrupted bei Abbruch', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
cancelTask(cwd, id);
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted?.status).toBe('cancelled');
expect(pending.interrupted?.reason).toMatch(/abgebrochen/i);
});
it('meldet KEIN interrupted, solange der Task normal läuft', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted).toBeUndefined();
expect(hasPending(pending)).toBe(false);
});
it('verwechselt Agenten nicht — fremder Claim gilt als interrupted', () => {
const id = openTaskFor('kimi');
claimTask(cwd, id, 'kimi');
// codex glaubt, an diesem Task zu arbeiten — tut er aber nicht.
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted?.taskId).toBe(id);
});
});
describe('resolvePending — Nachrichten und Zuweisungen', () => {
it('liefert ungelesene Nachrichten mit Vorschau', () => {
createMessage(cwd, { from: 'claude', to: 'codex', text: 'Bitte Reihenfolge umdrehen.' });
const pending = resolvePending(cwd, 'codex');
expect(pending.unreadCount).toBe(1);
expect(pending.messages[0]?.from).toBe('claude');
expect(pending.messages[0]?.preview).toContain('Reihenfolge');
expect(pending.note).toMatch(/ungelesene/i);
});
it('nennt offene, an den Agenten adressierte Tasks', () => {
const id = openTaskFor('codex');
const pending = resolvePending(cwd, 'codex');
expect(pending.waitingTasks).toContain(id);
});
it('nennt eingereichte Tasks, die auf das Review warten', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
reviewTask(cwd, id);
const pending = resolvePending(cwd, 'codex');
expect(pending.awaitingReview).toContain(id);
});
it('bleibt klein, wenn nichts anliegt', () => {
const pending = resolvePending(cwd, 'codex');
expect(hasPending(pending)).toBe(false);
expect(pending.note).toBeUndefined();
expect(JSON.stringify(pending).length).toBeLessThan(200);
});
it('begrenzt die Nachrichten-Vorschau, damit die Antwort klein bleibt', () => {
for (let i = 0; i < 12; i += 1) {
createMessage(cwd, { from: 'claude', to: 'codex', text: `Nachricht ${i} `.repeat(40) });
}
const pending = resolvePending(cwd, 'codex');
expect(pending.unreadCount).toBe(12);
expect(pending.messages.length).toBeLessThanOrEqual(3);
// Die Antwort fließt in JEDEN Log-Aufruf — sie darf nicht ausufern.
expect(JSON.stringify(pending).length).toBeLessThan(1500);
});
});