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 a = identity.canonical.toLowerCase();
|
||||||
const matchesAgent = (value: unknown) => identity.names.has(String(value ?? '').toLowerCase());
|
const matchesAgent = (value: unknown) => identity.names.has(String(value ?? '').toLowerCase());
|
||||||
|
|
||||||
const inProgress = ctx.serverUrl
|
// DEC-0035 / TSK-0273: `review` zählt als GEHALTEN, nicht nur `in_progress`.
|
||||||
? await remoteClient.listTasks(ctx.serverUrl, { status: 'in_progress' })
|
// Sonst gilt ein Agent in der Sekunde des Einreichens als frei und greift
|
||||||
: svcListTasks(ctx.projectCwd, { status: 'in_progress' });
|
// sich die nächste Task — weist der Architekt die Review danach zurück,
|
||||||
const busy = inProgress.some((t) => matchesAgent(t.claimedBy ?? t.assignedTo));
|
// 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;
|
if (busy) return undefined;
|
||||||
|
|
||||||
const tasks = await listOpenRoleTasks(ctx);
|
const tasks = await listOpenRoleTasks(ctx);
|
||||||
|
|||||||
@ -129,6 +129,12 @@ export const remoteClient = {
|
|||||||
return request<TaskLogEntry>(baseUrl, 'POST', `/tasks/${id}/log`, entry);
|
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> {
|
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
|
||||||
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
|
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;
|
loopExitAt?: string;
|
||||||
loopExitAgoSec?: number;
|
loopExitAgoSec?: number;
|
||||||
loopExitReason?: string;
|
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 {
|
export interface HealthReport {
|
||||||
@ -130,6 +147,9 @@ export function agentLight(
|
|||||||
loopExitAt: loop?.exitAt === undefined ? undefined : new Date(loop.exitAt).toISOString(),
|
loopExitAt: loop?.exitAt === undefined ? undefined : new Date(loop.exitAt).toISOString(),
|
||||||
loopExitAgoSec: loop?.exitAt === undefined ? undefined : Math.max(0, Math.round((now - loop.exitAt) / 1000)),
|
loopExitAgoSec: loop?.exitAt === undefined ? undefined : Math.max(0, Math.round((now - loop.exitAt) / 1000)),
|
||||||
loopExitReason: loop?.exitReason,
|
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).
|
// Roster agents PLUS anyone who only ever announced themselves (presence-only).
|
||||||
const names = new Set<string>([...roster.map((r) => r.name), ...lastSeen.keys()]);
|
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)
|
const agents: AgentHealth[] = Array.from(names)
|
||||||
.sort((a, b) => a.localeCompare(b))
|
.sort((a, b) => a.localeCompare(b))
|
||||||
.map((name) => ({
|
.map((name) => {
|
||||||
name,
|
const light = agentLight(name, tasks, now);
|
||||||
role: roleByName.get(name) ?? 'implementer',
|
const openForAgent = tasks.filter((t) => t.status === 'open' && t.assignedTo === name).length;
|
||||||
dispatch: dispatchByName.get(name) ?? 'loop',
|
return {
|
||||||
...agentLight(name, tasks, now),
|
name,
|
||||||
}));
|
role: roleByName.get(name) ?? 'implementer',
|
||||||
|
dispatch: dispatchByName.get(name) ?? 'loop',
|
||||||
|
...light,
|
||||||
|
pendingCount: openForAgent,
|
||||||
|
unreadCount: unreadByAgent.get(name) ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
|
|||||||
@ -142,16 +142,23 @@ export function claimTask(cwd: string, id: string, agentName: string): Task {
|
|||||||
architect = undefined;
|
architect = undefined;
|
||||||
}
|
}
|
||||||
if (agentName !== architect) {
|
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(
|
const held = listTasks(cwd).find(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.status === 'in_progress' &&
|
(t.status === 'in_progress' || t.status === 'review') &&
|
||||||
t.id !== id &&
|
t.id !== id &&
|
||||||
(t.claimedBy === agentName || (!t.claimedBy && t.assignedTo === agentName)),
|
(t.claimedBy === agentName || (!t.claimedBy && t.assignedTo === agentName)),
|
||||||
);
|
);
|
||||||
if (held) {
|
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(
|
throw new Error(
|
||||||
`${agentName} already holds an in_progress claim on ${held.id} — one task at a time: ` +
|
`${agentName} ${what} — one task at a time: wait for the architect to approve (done) or reopen ${held.id} ` +
|
||||||
`submit ${held.id} for review (or wait for a reopen) before claiming ${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' });
|
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 {
|
export function reopenTask(cwd: string, id: string): Task {
|
||||||
const { task } = getTask(cwd, id);
|
const { task } = getTask(cwd, id);
|
||||||
if (task.status !== 'review') {
|
if (task.status !== 'review' && task.status !== 'in_progress') {
|
||||||
throw new Error(`Task ${id} cannot transition ${task.status} → open; expected review → open`);
|
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 });
|
return updateTask(cwd, id, { status: 'open', claimedBy: undefined });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
} from '../core/services/taskService.js';
|
} from '../core/services/taskService.js';
|
||||||
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
|
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
|
||||||
import { appendTaskLog } from '../core/services/taskLogService.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 { createAsk, listAsks, answerAsk, escalateAsk } from '../core/services/askService.js';
|
||||||
import type { Ask } from '../core/schema.js';
|
import type { Ask } from '../core/schema.js';
|
||||||
import { createMessage, listInbox } from '../core/services/messageService.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)));
|
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
|
||||||
|
|
||||||
server.tool('agenthub_task_log',
|
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() },
|
{ 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.',
|
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() },
|
{ 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); }
|
.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; }
|
.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 { color: var(--green); text-decoration: none; }
|
||||||
.ac-meta a:hover { text-decoration: underline; }
|
.ac-meta a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
@ -259,7 +263,17 @@ export function renderAgentHealthHtml(cwd: string, startedAtMs: number): string
|
|||||||
: a.loopExitReason
|
: a.loopExitReason
|
||||||
? ' · out of loop ' + compact(a.loopExitAgoSec || 0) + ' ago (' + esc(a.loopExitReason) + ')'
|
? ' · out of loop ' + compact(a.loopExitAgoSec || 0) + ' ago (' + esc(a.loopExitReason) + ')'
|
||||||
: ' · loop unknown';
|
: ' · 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 tr = card.querySelector('[data-track]');
|
||||||
var newTrack = trackStatus(a.name);
|
var newTrack = trackStatus(a.name);
|
||||||
if (tr.innerHTML !== newTrack) tr.innerHTML = newTrack;
|
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 { computeBudget } from '../core/services/budgetService.js';
|
||||||
import { getRoster } from '../core/services/rosterService.js';
|
import { getRoster } from '../core/services/rosterService.js';
|
||||||
import { computeHealth, enterLoop, leaveLoop, stampSeen } from '../core/services/presenceService.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 { loadConfig, saveConfig } from '../core/config.js';
|
||||||
import { agentIdentity } from '../core/services/identityService.js';
|
import { agentIdentity } from '../core/services/identityService.js';
|
||||||
import { renderActivityHtml } from './activity.js';
|
import { renderActivityHtml } from './activity.js';
|
||||||
@ -437,7 +438,26 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
}
|
}
|
||||||
if (agent) stampSeen(agent);
|
if (agent) stampSeen(agent);
|
||||||
eventBus.publishLog({ taskId: id, ...entry });
|
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) => {
|
app.post('/tasks/:id/dispatch', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
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');
|
expect((await taskViaApi(server.url, newer)).status).toBe('open');
|
||||||
}, 10_000);
|
}, 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');
|
const first = await createAndAssign(server.url, 'kimi: first', 'high');
|
||||||
await sleep(10);
|
await sleep(10);
|
||||||
const second = await createAndAssign(server.url, 'kimi: second', 'medium');
|
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, first)).status).toBe('in_progress');
|
||||||
expect((await taskViaApi(server.url, second)).status).toBe('open');
|
expect((await taskViaApi(server.url, second)).status).toBe('open');
|
||||||
|
|
||||||
// Submit the active task for review → the agent is free for the next one.
|
const patch = (id: string, body: Record<string, unknown>) =>
|
||||||
await fetch(`${server.url}/tasks/${first}`, {
|
fetch(`${server.url}/tasks/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 });
|
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
|
||||||
expect((await taskViaApi(server.url, second)).status).toBe('in_progress');
|
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 () => {
|
it('(c) server guard: claim while holding an in_progress task → 400, architect exempt', async () => {
|
||||||
const t1 = await createAndAssign(server.url, 'kimi: held', 'medium');
|
const t1 = await createAndAssign(server.url, 'kimi: held', 'medium');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user