diff --git a/src/cli/commands/start.ts b/src/cli/commands/start.ts index e507e27..16d6485 100644 --- a/src/cli/commands/start.ts +++ b/src/cli/commands/start.ts @@ -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); diff --git a/src/cli/remoteClient.ts b/src/cli/remoteClient.ts index 1ddd88a..c33d427 100644 --- a/src/cli/remoteClient.ts +++ b/src/cli/remoteClient.ts @@ -129,6 +129,12 @@ export const remoteClient = { return request(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 { + const query = taskId ? `?taskId=${encodeURIComponent(taskId)}` : ''; + return request(baseUrl, 'GET', `/agents/${encodeURIComponent(agent)}/pending${query}`); + }, + async createHandoff(baseUrl: string, options: Partial): Promise { return request(baseUrl, 'POST', '/handoffs', options); }, diff --git a/src/core/services/checkinService.ts b/src/core/services/checkinService.ts new file mode 100644 index 0000000..55184f5 --- /dev/null +++ b/src/core/services/checkinService.ts @@ -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; + 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 + ); +} diff --git a/src/core/services/presenceService.ts b/src/core/services/presenceService.ts index fc6892e..a020d1d 100644 --- a/src/core/services/presenceService.ts +++ b/src/core/services/presenceService.ts @@ -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([...roster.map((r) => r.name), ...lastSeen.keys()]); + const messages = listMessages(cwd); + const unreadByAgent = new Map(); + 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) => ({ - name, - role: roleByName.get(name) ?? 'implementer', - dispatch: dispatchByName.get(name) ?? 'loop', - ...agentLight(name, tasks, now), - })); + .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', + ...light, + pendingCount: openForAgent, + unreadCount: unreadByAgent.get(name) ?? 0, + }; + }); return { status: 'ok', diff --git a/src/core/services/taskService.ts b/src/core/services/taskService.ts index caddad7..49af9c4 100644 --- a/src/core/services/taskService.ts +++ b/src/core/services/taskService.ts @@ -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 }); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f62f156..ad86acd 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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 { 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() }, diff --git a/src/server/agentHealth.ts b/src/server/agentHealth.ts index 6c71b53..743f903 100644 --- a/src/server/agentHealth.ts +++ b/src/server/agentHealth.ts @@ -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 + ? ' · ' + a.pendingCount + ' Task(s) warten' + : ''; + if (a.unreadCount > 0) pending += ' · ' + a.unreadCount + ' ungelesen'; + var deaf = a.deafForSec != null + ? ' · kein Check-in seit ' + compact(a.deafForSec) + '' + : ''; + 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; diff --git a/src/server/routes.ts b/src/server/routes.ts index fcefc3e..3868c04 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -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 }; diff --git a/tests/checkinService.test.ts b/tests/checkinService.test.ts new file mode 100644 index 0000000..46c8c03 --- /dev/null +++ b/tests/checkinService.test.ts @@ -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); + }); +}); diff --git a/tests/singleClaim.test.ts b/tests/singleClaim.test.ts index a2210ba..f7419a9 100644 --- a/tests/singleClaim.test.ts +++ b/tests/singleClaim.test.ts @@ -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}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: 'review' }), - }); + const patch = (id: string, body: Record) => + fetch(`${server.url}/tasks/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + 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) => + 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');