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>
This commit is contained in:
chahinebrini 2026-07-29 18:05:08 +02:00
parent 6d088648da
commit 1f3126b3b5
10 changed files with 473 additions and 27 deletions

View File

@ -89,10 +89,22 @@ export async function findAddressedOpenTask(
const a = identity.canonical.toLowerCase();
const matchesAgent = (value: unknown) => identity.names.has(String(value ?? '').toLowerCase());
const inProgress = ctx.serverUrl
? await remoteClient.listTasks(ctx.serverUrl, { status: 'in_progress' })
: svcListTasks(ctx.projectCwd, { status: 'in_progress' });
const busy = inProgress.some((t) => matchesAgent(t.claimedBy ?? t.assignedTo));
// DEC-0035 / TSK-0273: `review` zählt als GEHALTEN, nicht nur `in_progress`.
// Sonst gilt ein Agent in der Sekunde des Einreichens als frei und greift
// sich die nächste Task — weist der Architekt die Review danach zurück,
// blockiert der Ein-Task-Guard den Reopen und die zurückgewiesene Arbeit
// bleibt unbemerkt liegen (so ging der Reopen von TSK-0218 verloren).
// Der Loop bleibt trotzdem aktiv: der Agent wartet WACH auf das Approve,
// er fängt nur nichts Neues an.
const [inProgress, inReview] = await Promise.all([
ctx.serverUrl
? remoteClient.listTasks(ctx.serverUrl, { status: 'in_progress' })
: Promise.resolve(svcListTasks(ctx.projectCwd, { status: 'in_progress' })),
ctx.serverUrl
? remoteClient.listTasks(ctx.serverUrl, { status: 'review' })
: Promise.resolve(svcListTasks(ctx.projectCwd, { status: 'review' })),
]);
const busy = [...inProgress, ...inReview].some((t) => matchesAgent(t.claimedBy ?? t.assignedTo));
if (busy) return undefined;
const tasks = await listOpenRoleTasks(ctx);

View File

@ -129,6 +129,12 @@ export const remoteClient = {
return request<TaskLogEntry>(baseUrl, 'POST', `/tasks/${id}/log`, entry);
},
/** TSK-0274: was wartet auf diesen Agenten (Check-in-Kanal für Arbeitende). */
async getPending(baseUrl: string, agent: string, taskId?: string): Promise<unknown> {
const query = taskId ? `?taskId=${encodeURIComponent(taskId)}` : '';
return request<unknown>(baseUrl, 'GET', `/agents/${encodeURIComponent(agent)}/pending${query}`);
},
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
},

View File

@ -0,0 +1,145 @@
import { listTasks } from './taskService.js';
import { listMessages } from './messageService.js';
import { agentIdentity } from './identityService.js';
/**
* Check-in-Kanal (TSK-0274) der Rückkanal für ARBEITENDE Agenten.
*
* Hintergrund (die Wurzel, die uns sessionlang im Feuerwehrmodus hielt):
* Ein Agent empfängt SSE-Events ausschließlich, solange er in `agenthub_work`
* blockiert. Führt er einen Task aus, ist er vollständig taub kein Reopen,
* kein Cancel, keine Nachricht erreicht ihn, bis er von sich aus fertig wird.
* Ein echter Interrupt in einen laufenden Agenten-Turn existiert nicht.
*
* Deshalb nutzen wir den einzigen realen Kanal: die Momente, in denen der
* Agent von SICH AUS mit dem Hub spricht (allen voran `agenthub_task_log`,
* das wir nach jedem Teilschritt ohnehin verlangen). Jede solche Antwort
* trägt ab jetzt mit, was auf ihn wartet.
*
* Bewusst ZUSTANDSLOS aus Tasks + Messages abgeleitet kein Event-Log, kein
* In-Memory-Puffer, nichts, was ein Hub-Neustart verlieren kann. Ein Reopen
* ist erkennbar, weil `reopenTask` den Claim abräumt und `assignedTo` stehen
* lässt: die Task taucht dadurch wieder als "offen und an mich adressiert" auf.
*/
/** Wie viele Nachrichten-Vorschauen maximal mitfließen (Antwort bleibt klein). */
const MAX_MESSAGE_PREVIEWS = 3;
/** Auf diese Länge wird ein Nachrichten-Vorschautext gekürzt. */
const PREVIEW_CHARS = 120;
export interface PendingMessagePreview {
id: string;
from: string;
preview: string;
}
export interface PendingForAgent {
/**
* Gesetzt, wenn der Agent an einem Task arbeitet, der ihm nicht mehr gehört
* (reopened, cancelled, weggenommen). DAS ist der Fall, für den es den
* Kanal gibt: der Agent muss sofort aufhören, nicht erst am Ende erfahren.
*/
interrupted?: {
taskId: string;
status: string;
reason: string;
action: string;
};
/** Offene Tasks, die an den Agenten adressiert sind (inkl. Reopens). */
waitingTasks: string[];
/** Vom Agenten eingereichte Tasks, die auf das Architekten-Review warten. */
awaitingReview: string[];
unreadCount: number;
messages: PendingMessagePreview[];
/** Klartext für das Modell — nur gesetzt, wenn wirklich etwas anliegt. */
note?: string;
}
function previewOf(text: unknown): string {
const raw = String(text ?? '').replace(/\s+/g, ' ').trim();
return raw.length > PREVIEW_CHARS ? `${raw.slice(0, PREVIEW_CHARS)}` : raw;
}
/**
* Was wartet gerade auf diesen Agenten?
*
* @param currentTaskId Der Task, an dem der Agent NACH EIGENER AUSSAGE arbeitet
* (z. B. die ID aus `agenthub_task_log`). Nur damit lässt sich erkennen, dass
* ihm die Arbeit unter den Händen weggezogen wurde.
*/
export function resolvePending(
cwd: string,
agent: string,
currentTaskId?: string,
): PendingForAgent {
// Alias-/Rollen-Auflösung, wenn eine Config da ist — sonst schlichter
// Namensvergleich. Ein Check-in darf NIE an fehlender Konfiguration
// scheitern: er ist der einzige Kanal eines arbeitenden Agenten.
let names: Set<string>;
try {
names = agentIdentity(cwd, agent).names;
} catch {
names = new Set([String(agent ?? '').toLowerCase()]);
}
const isMine = (value: unknown) => names.has(String(value ?? '').toLowerCase());
const tasks = listTasks(cwd);
const pending: PendingForAgent = {
waitingTasks: [],
awaitingReview: [],
unreadCount: 0,
messages: [],
};
for (const task of tasks) {
if (task.status === 'open' && isMine(task.assignedTo)) pending.waitingTasks.push(task.id);
else if (task.status === 'review' && isMine(task.claimedBy ?? task.assignedTo)) pending.awaitingReview.push(task.id);
}
if (currentTaskId) {
const current = tasks.find((t) => t.id === currentTaskId);
if (current && !(current.status === 'in_progress' && isMine(current.claimedBy ?? current.assignedTo))) {
pending.interrupted = {
taskId: current.id,
status: current.status ?? 'unknown',
reason:
current.status === 'open'
? 'Der Task wurde zurückgegeben (reopen) — es liegt neues Review-Feedback vor.'
: current.status === 'cancelled'
? 'Der Task wurde abgebrochen.'
: current.status === 'done'
? 'Der Task wurde bereits abgenommen.'
: `Der Task steht auf "${current.status}" und ist nicht mehr dein aktiver Claim.`,
action:
current.status === 'open'
? 'STOPP: nicht weiterbauen. Lies den neuesten Handoff zu diesem Task und claime ihn erneut, bevor du weitermachst.'
: 'STOPP: nicht weiterbauen. Hol dir mit agenthub_work den aktuellen Stand.',
};
}
}
const unread = listMessages(cwd).filter((m) => m.status === 'unread' && isMine(m.to));
pending.unreadCount = unread.length;
pending.messages = unread.slice(-MAX_MESSAGE_PREVIEWS).map((m) => ({
id: m.id,
from: m.from,
preview: previewOf((m as { text?: string }).text),
}));
const parts: string[] = [];
if (pending.interrupted) parts.push(pending.interrupted.action);
if (pending.unreadCount > 0) parts.push(`${pending.unreadCount} ungelesene Nachricht(en) — agenthub_inbox lesen und antworten.`);
if (pending.waitingTasks.length > 0) parts.push(`Offen für dich: ${pending.waitingTasks.join(', ')}.`);
if (parts.length > 0) pending.note = parts.join(' ');
return pending;
}
/** True, wenn wirklich etwas anliegt — sonst lassen Aufrufer den Block weg. */
export function hasPending(pending: PendingForAgent): boolean {
return (
pending.interrupted !== undefined ||
pending.unreadCount > 0 ||
pending.waitingTasks.length > 0
);
}

View File

@ -41,6 +41,23 @@ export interface AgentHealth {
loopExitAt?: string;
loopExitAgoSec?: number;
loopExitReason?: string;
/**
* TSK-0274: offene Tasks, die an diesen Agenten adressiert sind also
* handlungsrelevante Arbeit, die er noch nicht aufgenommen hat.
*
* Bewusst OHNE die ungelesenen Nachrichten: der historische Nachrichten-
* Rückstau (einzelne Agenten haben dreistellige Altbestände) würde das
* Signal vollständig erschlagen. Der Rückstau steht separat in `unreadCount`.
*/
pendingCount: number;
/** Ungelesene Nachrichten — separat, weil überwiegend Altbestand. */
unreadCount: number;
/**
* Sekunden seit dem letzten Check-in, WÄHREND der Agent an einem Task
* arbeitet also die Zeitspanne, in der er nichts mitbekommen konnte.
* Nur gesetzt, wenn er tatsächlich einen Task hält.
*/
deafForSec?: number;
}
export interface HealthReport {
@ -130,6 +147,9 @@ export function agentLight(
loopExitAt: loop?.exitAt === undefined ? undefined : new Date(loop.exitAt).toISOString(),
loopExitAgoSec: loop?.exitAt === undefined ? undefined : Math.max(0, Math.round((now - loop.exitAt) / 1000)),
loopExitReason: loop?.exitReason,
pendingCount: 0, // von computeHealth() befüllt (braucht die Task-/Nachrichtenliste)
unreadCount: 0,
deafForSec: busy && seenAgo !== undefined ? Math.max(0, Math.round(seenAgo / 1000)) : undefined,
};
}
@ -142,14 +162,28 @@ export function computeHealth(cwd: string, startedAtMs: number, now: number = Da
// Roster agents PLUS anyone who only ever announced themselves (presence-only).
const names = new Set<string>([...roster.map((r) => r.name), ...lastSeen.keys()]);
const messages = listMessages(cwd);
const unreadByAgent = new Map<string, number>();
for (const m of messages) {
if (m.status !== 'unread') continue;
const to = String(m.to ?? '');
if (to) unreadByAgent.set(to, (unreadByAgent.get(to) ?? 0) + 1);
}
const agents: AgentHealth[] = Array.from(names)
.sort((a, b) => a.localeCompare(b))
.map((name) => ({
.map((name) => {
const light = agentLight(name, tasks, now);
const openForAgent = tasks.filter((t) => t.status === 'open' && t.assignedTo === name).length;
return {
name,
role: roleByName.get(name) ?? 'implementer',
dispatch: dispatchByName.get(name) ?? 'loop',
...agentLight(name, tasks, now),
}));
...light,
pendingCount: openForAgent,
unreadCount: unreadByAgent.get(name) ?? 0,
};
});
return {
status: 'ok',

View File

@ -142,16 +142,23 @@ export function claimTask(cwd: string, id: string, agentName: string): Task {
architect = undefined;
}
if (agentName !== architect) {
// DEC-0035: `review` bindet den Agenten genauso wie `in_progress` — er ist
// erst wieder frei, wenn der Architekt abgenommen (done) oder
// zurückgewiesen (reopen) hat. `agenthub task dispatch` bleibt der bewusste
// Architekten-Override.
const held = listTasks(cwd).find(
(t) =>
t.status === 'in_progress' &&
(t.status === 'in_progress' || t.status === 'review') &&
t.id !== id &&
(t.claimedBy === agentName || (!t.claimedBy && t.assignedTo === agentName)),
);
if (held) {
const what = held.status === 'review'
? `is waiting for architect review on ${held.id}`
: `already holds an in_progress claim on ${held.id}`;
throw new Error(
`${agentName} already holds an in_progress claim on ${held.id} — one task at a time: ` +
`submit ${held.id} for review (or wait for a reopen) before claiming ${id}.`,
`${agentName} ${what} — one task at a time: wait for the architect to approve (done) or reopen ${held.id} ` +
`before claiming ${id}. Stay in agenthub_work meanwhile; you will be woken.`,
);
}
}
@ -202,10 +209,24 @@ export function cancelTask(cwd: string, id: string): Task {
return updateTask(cwd, id, { status: 'cancelled' });
}
/**
* Zurück auf `open`. Zwei legitime Fälle, beide mit demselben Ergebnis
* (Claim abgeräumt, `assignedTo` bleibt als Adressierung stehen):
*
* - `review → open` der Architekt weist die Lieferung zurück.
* - `in_progress → open` der Architekt entzieht eine laufende Arbeit
* (Agent hängt, Task wird umgehängt, Priorität gedreht). Ohne diesen Weg
* ließ sich eine festhängende Task gar nicht mehr befreien (selbst erlebt
* beim Umhängen von TSK-0273): reopen scheiterte mit 400, und der
* Ein-Task-Guard blockierte den Agenten dauerhaft.
*
* Der betroffene Agent erfährt davon über den Check-in-Kanal (checkinService):
* sein nächster `agenthub_task_log` liefert `pending.interrupted` zurück.
*/
export function reopenTask(cwd: string, id: string): Task {
const { task } = getTask(cwd, id);
if (task.status !== 'review') {
throw new Error(`Task ${id} cannot transition ${task.status} → open; expected review → open`);
if (task.status !== 'review' && task.status !== 'in_progress') {
throw new Error(`Task ${id} cannot transition ${task.status} → open; expected review → open or in_progress → open`);
}
return updateTask(cwd, id, { status: 'open', claimedBy: undefined });
}

View File

@ -18,6 +18,7 @@ import {
} from '../core/services/taskService.js';
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
import { appendTaskLog } from '../core/services/taskLogService.js';
import { resolvePending, hasPending } from '../core/services/checkinService.js';
import { createAsk, listAsks, answerAsk, escalateAsk } from '../core/services/askService.js';
import type { Ask } from '../core/schema.js';
import { createMessage, listInbox } from '../core/services/messageService.js';
@ -398,9 +399,30 @@ export async function startMcpServer(cwd: string): Promise<void> {
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
server.tool('agenthub_task_log',
'Report meaningful progress on the task you are working on — one short line — so the architect can watch it live on the task console. Call it as you work (e.g. "wrote failing test", "green: 12 tests", "blocked on X").',
'Report meaningful progress on the task you are working on — one short line — so the architect can watch it live on the task console. Call it after EVERY step (e.g. "wrote failing test", "green: 12 tests", "blocked on X"). '
+ 'IMPORTANT: the response is your only inbox while you work — you receive no events during a task. '
+ 'If it contains a `pending` block, ACT ON IT IMMEDIATELY: `pending.interrupted` means the task was reopened, cancelled or taken from you — STOP building, do not submit, follow `pending.interrupted.action`. '
+ 'Unread messages and newly assigned tasks arrive here too.',
{ id: z.string(), text: z.string(), agent: z.string().optional(), level: z.string().optional() },
async ({ id, text, agent, level }) => asText(remote ? await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }) : appendTaskLog(root, id, { text, agent, level })));
async ({ id, text, agent, level }) => {
if (remote) {
// Der Server hängt den pending-Block selbst an (siehe routes.ts).
return asText(await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }));
}
const entry = appendTaskLog(root, id, { text, agent, level });
if (!agent) return asText(entry);
const pending = resolvePending(root, agent, id);
return asText(hasPending(pending) ? { ...entry, pending } : entry);
});
server.tool('agenthub_checkin',
'Check what is waiting for you WITHOUT writing a log line. Use it when a task runs long between log points: while you execute a task you receive no events, so this is how you notice a reopen, a cancellation or a new message. '
+ 'Pass the task you are currently working on to learn whether it is still yours.',
{ agent: z.string(), taskId: z.string().optional() },
async ({ agent, taskId }) =>
asText(remote
? await remoteClient.getPending(serverUrl!, agent, taskId)
: resolvePending(root, agent, taskId)));
server.tool('agenthub_memory_add', 'Record a result / finding / blocker as a memory entry.',
{ title: z.string(), category: z.enum(['architecture', 'product', 'technical', 'implementation', 'lesson']).optional(), content: z.string() },

View File

@ -117,6 +117,10 @@ export function renderAgentHealthHtml(cwd: string, startedAtMs: number): string
.light-stale { color: #ffb4b4; border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.08); }
.ac-meta { font: 11px/1.5 var(--font-mono); color: var(--muted); min-height: 16px; }
/* TSK-0274: wartende Arbeit + Check-in-Alter (Taubheit) */
.ac-pending { color: var(--accent); font-weight: 600; }
.ac-deaf { color: var(--muted); }
.ac-deaf-warn { color: var(--status-review); font-weight: 600; }
.ac-meta a { color: var(--green); text-decoration: none; }
.ac-meta a:hover { text-decoration: underline; }
@ -259,7 +263,17 @@ export function renderAgentHealthHtml(cwd: string, startedAtMs: number): string
: a.loopExitReason
? ' · out of loop ' + compact(a.loopExitAgoSec || 0) + ' ago (' + esc(a.loopExitReason) + ')'
: ' · loop unknown';
card.querySelector('[data-meta]').innerHTML = esc(seen) + task + loop;
// TSK-0274: Taubheit sichtbar machen. Ein arbeitender Agent empfaengt
// nichts — entscheidend ist, wie lange er nicht eingecheckt hat und
// wie viel derweil auf ihn wartet.
var pending = a.pendingCount > 0
? ' · <b class="ac-pending">' + a.pendingCount + ' Task(s) warten</b>'
: '';
if (a.unreadCount > 0) pending += ' · ' + a.unreadCount + ' ungelesen';
var deaf = a.deafForSec != null
? ' · <span class="' + (a.deafForSec > 300 ? 'ac-deaf-warn' : 'ac-deaf') + '">kein Check-in seit ' + compact(a.deafForSec) + '</span>'
: '';
card.querySelector('[data-meta]').innerHTML = esc(seen) + task + loop + pending + deaf;
var tr = card.querySelector('[data-track]');
var newTrack = trackStatus(a.name);
if (tr.innerHTML !== newTrack) tr.innerHTML = newTrack;

View File

@ -12,6 +12,7 @@ import { autoDelegate, suggestDelegation } from '../core/services/delegateServic
import { computeBudget } from '../core/services/budgetService.js';
import { getRoster } from '../core/services/rosterService.js';
import { computeHealth, enterLoop, leaveLoop, stampSeen } from '../core/services/presenceService.js';
import { resolvePending, hasPending } from '../core/services/checkinService.js';
import { loadConfig, saveConfig } from '../core/config.js';
import { agentIdentity } from '../core/services/identityService.js';
import { renderActivityHtml } from './activity.js';
@ -437,7 +438,26 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
}
if (agent) stampSeen(agent);
eventBus.publishLog({ taskId: id, ...entry });
return entry;
// TSK-0274 Check-in-Kanal: ein arbeitender Agent ist zwischen zwei
// work-Aufrufen taub. Dieser Log-Aufruf ist der einzige Moment, in dem er
// von sich aus spricht — also geben wir ihm hier zurück, was auf ihn
// wartet (Reopen/Cancel seines Tasks, Nachrichten, offene Zuweisungen).
if (!agent) return entry;
const pending = resolvePending(cwd, agent, id);
return hasPending(pending) ? { ...entry, pending } : entry;
});
/**
* Expliziter Check-in (TSK-0274) für Agenten, die zwischendurch nachsehen
* wollen, ohne eine Log-Zeile zu schreiben, und für die Health-Sichtbarkeit.
* `taskId` optional: damit wird zusätzlich geprüft, ob der Task dem Agenten
* überhaupt noch gehört.
*/
app.get('/agents/:agent/pending', async (request) => {
const { agent } = request.params as { agent: string };
const { taskId } = request.query as { taskId?: string };
stampSeen(agent);
return resolvePending(cwd, agent, taskId);
});
app.post('/tasks/:id/dispatch', async (request, reply) => {
const { id } = request.params as { id: string };

View File

@ -0,0 +1,135 @@
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);
});
});

View File

@ -105,7 +105,15 @@ describe('single-claim semantics (TSK-0237)', () => {
expect((await taskViaApi(server.url, newer)).status).toBe('open');
}, 10_000);
it('(b) auto-claims the next task only after the active one reaches review', async () => {
/**
* DEC-0035 (löst das frühere TSK-0237-Verhalten ab): `review` bindet den
* Agenten genauso wie `in_progress`. Früher galt er in der Sekunde des
* Einreichens als frei und griff sich die nächste Task wies der Architekt
* die Review danach zurück, blockierte der Ein-Task-Guard den Reopen und die
* zurückgewiesene Arbeit blieb unbemerkt liegen (so ging der Reopen von
* TSK-0218 verloren). Erst das Approve gibt den Agenten frei.
*/
it('(b) claimt die nächste Task NICHT bei review — erst nach dem Architekten-Approve', async () => {
const first = await createAndAssign(server.url, 'kimi: first', 'high');
await sleep(10);
const second = await createAndAssign(server.url, 'kimi: second', 'medium');
@ -114,16 +122,45 @@ describe('single-claim semantics (TSK-0237)', () => {
expect((await taskViaApi(server.url, first)).status).toBe('in_progress');
expect((await taskViaApi(server.url, second)).status).toBe('open');
// Submit the active task for review → the agent is free for the next one.
await fetch(`${server.url}/tasks/${first}`, {
const patch = (id: string, body: Record<string, unknown>) =>
fetch(`${server.url}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'review' }),
body: JSON.stringify(body),
});
// Eingereicht — der Agent bleibt gebunden, solange das Review offen ist.
await patch(first, { status: 'review' });
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
expect((await taskViaApi(server.url, second)).status).toBe('open');
// Approve → jetzt erst ist er frei für die nächste Task.
await patch(first, { status: 'done' });
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
expect((await taskViaApi(server.url, second)).status).toBe('in_progress');
}, 12_000);
}, 14_000);
it('(b2) ein Reopen gibt dem Agenten die ALTE Task zurück, nicht die nächste', async () => {
const first = await createAndAssign(server.url, 'kimi: first', 'high');
await sleep(10);
const second = await createAndAssign(server.url, 'kimi: second', 'medium');
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
const patch = (id: string, body: Record<string, unknown>) =>
fetch(`${server.url}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
await patch(first, { status: 'review' });
await patch(first, { status: 'open' }); // Architekt weist zurück
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
// Die zurückgewiesene Arbeit muss gewinnen — genau hier ging früher ein
// Reopen verloren, weil der Agent schon an der nächsten Task hing.
expect((await taskViaApi(server.url, first)).status).toBe('in_progress');
expect((await taskViaApi(server.url, second)).status).toBe('open');
}, 14_000);
it('(c) server guard: claim while holding an in_progress task → 400, architect exempt', async () => {
const t1 = await createAndAssign(server.url, 'kimi: held', 'medium');