Vom CEO gefunden: TSK-0293 stand auf in_progress bei codex — und codex tat nichts. Ursache: `findAddressedOpenTask` sucht ausschliesslich Tasks mit Status `open`. Haelt ein Agent bereits eine in_progress-Task (typisch nach einem Session-Neustart), greift der Busy-Guard und der Loop liefert NICHTS zurueck. Der Agent wartet dann auf neue Arbeit, die nie kommt, waehrend seine eigene Task unbearbeitet liegt — und von aussen sieht es aus, als sei er tot. `resolvePending` liefert jetzt `heldTask`, und alle drei Wege sagen es dem Agenten deutlich: - `agenthub start <agent>`: "DU HAELTST BEREITS <id>" ganz oben - `agenthub work` (CLI): nennt die Task und beendet sich, statt zu warten - `agenthub_work` (MCP): gibt Task samt Body zurueck statt zu blockieren Tests: 295 → 297. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
212 lines
7.9 KiB
TypeScript
212 lines
7.9 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, markMessageDelivered } from '../src/core/services/messageService.js';
|
|
import { resolvePending, hasPending } from '../src/core/services/checkinService.js';
|
|
import { createAsk, answerAsk } from '../src/core/services/askService.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(/unbeantwortete/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);
|
|
});
|
|
});
|
|
|
|
describe('resolvePending — beantwortete Fragen (Ask-Timeout-Lücke)', () => {
|
|
it('liefert die Antwort auf eine eigene Frage nach — auch lange nach dem 300s-Wait', () => {
|
|
const ask = createAsk(cwd, { from: 'kimi', to: 'claude', question: 'Welcher Weg?' });
|
|
answerAsk(cwd, ask.id, 'Nimm Variante B.', 'claude');
|
|
|
|
const pending = resolvePending(cwd, 'kimi');
|
|
|
|
// agenthub_ask(wait) gibt nach 300s auf; braucht der Architekt laenger,
|
|
// erreicht die Antwort den dormanten Agenten sonst NIE.
|
|
expect(pending.answeredAsks.map((a) => a.id)).toContain(ask.id);
|
|
expect(pending.answeredAsks[0]?.answer).toContain('Variante B');
|
|
expect(pending.note).toMatch(/BEANTWORTET/);
|
|
expect(hasPending(pending)).toBe(true);
|
|
});
|
|
|
|
it('zeigt fremde Antworten nicht an', () => {
|
|
const ask = createAsk(cwd, { from: 'codex', to: 'claude', question: 'X?' });
|
|
answerAsk(cwd, ask.id, 'Y', 'claude');
|
|
expect(resolvePending(cwd, 'kimi').answeredAsks).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('resolvePending — `delivered` zaehlt weiter', () => {
|
|
it('zeigt eine bereits ausgelieferte, aber unbeantwortete Nachricht weiter an', () => {
|
|
const msg = createMessage(cwd, { from: 'claude', to: 'kimi', text: 'Korrektur: nimm doch Variante A.' });
|
|
markMessageDelivered(cwd, msg.id);
|
|
|
|
const pending = resolvePending(cwd, 'kimi');
|
|
|
|
// `delivered` heisst nur "einmal aufgetaucht". Filterte der Check-in auf
|
|
// `unread`, ginge eine Architekten-Korrektur still verloren.
|
|
expect(pending.unreadCount).toBe(1);
|
|
expect(hasPending(pending)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('resolvePending — Adressierung wie im Work-Loop', () => {
|
|
it('erkennt eine Task am Titel-Praefix, auch ohne assignedTo', () => {
|
|
// Genau der Fall, der den Session-Start luegen liess: Task traegt das
|
|
// Praefix, aber kein assignedTo — der Work-Loop haette sie sofort geclaimt.
|
|
const task = createTask(cwd, { title: 'codex: Blocker-Redesign', role: 'implementer' });
|
|
|
|
const pending = resolvePending(cwd, 'codex');
|
|
|
|
expect(pending.waitingTasks).toContain(task.id);
|
|
expect(hasPending(pending)).toBe(true);
|
|
});
|
|
|
|
it('beansprucht keine fremd adressierte Task', () => {
|
|
createTask(cwd, { title: 'kimi: nicht meins', role: 'implementer' });
|
|
expect(resolvePending(cwd, 'codex').waitingTasks).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('resolvePending — bereits gehaltene Task', () => {
|
|
it('nennt die in_progress-Task, die der Agent haelt', () => {
|
|
const id = openTaskFor('codex');
|
|
claimTask(cwd, id, 'codex');
|
|
|
|
const pending = resolvePending(cwd, 'codex');
|
|
|
|
// Der Work-Loop sucht nur `open` — ohne diesen Hinweis wartet ein Agent
|
|
// nach dem Neustart auf neue Arbeit, waehrend seine eigene liegt.
|
|
expect(pending.heldTask).toBe(id);
|
|
expect(pending.note).toMatch(/haeltst bereits/i);
|
|
expect(hasPending(pending)).toBe(true);
|
|
});
|
|
|
|
it('meldet keine fremde in_progress-Task als gehalten', () => {
|
|
const id = openTaskFor('kimi');
|
|
claimTask(cwd, id, 'kimi');
|
|
expect(resolvePending(cwd, 'codex').heldTask).toBeUndefined();
|
|
});
|
|
});
|