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:
parent
6d088648da
commit
1f3126b3b5
@ -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);
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
145
src/core/services/checkinService.ts
Normal file
145
src/core/services/checkinService.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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() },
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 };
|
||||
|
||||
135
tests/checkinService.test.ts
Normal file
135
tests/checkinService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user