Compare commits

...

27 Commits

Author SHA1 Message Date
chahinebrini
df698c9dff feat(agenthub): Rollenwechsel, agent-setup + Push-Channel
roleService haelt roles.*.preferredAgent (Routing-Autoritaet fuer asks,
Watchdog, start) und agents.*.role (Health/Board/MCP-Discovery) konsistent —
bisher konnte nur eine Seite gesetzt werden und der Roster lief auseinander.
Dazu agent-setup-Kommando, Push-Channel im MCP und Selbstheilung im
remoteClient.

Build clean, 304 Tests in 55 Dateien gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113SC5FkaMxcRzXHDBsHmdJ
2026-08-02 21:38:47 +02:00
chahinebrini
908a043ac5 fix(work): bereits gehaltene Task wird genannt statt verschwiegen
Vom CEO gefunden: TSK-0293 stand auf in_progress bei codex — und codex tat
nichts. Ursache: `findAddressedOpenTask` sucht ausschliesslich Tasks mit
Status `open`. Haelt ein Agent bereits eine in_progress-Task (typisch nach
einem Session-Neustart), greift der Busy-Guard und der Loop liefert NICHTS
zurueck. Der Agent wartet dann auf neue Arbeit, die nie kommt, waehrend seine
eigene Task unbearbeitet liegt — und von aussen sieht es aus, als sei er tot.

`resolvePending` liefert jetzt `heldTask`, und alle drei Wege sagen es dem
Agenten deutlich:
  - `agenthub start <agent>`: "DU HAELTST BEREITS <id>" ganz oben
  - `agenthub work` (CLI): nennt die Task und beendet sich, statt zu warten
  - `agenthub_work` (MCP): gibt Task samt Body zurueck statt zu blockieren

Tests: 295 → 297.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:45:15 +02:00
chahinebrini
8a1728cbce fix(server): Wurzel-URL leitet aufs Board statt 404
http://<host>:3377/ antwortete mit 404 — das Board liegt auf /board. Wer
prueft, ob der Hub laeuft, oeffnet aber genau die Wurzel-URL und haelt ihn
dann faelschlich fuer tot. Vom CEO gemeldet, mir selbst heute einmal passiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:40:59 +02:00
chahinebrini
dac8afb3c1 fix(cli): Selbstheilung bevorzugt Loopback — sie heilte sich bisher in die instabilere Adresse
Vom CEO an codex' Log entdeckt: der CLI-Worker scheiterte mit
"AgentHub server at http://192.168.178.52:3377 is not reachable" — obwohl die
MCP-Bridge laengst auf 127.0.0.1 zeigte. Grund: die CLI nimmt die
PROJEKT-Config, nicht die MCP-Umgebungsvariable.

Und die Projekt-Config wurde immer wieder zurueckgeschrieben: antwortet die
konfigurierte Adresse kurz nicht — bei JEDEM Hub-Neustart der Fall —, sucht
resolveContext per mDNS, findet die LAN-IP und persistiert sie. Ein zuvor
gesetztes Loopback ging dabei jedes Mal verloren. Die LAN-IP wiederum bricht
beim naechsten Neustart oder Netzwechsel weg: das System heilte sich
zuverlaessig in die instabilere Adresse hinein.

`preferLoopback()` prueft jetzt, ob derselbe Hub auch auf 127.0.0.1 antwortet,
und persistiert dann Loopback. Fuer Agenten auf derselben Maschine ist das
strikt robuster — es kann weder durch mDNS-Neuankuendigung noch durch einen
Netzwechsel ungueltig werden. Projekt-Config zusaetzlich zurueckgesetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:36:42 +02:00
chahinebrini
6e614b1f4d fix(briefing): Hintergrund-Worker ohne Timeout als ERSTE Aktion — CEO-Vorschlag
Der CEO hat den Kern benannt: beide Agenten sollen beim Start einen
Hintergrund-Worker fahren, so wie der Architekt seine watch-Prozesse.

Messung dazu: der CLI-Worker `agenthub work` hat OHNE --timeout gar keine
Wartegrenze — er blockiert unbegrenzt und endet erst, wenn Arbeit kommt. Das
MCP-Tool braucht dagegen zwingend einen Timeout (MCP-Client-Limit). Genau
darin unterschieden sich die beiden Agenten die ganze Zeit: kimi fuhr den
CLI-Worker im Hintergrund (kein Timeout, ueberlebt Turn-Grenzen), codex rief
das MCP-Tool im Turn auf (50s, stirbt mit dem Turn).

Das Briefing macht den Hintergrund-Worker jetzt zur ERSTEN Aktion und sagt
ausdruecklich: ohne --timeout, als Hintergrund-Task der eigenen Umgebung, so
dass dessen Ende den Agenten weckt. Das Tool ist nur noch Notnagel fuer Hosts
ohne Hintergrund-Tasks — und dann ohne timeoutSec, damit der Roster-Wert gilt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:29:48 +02:00
chahinebrini
178883cd2e fix(briefing): Work-Loop als Hintergrundprozess — und kein hartkodiertes timeoutSec mehr
Zwei selbstverschuldete Fehler, vom CEO an den echten Agenten-Logs entdeckt:

1) Das Briefing schrieb `timeoutSec=50` vor. codex uebergab den Wert brav —
   und ein explizites Argument sticht die Roster-Einstellung. Der gerade
   gebaute per-Agent-Timeout (codex 240s) war damit wirkungslos. Jetzt steht
   dort ausdruecklich: OHNE timeoutSec aufrufen, der Roster entscheidet.

2) Der eigentliche Unterschied zwischen den Agenten stand die ganze Zeit im
   kimi-Log: kimi startet den Loop als HINTERGRUNDPROZESS
   ("bash task started in background"), codex als Tool-Call mitten im Turn.
   Ein Hintergrundprozess ueberlebt das Turn-Ende, ein Tool-Call nicht —
   deshalb war codex nach jedem leeren Aufruf still und kimi nicht. Das
   Briefing verlangt jetzt fuer alle den Hintergrundprozess
   (`agenthub work --agent <name>`), der Tool-Call ist nur noch Notnagel.

Damit ist der Unterschied strukturell adressiert statt agentenspezifisch
umgangen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:26:05 +02:00
chahinebrini
55606eddc8 diag(mcp): Client-Capabilities beim Binden festhalten
Der Push-Test mit den echten CLIs war NEGATIV: codex meldete "durch
agenthub_work erfahren", kimi ausdruecklich "kein MCP-Push". Der Kanal
funktioniert (mit eigenem Testclient verifiziert), die Hosts reichen ihn
aber offenbar nicht ans Modell durch.

Naheliegender Verdacht, statt zu spekulieren gemessen: `sendLoggingMessage`
nuetzt nichts, wenn der CLIENT `logging` nicht deklariert — dann verwirft
seine SDK-Seite die Notification, bevor das Modell sie je sieht. Mein
Testclient deklarierte sie ausdruecklich und empfing deshalb.

Die Bridge schreibt die deklarierten Client-Capabilities jetzt nach
~/.cache/agenthub/client-capabilities-<agent>.json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:21:28 +02:00
chahinebrini
dbaf29a6c6 feat(mcp): Push-Kanal beim Bridge-Start binden (agenthub mcp --agent <name>)
Bisher band die Bridge den Push-Kanal erst, wenn der Agent seinen Namen nannte
(hello/work). Genau davor — direkt nach dem Session-Start — war er also
weiterhin taub, und das ist der Zustand, den wir beheben wollen. Mit
`--agent <name>` bindet die Bridge ab der ersten Sekunde.

Codex- und Kimi-Konfiguration entsprechend gesetzt (args ["mcp","--agent",…]).

Briefing umgeschrieben: Es sagte "du bist waehrend der Arbeit taub" — das
stimmt nicht mehr. Jetzt: Push-Benachrichtigungen sind verbindlich, sofort
darauf reagieren; der pending-Block im task_log-Ergebnis bleibt als zweiter
Empfangsweg, Loggen bleibt Pflicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:13:07 +02:00
chahinebrini
e8c2e43cb0 feat(mcp): echter Push an Agenten über MCP-Notifications — ohne blockierenden Tool-Call
DIE WURZEL, endlich an der Wurzel gefasst. Bisher konnte ein Agent nur
empfangen, WÄHREND er in `agenthub_work` blockierte. Kehrte der Aufruf leer
zurück und das Modell startete ihn nicht neu, war der Agent unerreichbar —
ein Mensch musste ihn anstoßen. Wir haben einen ganzen Tag um diese Tatsache
herum gebaut (Watchdog, Check-in-Kanal, längere Timeouts, notListening) statt
sie aufzulösen. Der CEO hat zu Recht darauf bestanden, das Protokoll selbst
zu nutzen.

MCP kann Server→Client jederzeit `notifications/message` schicken. Die Bridge
ist ohnehin ein langlebiger Prozess pro Agent: sie hängt sich jetzt dauerhaft
an den SSE-Stream des Hubs (`pushChannel.ts`) und reicht relevante Ereignisse
als Notification hoch — neue/zurückgegebene Task, Nachricht, beantwortete
Frage, Abbruch. Bindung erfolgt, sobald der Agent seinen Namen nennt
(hello/work), inklusive Alias-Auflösung; Reconnect mit Backoff.

⚠️ Die entscheidende Falle: `sendLoggingMessage` prüft `_capabilities.logging`
und verwirft die Notification sonst STILL. Der Server deklarierte gar keine
Capabilities — daran wäre dieser Weg unbemerkt gescheitert. Jetzt deklariert.

Live verifiziert mit einem echten MCP-Client: Nachricht am Hub angelegt →
Notification kam beim Client an, ohne dass dieser in einem Tool-Call wartete.

OFFEN und bewusst nicht behauptet: ob die CLI-Hosts (Codex/Kimi) die
Notification dem Modell zeigen — das ist client-abhängig und empirisch zu
prüfen. Deshalb bleiben Check-in-Kanal und Work-Loop als Netz bestehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:08:21 +02:00
chahinebrini
e5bd28488d feat(work): workTimeoutSec pro Agent — codex und kimi auf dasselbe Niveau
Der Work-Loop blockierte fuer ALLE Agenten 50s. Der Wert stammt von Kimi-Code,
dessen MCP-Client Requests nach gut einer Minute abbricht (-32001). Codex
vertraegt Minuten — musste aber mit demselben Minimum leben und kehrte dadurch
sechsmal so oft leer zurueck wie noetig. Jede Rueckkehr ist eine Gelegenheit,
den Turn zu beenden und aus dem Loop zu fallen; genau daran unterschied sich
codex' Zuverlaessigkeit von kimis.

Ein globaler Default zwingt alle auf die Grenze des schwaechsten Clients. Die
Grenze gehoert aber zum Agenten, nicht zum Hub — deshalb jetzt
`agents[].workTimeoutSec` im Roster, ausgeliefert ueber /agents/:agent/identity.
Aufloesung: explizites Argument > Roster > globaler Default.

Konfiguriert: codex 240s, kimi 50s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:57:52 +02:00
chahinebrini
6a4cd32312 feat(pulse): notListening — Agenten mit offener Arbeit, die nicht im Loop sitzen
Der Fall, der den Menschen zum Postman macht: eine Task ist an einen Agenten
adressiert, aber er sitzt nicht in agenthub_work. Er ist dann NICHT dormant
(er lebt womöglich und hat gerade erst mit dem Hub gesprochen), hört aber
nicht zu — die Arbeit bleibt liegen, ohne dass irgendwo ein Fehler erscheint.

Real erlebt: TSK-0288 lag offen an codex adressiert, codex war `active`
(75 s zuvor gesehen) mit inLoop=false. Der Architekt hat es nicht bemerkt und
dem CEO stattdessen erzählt, die Task werde "automatisch" geclaimt. Genau
diese Erwartungs-statt-Prüfung soll das Signal künftig unmöglich machen.

`/architect/pulse` liefert dazu `notListening` mit Agent, Anzahl wartender
Tasks und einem Klartext-Hinweis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:17:40 +02:00
chahinebrini
c2e19b3371 feat(watch): --ignore-from — Notifier nicht vom Watchdog zumüllen lassen
Der Architekten-Message-Notifier (`watch --await-message claude`) feuerte bei
JEDER Nachricht — auch bei den Watchdog-Erinnerungen, die alle paar Minuten
eintreffen ("review waiting 5m/10m/15m…"). Real erlebt: dreimal hintereinander
geweckt worden, ohne dass etwas Neues passiert war. Ein Signal, das im Takt
seiner eigenen Erinnerungen feuert, entwertet sich selbst.

`--ignore-from <agents>` blendet Absender aus; message-Events tragen dafür
jetzt `from` (bisher nur im title). Armung des Architekten künftig mit
`--ignore-from agenthub` — echte Agenten-Post weckt weiterhin sofort, die
Watchdog-Erinnerungen sieht man beim Pollen ohnehin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:47:11 +02:00
chahinebrini
7a4ccaeb61 fix(hub): dormant ≠ beschäftigt — arbeitende Agenten nicht mehr als tot melden
`dormantAgents` filterte auf `!inLoop`. `inLoop` ist aber immer false, solange
ein Agent einen Task AUSFÜHRT — dadurch meldete der Pulse jeden hart
arbeitenden Agenten als dormant. Live gesehen: codex mit 17 s altem Check-in
stand als dormant im Pulse. Genau das Signal, auf das sich der Architekt
verlassen soll, hätte ihn dazu gebracht, funktionierende Agenten anzustoßen.

Jetzt zwei getrennte Fälle:
  - `loopExitReason` gesetzt → der Agent hat den Loop ausdrücklich verlassen
    und hört nicht mehr zu: sofort melden (unverändert).
  - kein Exit-Grund, aber Task in Arbeit → er führt aus; erst nach
    DORMANT_AFTER_MS (5 min) ohne Check-in ist das mein Fall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:32:10 +02:00
chahinebrini
5fb2b6d6c6 fix(hub): SessionStart-Hook und agenthub start liefern denselben Text + Verzeichnis-Guard
1) hookContext druckte einen eigenen, aelteren Text. Fuer Codex/Kimi ist der
   Hook aber oft der EINZIGE Einstieg (sie starten automatisch) — dadurch
   kannten auto-gestartete Agenten weder den Check-in-Kanal noch DEC-0035.
   Aufgefallen, als codex ohne `agenthub start` losgelaufen ist. Beide Wege
   nutzen jetzt agentBriefing().

2) `server start` prueft jetzt VOR dem Binden, ob hier ueberhaupt ein Projekt
   liegt. Vorher band der Server erst den Port und starb dann beim Laden der
   Config — der alte Hub war da schon gekillt, die Agenten liefen ins Leere.
   Klassiker: aus dem Quellrepo statt aus dem Projekt gestartet (an einem Tag
   dreimal passiert). Jetzt sofortiger, verstaendlicher Fehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:21:08 +02:00
chahinebrini
1250359e6d fix(hub): Check-in adressiert wie der Work-Loop (Titel-Präfix + Handoff)
Der Check-in prüfte nur `assignedTo`, `findAddressedOpenTask` dagegen
Titel-Präfix `<name>:`, Handoff-Empfänger UND `assignedTo`. Folge: `agenthub
start codex` meldete "AKTUELL: nichts offen", während TSK-0268 mit
`codex:`-Präfix offen lag — der Work-Loop hätte sie unmittelbar danach
geclaimt. Zwei Wahrheiten über dieselbe Frage.

Jetzt nutzt der Check-in dieselbe Adressierungslogik.

Tests: 293 → 295.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:18:02 +02:00
chahinebrini
4be0826a6f fix(hub): delivered-Nachrichten bleiben im Check-in sichtbar
`delivered` heißt nur "einmal ausgeliefert", nicht "gelesen" — abgeschlossen
ist eine Nachricht erst mit read/acked. Der Check-in filterte aber auf
`unread` und ließ damit jede Nachricht fallen, die irgendwo schon einmal
aufgetaucht war (etwa beim Session-Start), ohne dass der Agent sie je
beantwortet hätte.

Real aufgefallen an einer Architekten-Korrektur an kimi: Status `delivered`,
im pending-Block unsichtbar — die Korrektur wäre still verlorengegangen.
Genau der Fehlermodus, gegen den der Kanal gebaut wurde.

Tests: 292 → 294.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:12:23 +02:00
chahinebrini
a6f640242a fix(hub): beantwortete Asks über den Check-in-Kanal nachliefern
Realer Fall heute: kimi stellte ASK-0004 um 16:43 und blockierte in
`agenthub_ask(wait:true)`. Der Wait läuft nach 300 s aus. Der Architekt
brauchte für die Architekturentscheidung 18 Minuten — danach war kimi
dormant, das korrekt emittierte `ask`-Event lief ins Leere, und ein Mensch
musste den Agenten wecken.

Dieselbe Taubheit wie in TSK-0274, nur an einer anderen Stelle: dormant =
unerreichbar. Deshalb wandert die Antwort jetzt in den pending-Block —
`resolvePending` liefert beantwortete eigene Fragen mit, sodass sie beim
nächsten Hub-Kontakt (task_log, checkin, start) ankommt, egal wie lange der
Architekt gebraucht hat.

Tests: 290 → 292.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:09:41 +02:00
chahinebrini
e2bbae2044 feat(cli): agenthub start <agent> kennt den Architekten — Lagebericht statt Loop
Der Architekt kann prinzipiell nicht in agenthub_work blockieren (er ist die
interaktive Gegenstelle des Menschen). Sein Sessionstart ist deshalb ein
Lagebericht: Hub-Status, Board, wer woran arbeitet inkl. "kein Check-in seit",
wer auf Übernahme wartet, und eine ausdrückliche Warnung, wenn etwas auf sein
Review wartet. Dazu das rollenspezifische Briefing (pollen statt blockieren,
Approve ist sein Gate, Push gehört dem Menschen, "still" ≠ "tot").

BUGFIX dabei: Die Rolle wurde aus der LOKALEN Config gelesen und hing damit am
Startverzeichnis — das Quellrepo hat ein .agenthub OHNE Config, wodurch
loadConfig warf und die Auflösung stumm auf "implementer" zurückfiel. Der
Architekt bekam so das Implementer-Briefing und hätte sich einen Task
geclaimt. Jetzt kommt die Rolle vom Hub-Roster, wenn ein Hub erreichbar ist;
der lokale Weg greift nur noch ohne Server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:44:15 +02:00
chahinebrini
b5533359d5 feat(cli): agenthub start <agent> — ein Einzeiler statt Copy-Paste-Prompt
Der Mensch soll pro Session eine Zeile tippen, nicht einen Textblock
einfügen (CEO: „die Prompts sind mega lang"). `agenthub start codex` druckt
jetzt das verbindliche Briefing selbst — Loop-Pflicht, Taubheit während der
Arbeit, pending-Block als einziger Empfangsweg, DEC-0035 — und zeigt
anschließend, was gerade auf den Agenten wartet.

Kein zweites Kommando: das bestehende `start` (Onboarding: announce, Task
claimen, Handoff drucken) nimmt jetzt ein positionales Agent-Argument und
stellt das Briefing voran. `--agent` bleibt für Hooks/Skripte, `--no-briefing`
gibt das alte Verhalten.

Die Rolle kommt aus dem Roster (Architekt wird als solcher erkannt), sodass
`agenthub start <name>` für jeden Agenten ohne weitere Flags reicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:34:48 +02:00
chahinebrini
1f3126b3b5 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>
2026-07-29 18:05:08 +02:00
chahinebrini
6d088648da feat(hub): Korrektheits- und Realtime-Härtung — Alias, Lifecycle, Presence, Architekten-Pulse (TSK-0242/0245/0249)
Drei vom Architekten abgenommene Tasks, gebündelt als Checkpoint:

- TSK-0242: Agent-Alias-Mapping (kimi-ah → kimi kanonisiert, Rollen → preferredAgent),
  reopenTask räumt claimedBy ab, fsWatch reindiziert direkte Datei-Edits,
  work-Default 300s → 50s, task_list mit Limit.
- TSK-0245: zwei Agent-Klassen (dispatch loop|architect). Watchdog mahnt
  architekt-getriebene Agenten nur noch EINMAL statt im Minutentakt;
  `task dispatch` startet sie explizit, `task record` trägt extern
  erledigte Arbeit mit origin=external nach.
- TSK-0249: Lifecycle wird serverseitig erzwungen (open→review scheitert mit
  klarer Meldung), claimedBy/doneBy überleben bis done, Presence pro Agent,
  Review-Watchdog, GET /architect/pulse (1.4 kB statt 34 kB, since-Cursor,
  omitted statt stillem Abschneiden), unbekannter Agent → 400 statt 500.

Alle Punkte live am laufenden Hub nachgemessen, nicht aus Agenten-Logs übernommen.
Tests: 242 → 279 grün, tsc sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:48:24 +02:00
chahinebrini
9dfbf3f657 fix(work): single-claim semantics — max 1 in_progress claim per agent, next auto-claim only after review/done, server-side double-claim guard (TSK-0237)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 02:29:13 +02:00
chahinebrini
f5680c6982 feat(server): agent-health page + realtime auto-wake — SSE Last-Event-ID replay (durable SQLite event log), polling fallback wake on assign/message, /agent-health with reachability ampel + test-message delivery tracking + transport badge (TSK-0230)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:20:20 +02:00
chahinebrini
7bf52a0be3 feat(server): watchdog re-notify, agent health ampel, architect budget visibility
- Watchdog (configurable, ~60s): re-emits SSE + unread reminder + board
  warn log for unclaimed assigned tasks (3min high/critical, 10min else);
  alerts the architect on silent in_progress tasks (>15min, no auto-reassign);
  per-task cooldown against alert spam.
- GET /health (status/version/uptime/counts + per-agent traffic light) with
  lastSeen stamped on announce/claim/review/log/message; board sidebar shows
  the agent ampel; new 'agenthub health' CLI command.
- Budget: architect coordination actions (reviews, handoffs, messages,
  approvals) surface as estimated activity tokens + an 'actions' counter.
- Version bump 0.10.2 (single source: src/version.ts).
2026-07-23 17:52:37 +02:00
chahinebrini
9551c90597 fix(realtime): discovery self-heal, polling fallback, message ack semantics, CRLF SSE parser
- probe configured server URL (~1s) before remote commands; on failure
  re-discover via LAN discovery, persist and use the resolved URL
- poll tryClaim/trySurfaceMessages every ~4s during the SSE wait in CLI
  work and MCP waitForTask — a lost SSE frame costs seconds, never sleep
- work/MCP no longer auto-mark messages read: surfacing sets delivered,
  explicit ack/read required; loops still wake only on unread (no spin)
- parseSSEBuffer accepts CRLF frame separators and joins multi data: lines
- bump version to 0.10.1 (package.json, CLI --version, MCP server)
2026-07-23 16:53:33 +02:00
chahinebrini
52e5d0bf28 fix(cli): claimAndPrintTask zeigt neuesten Handoff statt ersten (Architekt-Hotfix) 2026-07-23 15:05:54 +02:00
chahinebrini
97db2358bb fix(board): kpi chips strictly one line — nowrap, compact durations (5d/3h/25m), no prefix 2026-07-22 19:10:31 +02:00
61 changed files with 5207 additions and 209 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "0.10.0", "version": "0.10.2",
"description": "Local coordination layer for AI coding agents", "description": "Local coordination layer for AI coding agents",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

View File

@ -2,15 +2,20 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { findProjectRoot } from '../../core/paths.js'; import { findProjectRoot } from '../../core/paths.js';
import { agentBriefing } from './startAgent.js';
import { workAgent } from './work.js';
import type { AgentContext } from './start.js';
import { remoteClient } from '../remoteClient.js';
/** /**
* Auto-start: make an agent enter the agenthub work-loop the moment its CLI * Auto-start: make an agent enter the agenthub work-loop the moment its CLI
* session starts no manual "call agenthub_work" needed. * session starts no manual "call agenthub_work" needed.
* *
* Every supported CLI exposes a `SessionStart` lifecycle hook whose command runs * Every supported CLI exposes a `SessionStart` lifecycle hook whose command runs
* `agenthub hook-context`, which prints the work-loop instruction. That text is * `agenthub hook-context`, which prints the work-loop instruction. Codex also
* injected into the new session as context. We write the hook to the RIGHT place * gets a `Stop` hook that waits in `agenthub work` and uses Codex's native
* per CLI: * continuation decision to wake the same thread. We write hooks to the RIGHT
* place per CLI:
* - Claude Code `.claude/settings.json` (project, or ~/ with --user) * - Claude Code `.claude/settings.json` (project, or ~/ with --user)
* - Codex `~/.codex/config.toml` (global; TUI SessionStart hook) * - Codex `~/.codex/config.toml` (global; TUI SessionStart hook)
* - Kimi Code `~/.kimi-code/config.toml` (global; SessionStart hook) * - Kimi Code `~/.kimi-code/config.toml` (global; SessionStart hook)
@ -37,16 +42,86 @@ export function inferCli(agent: string, explicit?: string): AgentCli {
/** Printed by the SessionStart hook → injected as session context. */ /** Printed by the SessionStart hook → injected as session context. */
export function hookContext(opts: { agent: string; role: string }): void { export function hookContext(opts: { agent: string; role: string }): void {
const { agent, role } = opts; const { agent, role } = opts;
process.stdout.write( // Denselben Text wie `agenthub start` ausgeben. Der Hook ist fuer viele
`AgentHub auto-start — you are "${agent}" (${role}). Before anything else, enter the work loop:\n` + // Sessions der EINZIGE Einstieg (Codex/Kimi starten automatisch) — ein
` agenthub work --agent ${agent} --role ${role}\n` + // eigener, aelterer Text hier fuehrt dazu, dass auto-gestartete Agenten den
`(or call the agenthub_work tool with { agent: "${agent}", role: "${role}" }). It BLOCKS until a ` + // Check-in-Kanal und DEC-0035 gar nicht kennen. Genau das war der Fall, bis
`task or message addressed to you arrives, then returns it. Handle it, then IMMEDIATELY call ` + // der CEO bemerkte, dass codex ohne `agenthub start` losgelaufen ist.
`agenthub_work AGAIN — run it in the BACKGROUND so you're woken on the next event without ` + process.stdout.write(`${agentBriefing(agent, role)}\n`);
`tying up your turn. The loop is: work -> handle -> agenthub_task_review -> work, forever. ` + }
`agenthub_work claims your tasks AND delivers your messages. Never run agenthub_task_done. ` +
`Staying in this loop is what keeps you reachable — do not end a turn without relaunching agenthub_work.\n`, /**
); * Codex-native Wake-Adapter. A host-managed `Stop` hook can continue the same
* thread with `decision:block`, so it may safely wait in `agenthub work`
* without relying on a detached PTY or MCP logging to wake the model.
*/
export async function codexStopHook(ctx: AgentContext): Promise<void> {
const lines: string[] = [];
let failed = false;
const originalLog = console.log;
const originalError = console.error;
const capture = (...args: unknown[]) => {
lines.push(args.map((v) => typeof v === 'string' ? v : JSON.stringify(v)).join(' '));
};
console.log = capture;
console.error = capture;
try {
if (ctx.role.toLowerCase() === 'architect') await waitForArchitectChange(ctx);
else await workAgent(ctx);
} catch (err) {
failed = true;
capture(`AgentHub: Stop-Hook fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
} finally {
console.log = originalLog;
console.error = originalError;
}
const reason = [
...lines,
'',
'AgentHub hat dich host-nativ geweckt. Bearbeite die gelieferte Task/Nachricht jetzt.',
'Wenn dieser Zug endet, armiert der Stop-Hook den Work-Loop automatisch erneut.',
].join('\n').trim();
if (failed || lines.some((line) =>
line.includes('and no server to wait on') || line.includes('no server available for architect')
)) {
process.stdout.write(`${JSON.stringify({ continue: false, stopReason: reason })}\n`);
return;
}
process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
}
async function waitForArchitectChange(ctx: AgentContext): Promise<void> {
if (!ctx.serverUrl) {
console.log(`AgentHub: no server available for architect ${ctx.agent}.`);
return;
}
let pulse = await remoteClient.getArchitectPulse(ctx.serverUrl);
let seq = pulse.nextSeq; // Seed only: do not replay stale historical events.
console.log(`AgentHub: architect ${ctx.agent} wartet auf Reviews, Nachrichten, Fragen oder Task-Statusänderungen…`);
for (;;) {
const [reviews, messages, asks, nextPulse] = await Promise.all([
remoteClient.listTasks(ctx.serverUrl, { status: 'review' }),
remoteClient.getInbox(ctx.serverUrl, ctx.agent, true),
remoteClient.listAsks(ctx.serverUrl, { status: 'pending' }),
remoteClient.getArchitectPulse(ctx.serverUrl, seq),
]);
seq = nextPulse.nextSeq;
const taskEvents = nextPulse.events.filter((event) => event.type === 'task');
if (reviews.length || messages.length || asks.length || taskEvents.length) {
if (reviews.length) console.log(`Reviews: ${reviews.map((task) => task.id).join(', ')}`);
for (const message of messages) console.log(`Nachricht ${message.id} von ${message.from}: ${message.text}`);
for (const ask of asks) console.log(`Frage ${ask.id} von ${ask.from}: ${ask.question}`);
for (const event of taskEvents) {
console.log(`Task-Status: ${event.id}${event.status ?? event.action}${event.assignedTo ? ` @${event.assignedTo}` : ''}`);
}
return;
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
} }
// ─── Marker-delimited upsert (for line-based TOML configs) ─────────────────── // ─── Marker-delimited upsert (for line-based TOML configs) ───────────────────
@ -110,21 +185,36 @@ function setupClaude(cwd: string, opts: { agent: string; role: string; user?: bo
out(' Note: SessionStart context is a strong nudge, not a hard guarantee.'); out(' Note: SessionStart context is a strong nudge, not a hard guarantee.');
} }
function setupCodex(opts: { agent: string; role: string }, out: (s: string) => void): void { export function codexHookBlock(agent: string, role: string): string {
const path = join(homedir(), '.codex', 'config.toml'); // Deliberately omit --role: the active role is resolved from AgentHub on
const command = `agenthub hook-context --agent ${opts.agent} --role ${opts.role}`; // every hook run, so switching codex implementer ↔ architect takes effect
const block = // in the next continuation without reinstalling the hook.
const startCommand = `agenthub hook-context --agent ${agent}`;
const stopCommand = `agenthub hook-stop --agent ${agent}`;
return (
`[[hooks.SessionStart]]\n` + `[[hooks.SessionStart]]\n` +
`matcher = "startup|resume"\n\n` + `matcher = "startup|resume"\n\n` +
`[[hooks.SessionStart.hooks]]\n` + `[[hooks.SessionStart.hooks]]\n` +
`type = "command"\n` + `type = "command"\n` +
`command = ${JSON.stringify(command)}\n` + `command = ${JSON.stringify(startCommand)}\n` +
`timeout = 30\n` + `timeout = 30\n` +
`statusMessage = "AgentHub auto-start (${opts.agent})"`; `statusMessage = "AgentHub auto-start (${agent})"\n\n` +
`[[hooks.Stop]]\n\n` +
`[[hooks.Stop.hooks]]\n` +
`type = "command"\n` +
`command = ${JSON.stringify(stopCommand)}\n` +
`timeout = 86400\n` +
`statusMessage = "AgentHub: warte auf Arbeit für ${agent}"`
);
}
function setupCodex(opts: { agent: string; role: string }, out: (s: string) => void): void {
const path = join(homedir(), '.codex', 'config.toml');
const block = codexHookBlock(opts.agent, opts.role);
upsertMarkerBlock(path, block); upsertMarkerBlock(path, block);
out(`AgentHub: Codex auto-start hook written -> ${path}`); out(`AgentHub: Codex auto-start hook written -> ${path}`);
out(` New Codex TUI sessions start as "${opts.agent}" (${opts.role}) and enter the work loop.`); out(` New Codex TUI sessions start as "${opts.agent}" (${opts.role}).`);
out(' Codex fires SessionStart hooks from the global config in interactive sessions.'); out(' The Stop hook waits in agenthub work and resumes this thread natively when work arrives.');
} }
function setupKimi(opts: { agent: string; role: string }, out: (s: string) => void): void { function setupKimi(opts: { agent: string; role: string }, out: (s: string) => void): void {

View File

@ -0,0 +1,49 @@
import type { HealthReport } from '../../core/services/presenceService.js';
/** `agenthub health` — compact hub status + per-agent traffic light. */
const LIGHT: Record<string, string> = {
active: '●', // green — acted <2min ago
busy: '●', // blue — working a task
idle: '○', // gray — nothing in flight
stale: '●', // red — >10min silent with an open assignment
};
function fmtDuration(sec: number): string {
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 48) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
function fmtAgo(sec: number | undefined): string {
return sec === undefined ? 'never seen' : `${fmtDuration(sec)} ago`;
}
export function printHealth(h: HealthReport): void {
const c = h.counts;
console.log(
`hub: ${h.status} · v${h.version} · up ${fmtDuration(h.uptimeSec)} · ` +
`${c.tasks} tasks (${c.open} open, ${c.inProgress} in progress, ${c.review} review) · ${c.unreadMessages} unread`,
);
for (const error of h.indexErrors ?? []) {
console.log(`! index error · ${error.filePath} · ${error.error}`);
}
if (h.agents.length === 0) {
console.log('no agents yet');
return;
}
const width = Math.max(...h.agents.map((a) => a.name.length));
for (const a of h.agents) {
const light = LIGHT[a.state] ?? '○';
const task = a.taskId ? ` ${a.taskId}${a.state === 'stale' ? ' open' : ''} ·` : '';
const loop = a.inLoop
? ` · in loop ${fmtAgo(a.loopSinceAgoSec)}`
: a.loopExitReason
? ` · out of loop ${fmtAgo(a.loopExitAgoSec)} (${a.loopExitReason})`
: ' · loop unknown';
console.log(`${a.name.padEnd(width)} ${light} ${a.state}${task} ${fmtAgo(a.lastSeenAgoSec)}${loop}`);
}
}

View File

@ -1,4 +1,5 @@
import { startServer } from '../../server/index.js'; import { startServer } from '../../server/index.js';
import { findProjectRoot } from '../../core/paths.js';
/** /**
* Probe a URL to check whether an AgentHub server is already answering there. * Probe a URL to check whether an AgentHub server is already answering there.
@ -30,5 +31,22 @@ export async function serverStart(cwd: string, options: { port: number; host: st
return; return;
} }
await startServer(cwd, options); // Verzeichnis-Guard: NICHT den Port binden, wenn hier gar kein Projekt liegt.
// Ohne diese Prüfung bindet der Server erst den Port, stolpert dann beim
// Laden der Config und stirbt — der Port ist kurz belegt, der alte Hub ist
// schon gekillt, und die Agenten laufen ins Leere. Klassischer Fall: aus dem
// agenthub-Quellrepo statt aus dem Projekt gestartet (mir an einem Tag
// dreimal passiert). Lieber sofort und verständlich scheitern.
const projectRoot = findProjectRoot(cwd);
if (!projectRoot) {
console.error(
`Kein AgentHub-Projekt in ${cwd} (und keinem übergeordneten Verzeichnis).\n` +
`Starte den Server aus dem Projekt-Root — dort liegt .agenthub/ mit den Daten.\n` +
`Falls das hier ein neues Projekt werden soll: agenthub init`,
);
process.exitCode = 1;
return;
}
await startServer(projectRoot, options);
} }

View File

@ -5,6 +5,7 @@ import {
claimTask as svcClaimTask, claimTask as svcClaimTask,
} from '../../core/services/taskService.js'; } from '../../core/services/taskService.js';
import { listHandoffs as svcListHandoffs, getHandoff as svcGetHandoff } from '../../core/services/handoffService.js'; import { listHandoffs as svcListHandoffs, getHandoff as svcGetHandoff } from '../../core/services/handoffService.js';
import { agentIdentity } from '../../core/services/identityService.js';
export interface AgentContext { export interface AgentContext {
serverUrl?: string; serverUrl?: string;
@ -21,6 +22,8 @@ interface Listed {
taskId?: string; taskId?: string;
toAgent?: string; toAgent?: string;
assignedTo?: string; assignedTo?: string;
claimedBy?: string;
createdAt?: string;
} }
/** Announce presence (best-effort) and print the joined line. */ /** Announce presence (best-effort) and print the joined line. */
@ -58,34 +61,88 @@ export async function listReviewTasks(ctx: AgentContext): Promise<Listed[]> {
: svcListTasks(ctx.projectCwd, { status: 'review' }); : svcListTasks(ctx.projectCwd, { status: 'review' });
} }
const PRIORITY_RANK: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 };
/** /**
* Find the open task addressed to this agent. "Addressed" = task title starts * Find the ONE open task this agent should claim next. "Addressed" = task
* with "<agent>:" (delegation convention), OR a handoff for the task has * title starts with "<agent>:" (delegation convention), OR a handoff for the
* toAgent === <agent>, OR the task is already assignedTo this agent (a reopened * task has toAgent === <agent>, OR the task is already assignedTo this agent
* task that came back for rework). Returns the task + the handoff list (so the * (a reopened task that came back for rework).
* caller can print the matching handoff without re-fetching). *
* Single-claim semantics (TSK-0237): while the agent already holds an
* in_progress task, NOTHING is addressed the next auto-claim happens only
* after that task reaches review/done (or the claim is taken back via
* reopen). Among several addressed candidates exactly one wins: highest
* priority, oldest createdAt breaks ties. The rest stays open/assigned.
* Returns the task + the handoff list (so the caller can print the matching
* handoff without re-fetching).
*/ */
export async function findAddressedOpenTask( export async function findAddressedOpenTask(
ctx: AgentContext, ctx: AgentContext,
): Promise<{ task: Listed; handoffs: Listed[] } | undefined> { ): Promise<{ task: Listed; handoffs: Listed[] } | undefined> {
const a = ctx.agent.toLowerCase(); const identity = ctx.serverUrl
? await remoteClient.getAgentIdentity(ctx.serverUrl, ctx.agent).then((value) => ({
canonical: value.canonical,
names: new Set(value.names),
}))
: agentIdentity(ctx.projectCwd, ctx.agent);
const a = identity.canonical.toLowerCase();
const matchesAgent = (value: unknown) => identity.names.has(String(value ?? '').toLowerCase());
// 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); const tasks = await listOpenRoleTasks(ctx);
const handoffs = ctx.serverUrl ? await remoteClient.listHandoffs(ctx.serverUrl) : svcListHandoffs(ctx.projectCwd); const handoffs = ctx.serverUrl ? await remoteClient.listHandoffs(ctx.serverUrl) : svcListHandoffs(ctx.projectCwd);
const addressedByHandoff = new Set( const addressedByHandoff = new Set(
handoffs handoffs
.filter((h) => h.toAgent && String(h.toAgent).toLowerCase() === a && h.taskId) .filter((h) => h.toAgent && matchesAgent(h.toAgent) && h.taskId)
.map((h) => String(h.taskId)), .map((h) => String(h.taskId)),
); );
const mine = tasks.filter( const mine = tasks.filter(
(t) => (t) =>
(t.title ?? '').toLowerCase().startsWith(`${a}:`) || (t.title ?? '').toLowerCase().startsWith(`${a}:`) ||
addressedByHandoff.has(t.id) || addressedByHandoff.has(t.id) ||
(t.assignedTo && String(t.assignedTo).toLowerCase() === a), (t.assignedTo && matchesAgent(t.assignedTo)),
); );
if (mine.length === 0) return undefined; if (mine.length === 0) return undefined;
return { task: mine[0], handoffs }; if (mine.length === 1) return { task: mine[0], handoffs };
// Priority is not part of the task index — fetch it per candidate (only the
// few addressed ones), rank critical > high > medium > low, oldest first.
const ranked = await Promise.all(
mine.map(async (t) => {
let rank = PRIORITY_RANK.medium;
try {
// Both the remote client and the local service return { task, body }.
const detail = ctx.serverUrl ? await remoteClient.getTask(ctx.serverUrl, t.id) : svcGetTask(ctx.projectCwd, t.id);
const full = (detail as { task?: { priority?: string } }).task;
rank = PRIORITY_RANK[full?.priority ?? 'medium'] ?? PRIORITY_RANK.medium;
} catch {
/* missing priority ⇒ medium */
}
return { t, rank };
}),
);
ranked.sort((x, y) => y.rank - x.rank || String(x.t.createdAt ?? '').localeCompare(String(y.t.createdAt ?? '')));
return { task: ranked[0].t, handoffs };
} }
/** Claim the task and print its body + handoff + the review-gate next step. */ /** Claim the task and print its body + handoff + the review-gate next step. */
@ -104,11 +161,23 @@ export async function claimAndPrintTask(ctx: AgentContext, task: Listed, handoff
/* body is optional */ /* body is optional */
} }
const hof = handoffs.find((h) => h.taskId === task.id); // Pick the LATEST handoff for this task, not the first one created. When a
// task is reopened with a corrective handoff, the newer handoff supersedes
// the older; showing the stale first-created one made agents rebuild against
// an outdated spec — the architect's correction never actually reached them.
// Handoff ids are zero-padded (HOF-0061 > HOF-0056), so a descending string
// sort yields the newest.
const taskHofs = handoffs
.filter((h) => h.taskId === task.id)
.sort((a, b) => String(b.id).localeCompare(String(a.id)));
const hof = taskHofs[0];
if (hof) { if (hof) {
try { try {
const hd = ctx.serverUrl ? await remoteClient.getHandoff(ctx.serverUrl, hof.id) : svcGetHandoff(ctx.projectCwd, hof.id); const hd = ctx.serverUrl ? await remoteClient.getHandoff(ctx.serverUrl, hof.id) : svcGetHandoff(ctx.projectCwd, hof.id);
console.log(`\n─ Handoff ${hof.id} ──────────────`); console.log(`\n─ Handoff ${hof.id} ──────────────`);
if (taskHofs.length > 1) {
console.log(`(latest of ${taskHofs.length} handoffs — supersedes ${taskHofs.slice(1).map((h) => h.id).join(', ')})`);
}
console.log(hd.handoff.summary); console.log(hd.handoff.summary);
if (hd.body && hd.body.trim()) console.log(hd.body.trim()); if (hd.body && hd.body.trim()) console.log(hd.body.trim());
} catch { } catch {

View File

@ -0,0 +1,252 @@
import { loadConfig } from '../../core/config.js';
import { resolveAgentName } from '../../core/services/identityService.js';
import { resolvePending } from '../../core/services/checkinService.js';
import { remoteClient } from '../remoteClient.js';
/**
* `agenthub start <agent>` der EINE Befehl, mit dem eine frische
* Agenten-Session anfängt.
*
* Motivation (CEO 2026-07-29): Die Prompts sind mega lang, ich brauche eine
* Version, die ich immer verwenden kann." Statt einen Textblock in jede
* Session zu kopieren, druckt dieser Befehl das verbindliche Briefing selbst
* inklusive dessen, was gerade auf den Agenten wartet. Der Mensch tippt eine
* Zeile, der Agent liest den Rest hier.
*
* Absichtlich reiner Text (kein JSON): die Ausgabe landet im Kontext eines
* LLM-Agenten und muss als Anweisung lesbar sein.
*/
export interface StartAgentOptions {
serverUrl?: string;
projectCwd: string;
agent: string;
role?: string;
}
/** Das verbindliche Briefing. Wird von `agenthub start` UND vom SessionStart-Hook
* benutzt beide MÜSSEN denselben Text liefern, sonst arbeitet ein
* auto-gestarteter Agent nach veralteten Regeln. */
export function agentBriefing(agent: string, role: string): string {
return [
`Du bist "${agent}" im AgentHub (Rolle: ${role}).`,
'',
'SO ARBEITEST DU:',
'1. ERSTE AKTION, noch vor allem anderen — starte den Work-Loop als',
' HINTERGRUND-TASK DEINER EIGENEN UMGEBUNG (so, dass DU benachrichtigt',
' wirst, wenn er endet), NICHT als Tool-Call im Turn:',
` agenthub work --agent ${agent} --role ${role}`,
' OHNE --timeout: der CLI-Worker wartet dann unbegrenzt und endet erst,',
' wenn Arbeit fuer dich da ist. Sein Ende weckt dich — genau darum geht es.',
' Ein Tool-Call stirbt mit deinem Turn, ein Hintergrund-Task nicht. Daran',
' ist die Erreichbarkeit bisher gescheitert.',
' Sobald er dir etwas geliefert hat: bearbeiten UND den Worker neu starten.',
` (Notnagel, falls dein Host keine Hintergrund-Tasks kann: das Tool`,
` agenthub_work(agent="${agent}", role="${role}") — dann OHNE timeoutSec,`,
' die passende Wartezeit steht im Roster und ein explizites Argument',
' wuerde sie ueberschreiben.)',
'2. Deine Aufgabe steht IMMER im Handoff des Tasks. Lies ihn, bevor du baust.',
`3. Logge nach jedem Teilschritt: agenthub_task_log(id, text, agent="${agent}").`,
'',
'DU WIRST GEPUSHT — auch ohne laufenden Tool-Call:',
'Die AgentHub-Bridge schickt dir MCP-Benachrichtigungen, sobald etwas für',
'dich anliegt: neue oder zurückgegebene Task, Nachricht, beantwortete Frage,',
'Abbruch. Diese Meldungen sind VERBINDLICH — reagiere sofort darauf, statt',
'zu warten. Steht dort ein Abbruch oder eine Rückgabe: aufhören, nicht',
'einreichen.',
'',
'ZUSÄTZLICH (Netz, kein Ersatz): die ANTWORT von agenthub_task_log trägt',
'einen `pending`-Block, wenn etwas offen ist. Loggen bleibt Pflicht — es ist',
'dein zweiter Empfangsweg und macht deinen Fortschritt sichtbar.',
'',
' - Enthält die Antwort `pending.interrupted`, wurde dir der Task',
' zurückgegeben, abgebrochen oder entzogen: SOFORT aufhören, NICHT',
' einreichen, `pending.interrupted.action` befolgen.',
' - Enthält sie ungelesene Nachrichten: mit agenthub_inbox lesen und antworten.',
` - Für lange Strecken ohne Log-Zeile: agenthub_checkin(agent="${agent}", taskId="<dein Task>").`,
'',
'NACH DEM EINREICHEN (DEC-0035):',
'Mit agenthub_task_review bist du GEBUNDEN, bis der Architekt abnimmt oder',
'zurückweist. Du bekommst bewusst keine neue Task — das ist kein Fehler.',
'Bleib im Loop, du wirst geweckt.',
'',
'GRENZEN: Du markierst NIE selbst done (das ist das Gate des Architekten).',
'Du pushst nie ohne Freigabe. Bei Unklarheit: agenthub_ask statt raten.',
].join('\n');
}
/**
* Der Architekt startet anders als ein Implementer: er kann prinzipiell NICHT
* in `agenthub_work` blockieren (er ist die interaktive Gegenstelle des
* Menschen), also ist Pollen für ihn kein Workaround, sondern der richtige Weg.
* Sein Sessionstart ist deshalb ein Lagebericht, kein Loop-Einstieg.
*/
function architectBriefing(agent: string): string {
return [
`Du bist "${agent}" — Architekt im AgentHub.`,
'',
'DEIN UNTERSCHIED ZU DEN IMPLEMENTERN:',
'Du gehst NICHT in agenthub_work. Ein blockierender Loop ist für dich',
'unmöglich, weil du gleichzeitig mit dem Menschen sprichst. Stattdessen',
'prüfst du den Stand BEI JEDEM ZUG selbst:',
' GET /architect/pulse?since=<nextSeq> → Reviews, dormante Agenten,',
' neue Nachrichten, Änderungen. Klein genug für jeden Zug.',
'',
'DEINE GATES:',
' - Review/Approve ist DEIN Gate. Du fragst dafür niemanden um Erlaubnis.',
' - Push, Release-Builds und Grundsatzentscheidungen gehören dem Menschen.',
' - Was auf `review` steht, wartet auf DICH — melde es ungefragt.',
' - Agenten mit offener Review sind gebunden (DEC-0035): erst dein done',
' oder reopen gibt sie frei.',
'',
'WENN ETWAS STILL AUSSIEHT:',
'Ein Agent, der arbeitet, empfängt nichts — er ist taub bis zu seinem',
'nächsten Check-in. "Kein Log seit X" heißt also nicht "tot". Prüfe erst',
'das Task-Log, bevor du jemanden für dormant erklärst.',
].join('\n');
}
async function architectHealthReport(serverUrl: string | undefined): Promise<string[]> {
const out: string[] = [];
if (!serverUrl) {
out.push('HUB: kein Server erreichbar — lokaler Dateimodus.');
return out;
}
try {
const health = await remoteClient.getHealth(serverUrl);
const c = health.counts;
out.push(`HUB: ${health.status} · v${health.version} · seit ${Math.round(health.uptimeSec / 60)} min`);
out.push(`BOARD: ${c.open} offen · ${c.inProgress} in Arbeit · ${c.review} in Review`);
const busy = health.agents.filter((a) => a.state === 'busy');
const waiting = health.agents.filter((a) => a.pendingCount > 0 && a.state !== 'busy');
if (busy.length) {
out.push('IN ARBEIT:');
for (const a of busy) {
const deaf = a.deafForSec != null ? ` · kein Check-in seit ${Math.round(a.deafForSec / 60)} min` : '';
out.push(` ${a.name}${a.taskId ?? '?'}${deaf}`);
}
}
if (waiting.length) {
out.push('WARTET AUF ÜBERNAHME:');
for (const a of waiting) out.push(` ${a.name}: ${a.pendingCount} Task(s)`);
}
if (c.review > 0) out.push(`⚠️ ${c.review} Task(s) warten auf DEIN Review — zuerst erledigen.`);
if (!busy.length && !waiting.length && !c.review) out.push('Nichts hängt. Sauberer Start.');
} catch {
out.push('HUB: nicht erreichbar. Läuft `agenthub server start --host 0.0.0.0` aus dem Projekt-Root?');
}
return out;
}
/** Gibt die aufgelöste Rolle zurück, damit der Aufrufer weiß, ob noch ein
* Implementer-Onboarding (Task claimen) folgen soll. */
export async function startAgentSession(options: StartAgentOptions): Promise<string> {
const { projectCwd, serverUrl } = options;
let agent = options.agent;
let role = options.role;
// A role name is also a stable session entry point. This lets the human use
// `agenthub start architect` without knowing whether Claude, Codex, or a
// future agent currently holds that role.
if (!role && serverUrl) {
try {
const roles = await remoteClient.listRoles(serverUrl);
const requestedRole = Object.keys(roles).find(
(name) => name.toLowerCase() === options.agent.toLowerCase(),
);
if (requestedRole) {
role = requestedRole;
agent = roles[requestedRole].preferredAgent;
}
} catch {
// Hub nicht erreichbar — die lokale Config wird weiter unten versucht.
}
}
if (!role) {
try {
const config = loadConfig(projectCwd);
const requestedRole = Object.keys(config.roles ?? {}).find(
(name) => name.toLowerCase() === options.agent.toLowerCase(),
);
if (requestedRole) {
role = requestedRole;
agent = config.roles[requestedRole].preferredAgent;
}
} catch {
// Keine lokale Config — normale Agent-Auflösung läuft weiter.
}
}
// Rolle IMMER aus dem Roster des Hubs, wenn einer erreichbar ist: der Server
// hält die Wahrheit. Der lokale Weg greift nur ohne Hub — sonst hängt das
// Ergebnis davon ab, aus welchem Verzeichnis der Befehl gestartet wurde
// (das Quellrepo hat z. B. ein .agenthub OHNE Config, was die lokale
// Auflösung stumm auf "implementer" zurückfallen ließ).
if (!role && serverUrl) {
try {
const health = await remoteClient.getHealth(serverUrl);
const match = health.agents.find((a) => a.name.toLowerCase() === agent.toLowerCase());
if (match) {
agent = match.name;
role = match.role;
}
} catch {
// Hub nicht erreichbar — lokaler Weg unten.
}
}
if (!role) {
try {
agent = resolveAgentName(projectCwd, options.agent);
const config = loadConfig(projectCwd);
const architect = config.roles?.architect?.preferredAgent;
role = agent === architect ? 'architect' : (config.agents?.[agent]?.role ?? 'implementer');
} catch {
role = 'implementer';
}
}
if (serverUrl) {
try {
await remoteClient.announce(serverUrl, agent, role);
} catch {
// Präsenz ist best-effort — ein nicht erreichbarer Hub darf den Start
// nicht verhindern, das Briefing gilt trotzdem.
}
}
// Architekt: Lagebericht statt Loop-Einstieg.
if (role === 'architect') {
console.log(architectBriefing(agent));
console.log('');
for (const line of await architectHealthReport(serverUrl)) console.log(line);
return 'architect';
}
console.log(agentBriefing(agent, role ?? "implementer"));
// Was liegt gerade an? Damit die Session nicht blind in den Loop geht.
try {
const pending = serverUrl
? ((await remoteClient.getPending(serverUrl, agent)) as ReturnType<typeof resolvePending>)
: resolvePending(projectCwd, agent);
const lines: string[] = [];
// Zuerst und am deutlichsten: eine bereits gehaltene Task. Sonst wartet der
// Agent nach einem Neustart auf neue Arbeit, waehrend seine eigene liegt.
if (pending.heldTask) {
lines.push(`⚠️ DU HAELTST BEREITS ${pending.heldTask} — arbeite DORT weiter und reiche sie ein, bevor du Neues anfaengst.`);
}
if (pending.waitingTasks.length) lines.push(`Offen für dich: ${pending.waitingTasks.join(', ')}`);
if (pending.awaitingReview.length) lines.push(`Wartet auf Architekten-Review: ${pending.awaitingReview.join(', ')} — du bist so lange gebunden.`);
if (pending.unreadCount) lines.push(`Ungelesene Nachrichten: ${pending.unreadCount}`);
console.log('');
console.log(lines.length ? `AKTUELL:\n ${lines.join('\n ')}` : 'AKTUELL: nichts offen — geh in den Loop und warte.');
} catch {
console.log('');
console.log('AKTUELL: Status nicht abrufbar — geh trotzdem in den Loop.');
}
return role ?? 'implementer';
}

View File

@ -1,5 +1,5 @@
import { input, select } from '@inquirer/prompts'; import { input, select } from '@inquirer/prompts';
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js'; import { createTask, recordExternalTask, dispatchTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
import { appendTaskLog } from '../../core/services/taskLogService.js'; import { appendTaskLog } from '../../core/services/taskLogService.js';
import type { Task } from '../../core/schema.js'; import type { Task } from '../../core/schema.js';
@ -42,6 +42,14 @@ export function taskClaim(cwd: string, id: string, agentName: string): void {
claimTask(cwd, id, agentName); claimTask(cwd, id, agentName);
console.log(`AgentHub: Task claimed ${id} by ${agentName}`); console.log(`AgentHub: Task claimed ${id} by ${agentName}`);
} }
export function taskDispatch(cwd: string, id: string, agentName: string): void {
dispatchTask(cwd, id, agentName);
console.log(`AgentHub: Architect started ${agentName} for ${id}`);
}
export function taskRecord(cwd: string, options: { title: string; doneBy: string; description?: string; role?: Task['role']; priority?: Task['priority'] }): void {
const task = recordExternalTask(cwd, options);
console.log(`AgentHub: External work recorded ${task.id} by ${task.doneBy}`);
}
export function taskDone( export function taskDone(
cwd: string, cwd: string,

View File

@ -24,21 +24,30 @@ export type { AgentHubEvent } from '../../server/events.js';
* Parse all complete SSE events from a text buffer. * Parse all complete SSE events from a text buffer.
* *
* SSE wire format: "data: <json>\n\n" per event, ": \n\n" for keepalives. * SSE wire format: "data: <json>\n\n" per event, ": \n\n" for keepalives.
* This function splits on double-newline boundaries, extracts the `data:` * Frame separators are CRLF-tolerant: both "\n\n" and "\r\n\r\n" (and mixed
* line from each complete block, and returns anything that didn't end with * line endings inside a frame) are accepted. An event with multiple `data:`
* "\n\n" as `remaining` (to be prepended to the next chunk). * lines is joined with "\n" before parsing, per the SSE spec.
* Anything that didn't end with a blank line is returned as `remaining`
* (to be prepended to the next chunk).
*/ */
export function parseSSEBuffer(buffer: string): { events: AgentHubEvent[]; remaining: string } { export function parseSSEBuffer(buffer: string): { events: AgentHubEvent[]; remaining: string } {
const parts = buffer.split('\n\n'); const parts = buffer.split(/\r?\n\r?\n/);
const remaining = parts.pop() ?? ''; // last segment may be incomplete const remaining = parts.pop() ?? ''; // last segment may be incomplete
const events: AgentHubEvent[] = []; const events: AgentHubEvent[] = [];
for (const part of parts) { for (const part of parts) {
// A keepalive block looks like ":" — no data line. // A keepalive block looks like ":" — no data line.
const dataLine = part.split('\n').find((l) => l.startsWith('data: ')); const lines = part.split(/\r?\n/);
if (!dataLine) continue; const eventName = lines.find((l) => l.startsWith('event: '))?.slice(7);
if (eventName && eventName !== 'message') continue;
const idLine = lines.find((l) => l.startsWith('id: '));
const seq = idLine ? Number.parseInt(idLine.slice(4), 10) : undefined;
const dataLines = lines.filter((l) => l.startsWith('data: ')).map((l) => l.slice(6));
if (dataLines.length === 0) continue;
try { try {
events.push(JSON.parse(dataLine.slice(6)) as AgentHubEvent); const event = JSON.parse(dataLines.join('\n')) as AgentHubEvent;
if (seq !== undefined && Number.isFinite(seq)) event.seq = seq;
events.push(event);
} catch { } catch {
// Ignore malformed JSON — should never happen in practice. // Ignore malformed JSON — should never happen in practice.
} }
@ -159,10 +168,16 @@ async function fetchUnreadMessages(serverUrl: string, agent: string): Promise<Ag
} }
} }
function isMessageFor(event: AgentHubEvent, agent: string): boolean { function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: string[] = []): boolean {
if (event.type !== 'message' || event.action !== 'created') return false; if (event.type !== 'message' || event.action !== 'created') return false;
const to = event.assignedTo; const to = event.assignedTo;
return !!to && messageRecipientAliases(agent).has(String(to).toLowerCase()); if (!to || !messageRecipientAliases(agent).has(String(to).toLowerCase())) return false;
// Absender ausblenden, die den Notifier nur zumüllen. Konkreter Fall: der
// Watchdog schreibt dem Architekten alle paar Minuten Erinnerungen — ohne
// Filter weckt der Notifier ihn im Takt dieser Erinnerungen, obwohl nichts
// Neues passiert ist, und das Signal entwertet sich selbst.
const sender = String(event.from ?? (event.title ?? '').split('→')[0] ?? '').trim().toLowerCase();
return !ignoreFrom.some((ignored) => ignored.trim().toLowerCase() === sender);
} }
/** /**
@ -179,7 +194,7 @@ function isMessageFor(event: AgentHubEvent, agent: string): boolean {
*/ */
export async function watchEvents( export async function watchEvents(
serverUrl: string, serverUrl: string,
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean } = {}, options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean; ignoreFrom?: string[] } = {},
): Promise<void> { ): Promise<void> {
const url = new URL('/events', serverUrl); const url = new URL('/events', serverUrl);
// Pass role to the server for an additional server-side filter (saves // Pass role to the server for an additional server-side filter (saves
@ -187,6 +202,7 @@ export async function watchEvents(
if (options.role) url.searchParams.set('role', options.role); if (options.role) url.searchParams.set('role', options.role);
let response: Response; let response: Response;
let lastEventId: number | undefined;
try { try {
response = await fetch(url.toString(), { response = await fetch(url.toString(), {
headers: { Accept: 'text/event-stream' }, headers: { Accept: 'text/event-stream' },
@ -250,6 +266,7 @@ export async function watchEvents(
buffer = remaining; buffer = remaining;
for (const event of events) { for (const event of events) {
if (event.seq !== undefined) lastEventId = event.seq;
// Client-side role filter: skip tasks that don't match the requested // Client-side role filter: skip tasks that don't match the requested
// role. Non-task events (handoffs, decisions, memory) always print. // role. Non-task events (handoffs, decisions, memory) always print.
if (options.role && event.type === 'task' && event.role !== undefined && event.role !== options.role) { if (options.role && event.type === 'task' && event.role !== undefined && event.role !== options.role) {
@ -269,7 +286,7 @@ export async function watchEvents(
await reader.cancel(); await reader.cancel();
return; return;
} }
if (options.awaitMessage && isMessageFor(event, options.awaitMessage)) { if (options.awaitMessage && isMessageFor(event, options.awaitMessage, options.ignoreFrom)) {
await reader.cancel(); await reader.cancel();
return; return;
} }

View File

@ -1,5 +1,6 @@
import { parseSSEBuffer } from './watch.js'; import { parseSSEBuffer } from './watch.js';
import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js'; import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js';
import { resolvePending } from '../../core/services/checkinService.js';
import { discoverServer as discoverHubServer } from '../../discovery.js'; import { discoverServer as discoverHubServer } from '../../discovery.js';
import { remoteClient } from '../remoteClient.js'; import { remoteClient } from '../remoteClient.js';
@ -19,6 +20,8 @@ interface WorkAgentContext extends AgentContext {
timeoutSec?: number; timeoutSec?: number;
discoverServer?: (timeoutMs?: number) => Promise<string | undefined>; discoverServer?: (timeoutMs?: number) => Promise<string | undefined>;
reconnectBackoffMs?: number[]; reconnectBackoffMs?: number[];
/** Polling-fallback interval while the SSE wait is open (default 4000 ms). */
pollIntervalMs?: number;
/** /**
* Unattended mode (TSK-0118): the agent runs without a human at the keyboard. * Unattended mode (TSK-0118): the agent runs without a human at the keyboard.
* It must never pause for human input when it needs a decision it routes an * It must never pause for human input when it needs a decision it routes an
@ -28,11 +31,17 @@ interface WorkAgentContext extends AgentContext {
} }
/** /**
* Fetch + print + mark-read the agent's unread messages. Returns how many were * Fetch + print the agent's unread messages. Returns how many were surfaced.
* surfaced. This is what lets the work loop wake on an architect follow-up / * This is what lets the work loop wake on an architect follow-up / question
* question (TSK-0119): after a `task review` submit the implementer re-arms * (TSK-0119): after a `task review` submit the implementer re-arms `work` and
* `work` and stays reachable a reopen or a new assignment wakes it via a task * stays reachable a reopen or a new assignment wakes it via a task event,
* event, and a plain message wakes it here instead of leaving it dormant. * and a plain message wakes it here instead of leaving it dormant.
*
* Surfacing flips each message `unread` `delivered` (the listInbox read
* receipt), but deliberately does NOT mark it `read`: the message stays
* visible in the inbox until the agent explicitly acks/reads it, so a
* surfaced-but-missed message is never lost. The loop wakes only on `unread`,
* so a delivered message never re-wakes it (no spin).
*/ */
async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> { async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
if (!ctx.serverUrl) return 0; if (!ctx.serverUrl) return 0;
@ -46,11 +55,6 @@ async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
console.log(`AgentHub: ${msgs.length} message${msgs.length === 1 ? '' : 's'} for ${ctx.agent}:`); console.log(`AgentHub: ${msgs.length} message${msgs.length === 1 ? '' : 's'} for ${ctx.agent}:`);
for (const m of msgs) { for (const m of msgs) {
console.log(` ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`); console.log(` ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
try {
await remoteClient.markMessageRead(ctx.serverUrl, m.id);
} catch {
/* best-effort */
}
} }
return msgs.length; return msgs.length;
} }
@ -58,6 +62,21 @@ async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
export async function workAgent(ctx: WorkAgentContext): Promise<void> { export async function workAgent(ctx: WorkAgentContext): Promise<void> {
await announceAgent(ctx.serverUrl, ctx.agent, ctx.role); await announceAgent(ctx.serverUrl, ctx.agent, ctx.role);
// Haelt der Agent schon eine Task? Der Finder unten sucht nur `open` — nach
// einem Session-Neustart wartet der Worker deshalb auf neue Arbeit, waehrend
// die eigene, bereits geclaimte Task unbearbeitet liegt. Das war ein echter
// Stillstand: Task auf in_progress, Agent tut nichts, niemand sieht warum.
try {
const held = resolvePending(ctx.projectCwd, ctx.agent).heldTask;
if (held) {
console.log(
`AgentHub: du haeltst bereits ${held}. Arbeite dort weiter und reiche sie mit `
+ `\`agenthub task review ${held}\` ein — erst danach kommt Neues.`,
);
return;
}
} catch { /* ohne Projekt-Kontext weiter wie bisher */ }
// Already-waiting task? // Already-waiting task?
const found = await findAddressedOpenTask(ctx); const found = await findAddressedOpenTask(ctx);
if (found) { if (found) {
@ -102,9 +121,11 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
let settled = false; let settled = false;
let controller: AbortController | undefined; let controller: AbortController | undefined;
let poll: NodeJS.Timeout | undefined;
const finish = () => { const finish = () => {
if (settled) return; if (settled) return;
settled = true; settled = true;
if (poll) clearInterval(poll);
try { try {
controller?.abort(); controller?.abort();
} catch { } catch {
@ -122,36 +143,74 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
const deadline = ctx.timeoutSec ? Date.now() + ctx.timeoutSec * 1000 : undefined; const deadline = ctx.timeoutSec ? Date.now() + ctx.timeoutSec * 1000 : undefined;
const backoffs = ctx.reconnectBackoffMs ?? [2000, 5000, 10000]; const backoffs = ctx.reconnectBackoffMs ?? [2000, 5000, 10000];
let reconnectAttempt = 0; let reconnectAttempt = 0;
let lastEventId: number | undefined;
// Guards against overlapping checks (SSE-triggered vs. polling fallback).
let checking = false;
// Re-query then claim if a task addressed to us is now open. Returns true // Re-query then claim if a task addressed to us is now open. Returns true
// if a task was claimed (so the caller can stop). // if a task was claimed (so the caller can stop).
const tryClaim = async (): Promise<boolean> => { const tryClaim = async (): Promise<boolean> => {
ctx.serverUrl = serverUrl; if (checking) return false;
const f = await findAddressedOpenTask(ctx); checking = true;
if (!f) return false; try {
if (timer) clearTimeout(timer); ctx.serverUrl = serverUrl;
await claimAndPrintTask(ctx, f.task, f.handoffs); const f = await findAddressedOpenTask(ctx);
finish(); if (!f) return false;
return true; if (timer) clearTimeout(timer);
await claimAndPrintTask(ctx, f.task, f.handoffs);
finish();
return true;
} finally {
checking = false;
}
}; };
// Wake on an architect follow-up message (not just tasks): surface it and // Wake on an architect follow-up message (not just tasks): surface it and
// stop, so the implementer never sits dormant on a pending question. // stop, so the implementer never sits dormant on a pending question.
const trySurfaceMessages = async (): Promise<boolean> => { const trySurfaceMessages = async (): Promise<boolean> => {
ctx.serverUrl = serverUrl; if (checking) return false;
const n = await drainAgentMessages(ctx); checking = true;
if (n === 0) return false; try {
if (timer) clearTimeout(timer); ctx.serverUrl = serverUrl;
finish(); const n = await drainAgentMessages(ctx);
return true; if (n === 0) return false;
if (timer) clearTimeout(timer);
finish();
return true;
} finally {
checking = false;
}
}; };
// Polling fallback: SSE is instant when it works, but a dropped frame on an
// otherwise-open stream must never mean an infinite sleep — re-check tasks
// and messages every few seconds, so a lost event costs at most one
// poll interval (~4s by default).
poll = setInterval(() => {
if (settled) return;
void (async () => {
try {
if (await tryClaim()) return;
await trySurfaceMessages();
} catch {
/* best-effort: the SSE path and the next tick remain */
}
})();
}, ctx.pollIntervalMs ?? 4000);
poll.unref?.();
const waitLoop = async () => { const waitLoop = async () => {
while (!settled && remainingMs(deadline) > 0) { while (!settled && remainingMs(deadline) > 0) {
controller = new AbortController(); controller = new AbortController();
try { try {
ctx.serverUrl = serverUrl; ctx.serverUrl = serverUrl;
const res = await fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } }); const res = await fetch(`${serverUrl}/events`, {
signal: controller.signal,
headers: {
Accept: 'text/event-stream',
...(lastEventId !== undefined ? { 'Last-Event-ID': String(lastEventId) } : {}),
},
});
if (!res.body) throw new Error('SSE response has no body'); if (!res.body) throw new Error('SSE response has no body');
// Close the gap: a task or message may have appeared between the initial // Close the gap: a task or message may have appeared between the initial
@ -176,6 +235,9 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
const { events, remaining } = parseSSEBuffer(buffer); const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining; buffer = remaining;
for (const event of events) {
if (event.seq !== undefined) lastEventId = event.seq;
}
// Any task event may mean a task addressed to us just opened/reopened. // Any task event may mean a task addressed to us just opened/reopened.
if (events.some((e) => e.type === 'task')) { if (events.some((e) => e.type === 'task')) {
if (await tryClaim()) return; if (await tryClaim()) return;

View File

@ -2,12 +2,12 @@ import { Command } from 'commander';
import { init } from './commands/init.js'; import { init } from './commands/init.js';
import { status } from './commands/status.js'; import { status } from './commands/status.js';
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js'; import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js'; import { taskCreate, taskList, taskShow, taskClaim, taskDispatch, taskRecord, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js';
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js'; import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
import { decisionCreate, decisionList } from './commands/decision.js'; import { decisionCreate, decisionList } from './commands/decision.js';
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js'; import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
import { askCreate, askList, askAnswer, askEscalate, waitForAsk } from './commands/ask.js'; import { askCreate, askList, askAnswer, askEscalate, waitForAsk } from './commands/ask.js';
import { agentSetup, hookContext } from './commands/agentSetup.js'; import { agentSetup, codexStopHook, hookContext } from './commands/agentSetup.js';
import { syncOrgFromFile } from '../core/services/orgService.js'; import { syncOrgFromFile } from '../core/services/orgService.js';
import { delegate } from './commands/delegate.js'; import { delegate } from './commands/delegate.js';
import { serverStart } from './commands/server.js'; import { serverStart } from './commands/server.js';
@ -15,12 +15,16 @@ import { update } from './commands/update.js';
import { watchEvents } from './commands/watch.js'; import { watchEvents } from './commands/watch.js';
import { startAgent } from './commands/start.js'; import { startAgent } from './commands/start.js';
import { workAgent } from './commands/work.js'; import { workAgent } from './commands/work.js';
import { startAgentSession } from './commands/startAgent.js';
import { startMcpServer } from '../mcp/server.js'; import { startMcpServer } from '../mcp/server.js';
import { installMcp } from '../mcp/install.js'; import { installMcp } from '../mcp/install.js';
import { loadConfig, saveConfig } from '../core/config.js'; import { loadConfig, saveConfig } from '../core/config.js';
import { findProjectRoot } from '../core/paths.js'; import { findProjectRoot } from '../core/paths.js';
import { discoverServer } from '../discovery.js'; import { discoverServer, resolveReachableServerUrl } from '../discovery.js';
import { remoteClient, RemoteError } from './remoteClient.js'; import { remoteClient, RemoteError } from './remoteClient.js';
import { printHealth } from './commands/health.js';
import { VERSION } from '../version.js';
import { setPreferredAgent } from '../core/services/roleService.js';
interface ResolvedContext { interface ResolvedContext {
serverUrl?: string; serverUrl?: string;
@ -47,6 +51,36 @@ interface ResolvedContext {
// exactly one command per process. // exactly one command per process.
let activeProjectCwd: string | undefined; let activeProjectCwd: string | undefined;
/**
* Läuft der gefundene Hub auf DIESER Maschine, dann Loopback bevorzugen.
*
* Hintergrund (real erlebt, kostete Stunden): Die Selbstheilung unten sucht
* per mDNS, wenn die konfigurierte Adresse kurz nicht antwortet das passiert
* bei JEDEM Hub-Neustart. Sie fand die LAN-IP und schrieb sie in die Config,
* womit ein zuvor gesetztes 127.0.0.1 verlorenging. Die LAN-IP wiederum bricht
* beim nächsten Netzwechsel oder Neustart weg, also heilte sich das System in
* die instabilere Adresse hinein. Loopback ist für Agenten auf derselben
* Maschine strikt robuster es kann weder durch mDNS-Neuankündigung noch
* durch einen Netzwechsel ungültig werden.
*/
async function preferLoopback(url: string | undefined): Promise<string | undefined> {
if (!url) return url;
let port: string;
try {
port = new URL(url).port || '3377';
} catch {
return url;
}
const loopback = `http://127.0.0.1:${port}`;
if (url === loopback) return url;
try {
const res = await fetch(`${loopback}/status`, { signal: AbortSignal.timeout(1200) });
if (res.ok) return loopback;
} catch { /* nicht lokal erreichbar — dann bleibt die gefundene Adresse */ }
return url;
}
async function resolveContext(program: Command, cwd: string): Promise<ResolvedContext> { async function resolveContext(program: Command, cwd: string): Promise<ResolvedContext> {
activeProjectCwd = cwd; activeProjectCwd = cwd;
const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER; const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
@ -61,11 +95,29 @@ async function resolveContext(program: Command, cwd: string): Promise<ResolvedCo
} catch { } catch {
return { projectCwd: root }; return { projectCwd: root };
} }
if (config.serverUrl) return { serverUrl: config.serverUrl, projectCwd: root }; if (config.serverUrl) {
// Self-heal a stale saved URL (e.g. agenthub.local after a network
// change): probe it with a short timeout; if it's dead, re-discover the
// live server, persist it and use it for this call — no hard failure.
const reachable = await resolveReachableServerUrl(config.serverUrl);
if (reachable === config.serverUrl) return { serverUrl: config.serverUrl, projectCwd: root };
if (reachable) {
try {
saveConfig(root, { ...config, serverUrl: reachable });
console.error(`AgentHub: ${config.serverUrl} unreachable — switched to discovered server ${reachable} (config updated).`);
} catch {
console.error(`AgentHub: ${config.serverUrl} unreachable — using discovered server ${reachable} for this call.`);
}
return { serverUrl: reachable, projectCwd: root };
}
// Nothing discovered: keep the configured URL so runRemote reports its
// usual friendly "not reachable" error (and re-attempts discovery).
return { serverUrl: config.serverUrl, projectCwd: root };
}
// Initialized project but no server configured yet: auto-find one on the // Initialized project but no server configured yet: auto-find one on the
// LAN and remember it, so the agent connects with zero manual setup. // LAN and remember it, so the agent connects with zero manual setup.
const discovered = await discoverServer(2000); const discovered = await preferLoopback(await discoverServer(2000));
if (discovered) { if (discovered) {
try { try {
saveConfig(root, { ...config, serverUrl: discovered }); saveConfig(root, { ...config, serverUrl: discovered });
@ -120,7 +172,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
export function createProgram(cwd: string): Command { export function createProgram(cwd: string): Command {
const program = new Command('agenthub') const program = new Command('agenthub')
.description('Local coordination layer for AI coding agents') .description('Local coordination layer for AI coding agents')
.version('0.9.1') .version(VERSION)
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)'); .option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
program program
@ -149,6 +201,21 @@ export function createProgram(cwd: string): Command {
} }
}); });
program
.command('health')
.description('Hub health: status, uptime and per-agent traffic lights')
.action(async () => {
const { serverUrl } = await resolveContext(program, cwd);
if (!serverUrl) {
console.error('agenthub health needs a running server. Start one with: agenthub server start --host 0.0.0.0');
process.exit(1);
return;
}
await runRemote(serverUrl, async () => {
printHealth(await remoteClient.getHealth(serverUrl));
});
});
const memoryCmd = new Command('memory').description('Manage memory entries'); const memoryCmd = new Command('memory').description('Manage memory entries');
memoryCmd memoryCmd
.command('add') .command('add')
@ -301,6 +368,37 @@ export function createProgram(cwd: string): Command {
taskClaim(projectCwd, id, options.agent); taskClaim(projectCwd, id, options.agent);
} }
}); });
taskCmd
.command('dispatch <id>')
.description('Architect: explicitly start a session-less agent for an open task')
.requiredOption('--agent <agent>', 'Architect-dispatched agent')
.action(async (id, options) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
await remoteClient.dispatchTask(serverUrl, id, options.agent);
console.log(`AgentHub: Architect started ${options.agent} for ${id}`);
});
} else taskDispatch(projectCwd, id, options.agent);
});
taskCmd
.command('record')
.description('Record work already completed outside AgentHub')
.requiredOption('--title <title>', 'Completed work')
.requiredOption('--by <agent>', 'Who completed it')
.option('--description <description>', 'Details')
.option('--role <role>', 'Role')
.option('--priority <priority>', 'Priority')
.action(async (options) => {
const payload = { title: options.title, doneBy: options.by, description: options.description, role: options.role, priority: options.priority };
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const task = await remoteClient.recordExternalTask(serverUrl, payload);
console.log(`AgentHub: External work recorded ${task.id} by ${task.doneBy}`);
});
} else taskRecord(projectCwd, payload);
});
taskCmd taskCmd
.command('done <id>') .command('done <id>')
.description('Mark a task as done') .description('Mark a task as done')
@ -699,7 +797,7 @@ export function createProgram(cwd: string): Command {
const agentCmd = new Command('agent').description('Per-agent machine setup'); const agentCmd = new Command('agent').description('Per-agent machine setup');
agentCmd agentCmd
.command('setup') .command('setup')
.description('Auto-start: write a SessionStart hook (Claude Code / Codex / Kimi) so sessions enter the work loop') .description('Auto-start: install lifecycle hooks; Codex gets a native Stop-hook work-loop wake adapter')
.requiredOption('--agent <name>', 'This machine\'s agent name') .requiredOption('--agent <name>', 'This machine\'s agent name')
.requiredOption('--role <role>', 'This agent\'s role (implementer / architect / tester)') .requiredOption('--role <role>', 'This agent\'s role (implementer / architect / tester)')
.option('--cli <cli>', 'CLI to target: claude | codex | kimi (default: inferred from the agent name)') .option('--cli <cli>', 'CLI to target: claude | codex | kimi (default: inferred from the agent name)')
@ -709,6 +807,44 @@ export function createProgram(cwd: string): Command {
}); });
program.addCommand(agentCmd); program.addCommand(agentCmd);
const roleCmd = new Command('role').description('Inspect or change the agent assigned to a team role');
roleCmd
.command('show [role]')
.description('Show preferred agents for one role or all roles')
.action(async (role?: string) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
const health = await remoteClient.getHealth(serverUrl);
const roles = await remoteClient.listRoles(serverUrl);
const entries = Object.entries(roles).filter(([name]) => !role || name === role);
if (!entries.length) throw new Error(`Unknown role: ${role}`);
for (const [name, spec] of entries) {
const live = health.agents.find((a) => a.name === spec.preferredAgent);
console.log(`${name}: ${spec.preferredAgent}${live ? ` (${live.state})` : ''}`);
}
return;
}
const config = loadConfig(projectCwd);
const entries = Object.entries(config.roles).filter(([name]) => !role || name === role);
if (!entries.length) throw new Error(`Unknown role: ${role}`);
for (const [name, spec] of entries) console.log(`${name}: ${spec.preferredAgent}`);
});
roleCmd
.command('set <role>')
.description('Atomically switch a role to another configured agent')
.requiredOption('--agent <name>', 'Agent that should hold the role')
.action(async (role: string, options: { agent: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
const result = serverUrl
? await remoteClient.setPreferredAgent(serverUrl, role, options.agent)
: setPreferredAgent(projectCwd, role, options.agent);
console.log(`AgentHub: ${result.role} switched ${result.previousAgent}${result.agent}`);
if (result.role === 'architect') {
console.log(`Start the new architect with: agenthub start ${result.agent}`);
}
});
program.addCommand(roleCmd);
// ─── org sync (CLAUDE.md → team structure) ─────────────────────────────── // ─── org sync (CLAUDE.md → team structure) ───────────────────────────────
const orgCmd = new Command('org').description('Team org-chart structure'); const orgCmd = new Command('org').description('Team org-chart structure');
orgCmd orgCmd
@ -731,9 +867,47 @@ export function createProgram(cwd: string): Command {
.command('hook-context') .command('hook-context')
.description('Print the auto-start work-loop instruction (used by the SessionStart hook)') .description('Print the auto-start work-loop instruction (used by the SessionStart hook)')
.requiredOption('--agent <name>', 'Agent name') .requiredOption('--agent <name>', 'Agent name')
.requiredOption('--role <role>', 'Agent role') .option('--role <role>', 'Agent role override (normally resolved dynamically)')
.action((options: { agent: string; role: string }) => { .action(async (options: { agent: string; role?: string }) => {
hookContext({ agent: options.agent, role: options.role }); const { serverUrl, projectCwd } = await resolveContext(program, cwd);
let role = options.role;
if (!role && serverUrl) {
const health = await remoteClient.getHealth(serverUrl);
role = health.agents.find((a) => a.name.toLowerCase() === options.agent.toLowerCase())?.role;
}
if (!role) {
const config = loadConfig(projectCwd);
role = config.roles.architect?.preferredAgent === options.agent
? 'architect'
: config.agents?.[options.agent]?.role ?? 'implementer';
}
hookContext({ agent: options.agent, role });
});
program
.command('hook-stop')
.description('Wait for AgentHub work and resume the current Codex thread (used by the Stop hook)')
.requiredOption('--agent <name>', 'Agent name')
.option('--role <role>', 'Agent role override (normally resolved dynamically)')
.action(async (options: { agent: string; role?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
let role = options.role;
if (!role && serverUrl) {
const health = await remoteClient.getHealth(serverUrl);
role = health.agents.find((a) => a.name.toLowerCase() === options.agent.toLowerCase())?.role;
}
if (!role) {
const config = loadConfig(projectCwd);
role = config.roles.architect?.preferredAgent === options.agent
? 'architect'
: config.agents?.[options.agent]?.role ?? 'implementer';
}
await codexStopHook({
serverUrl,
projectCwd,
agent: options.agent,
role,
});
}); });
program program
@ -771,12 +945,13 @@ export function createProgram(cwd: string): Command {
.command('mcp [action]') .command('mcp [action]')
.description('Start the MCP server (stdio). `agenthub mcp install` writes .mcp.json so MCP-aware agents auto-register.') .description('Start the MCP server (stdio). `agenthub mcp install` writes .mcp.json so MCP-aware agents auto-register.')
.option('--print', 'Dry run: print the config instead of writing .mcp.json') .option('--print', 'Dry run: print the config instead of writing .mcp.json')
.action(async (action: string | undefined, options: { print?: boolean }) => { .option('--agent <name>', 'Bind the MCP push channel to this agent at startup (recommended: the bridge then pushes from the first second, without waiting for a tool call)')
.action(async (action: string | undefined, options: { print?: boolean; agent?: string }) => {
if (action === 'install') { if (action === 'install') {
installMcp(cwd, { print: options.print }); installMcp(cwd, { print: options.print, agent: options.agent });
return; return;
} }
await startMcpServer(cwd); await startMcpServer(cwd, { agent: options.agent });
}); });
// ─── hello (presence) ────────────────────────────────────────────────────── // ─── hello (presence) ──────────────────────────────────────────────────────
@ -800,17 +975,35 @@ export function createProgram(cwd: string): Command {
}); });
// ─── start (onboarding) ──────────────────────────────────────────────────── // ─── start (onboarding) ────────────────────────────────────────────────────
// Session-Einstieg für einen Agenten. `agenthub start codex` (positional) ist
// die Form, die ein Mensch in eine frische Session tippt — sie druckt zuerst
// das verbindliche Briefing (Loop, Check-in-Kanal, DEC-0035) und macht dann
// das Onboarding (anmelden, adressierten Task claimen, Handoff zeigen).
// `--agent` bleibt für Skripte/Hooks erhalten.
program program
.command('start') .command('start [agent]')
.description('Onboard: announce, claim the task addressed to you, print it + its handoff') .description('Start an agent session: briefing + announce + claim the task addressed to you')
.requiredOption('--agent <name>', 'Agent name') .option('--agent <name>', 'Agent name (alternative to the positional argument)')
.option('--role <role>', 'Role (default: implementer)', 'implementer') .option('--role <role>', 'Role (default: from the roster, else implementer)')
.action(async (options) => { .option('--no-briefing', 'Skip the standing briefing (onboarding output only)')
.action(async (positional: string | undefined, options: { agent?: string; role?: string; briefing?: boolean }) => {
const agent = positional ?? options.agent;
if (!agent) {
console.error('Which agent? Usage: agenthub start <agent> (e.g. agenthub start codex)');
process.exit(1);
}
const { serverUrl, projectCwd } = await resolveContext(program, cwd); const { serverUrl, projectCwd } = await resolveContext(program, cwd);
let role = options.role ?? 'implementer';
if (options.briefing !== false) {
role = await startAgentSession({ serverUrl, projectCwd, agent, role: options.role });
console.log('');
}
// Der Architekt claimt nichts — sein Start ist der Lagebericht.
if (role === 'architect') return;
if (serverUrl) { if (serverUrl) {
await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent: options.agent, role: options.role })); await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent, role }));
} else { } else {
await startAgent({ projectCwd, agent: options.agent, role: options.role }); await startAgent({ projectCwd, agent, role });
} }
}); });
@ -842,6 +1035,7 @@ export function createProgram(cwd: string): Command {
.option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier') .option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier')
.option('--await-message <agent>', 'Exit when an unread message arrives for agent/role; architect message notifier') .option('--await-message <agent>', 'Exit when an unread message arrives for agent/role; architect message notifier')
.option('--new-only', 'With --await-review/--await-message: ignore existing backlog on connect (re-armable without spinning)') .option('--new-only', 'With --await-review/--await-message: ignore existing backlog on connect (re-armable without spinning)')
.option('--ignore-from <agents>', 'With --await-message: comma-separated senders to ignore (e.g. "agenthub" to mute watchdog reminders)')
.action(async (options) => { .action(async (options) => {
const { serverUrl } = await resolveContext(program, cwd); const { serverUrl } = await resolveContext(program, cwd);
if (!serverUrl) { if (!serverUrl) {
@ -854,6 +1048,7 @@ export function createProgram(cwd: string): Command {
role: options.role as string | undefined, role: options.role as string | undefined,
awaitReview: options.awaitReview as boolean | undefined, awaitReview: options.awaitReview as boolean | undefined,
awaitMessage: options.awaitMessage as string | undefined, awaitMessage: options.awaitMessage as string | undefined,
ignoreFrom: typeof options.ignoreFrom === 'string' ? options.ignoreFrom.split(',') : undefined,
newOnly: options.newOnly as boolean | undefined, newOnly: options.newOnly as boolean | undefined,
}); });
}); });

View File

@ -2,6 +2,7 @@ import type { Task, Handoff, Decision, Memory, Message, Ask, ActivityItem } from
import type { IndexEntry } from '../core/index.js'; import type { IndexEntry } from '../core/index.js';
import type { InboxMessage } from '../core/services/messageService.js'; import type { InboxMessage } from '../core/services/messageService.js';
import type { TaskLogEntry } from '../core/services/taskLogService.js'; import type { TaskLogEntry } from '../core/services/taskLogService.js';
import type { HealthReport } from '../core/services/presenceService.js';
export class RemoteError extends Error { export class RemoteError extends Error {
constructor(public status: number, message: string) { constructor(public status: number, message: string) {
@ -38,10 +39,61 @@ export const remoteClient = {
return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body); return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
}, },
async getHealth(baseUrl: string): Promise<HealthReport> {
return request<HealthReport>(baseUrl, 'GET', '/health');
},
async setPreferredAgent(
baseUrl: string,
role: string,
agent: string,
): Promise<{ role: string; agent: string; previousAgent: string }> {
return request(baseUrl, 'PATCH', `/roles/${encodeURIComponent(role)}`, { agent });
},
async listRoles(baseUrl: string): Promise<Record<string, { preferredAgent: string; description?: string }>> {
return request(baseUrl, 'GET', '/roles');
},
async getArchitectPulse(
baseUrl: string,
sinceSeq?: number,
): Promise<{
nextSeq: number;
events: Array<{
seq: number;
type: string;
action: string;
id: string;
status?: string;
assignedTo?: string;
}>;
}> {
const query = sinceSeq === undefined ? '' : `?sinceSeq=${sinceSeq}`;
return request(baseUrl, 'GET', `/architect/pulse${query}`);
},
async getAgentIdentity(baseUrl: string, agent: string): Promise<{ canonical: string; names: string[]; workTimeoutSec?: number }> {
return request<{ canonical: string; names: string[]; workTimeoutSec?: number }>(
baseUrl,
'GET',
`/agents/${encodeURIComponent(agent)}/identity`,
);
},
async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> { async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> {
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action }); await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
}, },
async setLoop(baseUrl: string, agent: string, active: boolean, reason?: string): Promise<void> {
await request<{ ok: boolean }>(
baseUrl,
'POST',
`/agents/${encodeURIComponent(agent)}/loop`,
{ active, reason },
);
},
async updateStatus(baseUrl: string): Promise<string> { async updateStatus(baseUrl: string): Promise<string> {
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body); return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
}, },
@ -49,6 +101,9 @@ export const remoteClient = {
async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> { async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> {
return request<Task>(baseUrl, 'POST', '/tasks', options); return request<Task>(baseUrl, 'POST', '/tasks', options);
}, },
async recordExternalTask(baseUrl: string, options: Partial<Task> & { title: string; doneBy: string }): Promise<Task> {
return request<Task>(baseUrl, 'POST', '/tasks/record', options);
},
async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> { async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> {
const params = new URLSearchParams((filters ?? {}) as Record<string, string>); const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
@ -63,6 +118,9 @@ export const remoteClient = {
async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> { async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName }); return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
}, },
async dispatchTask(baseUrl: string, id: string, agent: string): Promise<Task> {
return request<Task>(baseUrl, 'POST', `/tasks/${id}/dispatch`, { agent });
},
async doneTask( async doneTask(
baseUrl: string, baseUrl: string,
@ -101,6 +159,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);
}, },

View File

@ -1,7 +1,7 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { dirname } from 'path'; import { dirname } from 'path';
import { getConfigPath } from './paths.js'; import { getConfigPath } from './paths.js';
import { ConfigSchema, type Config } from './schema.js'; import { ConfigSchema, WatchdogConfigSchema, type Config } from './schema.js';
export function defaultConfig(projectName: string): Config { export function defaultConfig(projectName: string): Config {
return { return {
@ -15,6 +15,7 @@ export function defaultConfig(projectName: string): Config {
tester: { preferredAgent: 'codex' }, tester: { preferredAgent: 'codex' },
}, },
serverUrl: undefined, serverUrl: undefined,
watchdog: WatchdogConfigSchema.parse({}),
}; };
} }

View File

@ -6,6 +6,7 @@ export const MemoryCategory = z.enum(['architecture', 'product', 'technical', 'i
export const Priority = z.enum(['low', 'medium', 'high', 'critical']); export const Priority = z.enum(['low', 'medium', 'high', 'critical']);
export const Role = z.enum(['architect', 'implementer', 'reviewer', 'tester']); export const Role = z.enum(['architect', 'implementer', 'reviewer', 'tester']);
export const DelegationMode = z.enum(['manual', 'suggest', 'auto']); export const DelegationMode = z.enum(['manual', 'suggest', 'auto']);
export const AgentDispatch = z.enum(['loop', 'architect']);
export const TaskSchema = z.object({ export const TaskSchema = z.object({
id: z.string().regex(/^TSK-\d{4}$/), id: z.string().regex(/^TSK-\d{4}$/),
@ -27,6 +28,9 @@ export const TaskSchema = z.object({
doneBy: z.string().optional(), doneBy: z.string().optional(),
doneTokens: z.number().int().nonnegative().optional(), doneTokens: z.number().int().nonnegative().optional(),
doneDuration: z.number().int().nonnegative().optional(), doneDuration: z.number().int().nonnegative().optional(),
/** Honest provenance for work recorded after it happened outside AgentHub. */
origin: z.enum(['agenthub', 'external']).default('agenthub'),
recordedAt: z.string().datetime().optional(),
}); });
export const HandoffSchema = z.object({ export const HandoffSchema = z.object({
@ -154,6 +158,21 @@ export const RoleConfigSchema = z.object({
*/ */
export const AgentConfigSchema = z.object({ export const AgentConfigSchema = z.object({
role: Role, role: Role,
/** loop = self-claiming session; architect = spawned/claimed explicitly by the architect. */
dispatch: AgentDispatch.default('loop'),
/** Previous/alternate names that resolve to this canonical roster key. */
aliases: z.array(z.string().min(1)).default([]),
/**
* Wie lange `agenthub_work` fuer DIESEN Agenten blockieren darf.
*
* Der Wert haengt am MCP-CLIENT, nicht am Hub: Kimi-Code bricht Requests nach
* gut einer Minute ab (-32001), Codex vertraegt mehrere Minuten. Ein globaler
* Default zwingt beide auf das Minimum des schwaechsten Clients der
* robustere Agent kehrt dann unnoetig oft leer zurueck, und JEDE Rueckkehr
* ist eine Gelegenheit, aus dem Loop zu fallen. Genau daran unterschied sich
* codex' Zuverlaessigkeit von kimis.
*/
workTimeoutSec: z.number().int().positive().max(600).optional(),
/** Display model, e.g. "Opus 4.8", "Sonnet 4.6", "Kimi K2", "GPT-5 Codex". */ /** Display model, e.g. "Opus 4.8", "Sonnet 4.6", "Kimi K2", "GPT-5 Codex". */
model: z.string().optional(), model: z.string().optional(),
/** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */ /** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */
@ -184,9 +203,30 @@ export const OrgNodeSchema = z.object({
parentId: z.string().optional(), parentId: z.string().optional(),
/** Provider override for the icon when there is no linked agent. */ /** Provider override for the icon when there is no linked agent. */
kind: z.string().optional(), kind: z.string().optional(),
/** When linked to an agent, org sync copies this into the roster. */
dispatch: AgentDispatch.optional(),
}); });
export type OrgNode = z.infer<typeof OrgNodeSchema>; export type OrgNode = z.infer<typeof OrgNodeSchema>;
/**
* Server-side watchdog tuning (TSK-0226). All values optional the defaults
* below apply when the section (or a single key) is absent from the config.
*/
export const WatchdogConfigSchema = z.object({
/** Master switch; false disables the watchdog loop entirely. */
enabled: z.boolean().default(true),
/** How often the watchdog scans tasks. */
intervalMs: z.number().int().positive().default(60_000),
/** OPEN + assigned but unclaimed for this long (high/critical) → re-notify. */
unclaimedHighMs: z.number().int().positive().default(3 * 60_000),
/** OPEN + assigned but unclaimed for this long (medium/low) → re-notify. */
unclaimedDefaultMs: z.number().int().positive().default(10 * 60_000),
/** IN_PROGRESS with no task-log line for this long → alert the architect. */
staleInProgressMs: z.number().int().positive().default(15 * 60_000),
/** REVIEW untouched for this long → remind the architect. */
staleReviewMs: z.number().int().positive().default(5 * 60_000),
}).default({});
export const ConfigSchema = z.object({ export const ConfigSchema = z.object({
version: z.literal('1'), version: z.literal('1'),
projectName: z.string().min(1), projectName: z.string().min(1),
@ -197,6 +237,8 @@ export const ConfigSchema = z.object({
/** Team org chart (arbitrary depth). When present, /team renders this tree. */ /** Team org chart (arbitrary depth). When present, /team renders this tree. */
org: z.array(OrgNodeSchema).optional(), org: z.array(OrgNodeSchema).optional(),
serverUrl: z.string().url().optional(), serverUrl: z.string().url().optional(),
/** Watchdog thresholds; absent config ⇒ sane defaults. */
watchdog: WatchdogConfigSchema,
}); });
export type Task = z.infer<typeof TaskSchema>; export type Task = z.infer<typeof TaskSchema>;
@ -207,3 +249,4 @@ export type Message = z.infer<typeof MessageSchema>;
export type Ask = z.infer<typeof AskSchema>; export type Ask = z.infer<typeof AskSchema>;
export type Status = z.infer<typeof StatusSchema>; export type Status = z.infer<typeof StatusSchema>;
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;
export type WatchdogConfig = z.infer<typeof WatchdogConfigSchema>;

View File

@ -1,4 +1,7 @@
import { getTask, listTasks } from './taskService.js'; import { getTask, listTasks } from './taskService.js';
import { listHandoffs } from './handoffService.js';
import { listMessages } from './messageService.js';
import { loadConfig } from '../config.js';
import { getRoster, inferKind, type RosterEntry } from './rosterService.js'; import { getRoster, inferKind, type RosterEntry } from './rosterService.js';
/** /**
@ -39,6 +42,14 @@ export const MAX_ACTIVE_MIN_PER_TASK = 45;
*/ */
export const MAX_LIVE_ESTIMATE_MIN_PER_TASK = 12; export const MAX_LIVE_ESTIMATE_MIN_PER_TASK = 12;
/**
* Estimated tokens per architect/coordination action (review, handoff,
* message, done/reopen decision). The architect rarely OWNS tasks, so without
* this their work was invisible in the report (TSK-0226). 30s of active
* work per action deliberately rough and flagged estimated like the rest.
*/
export const TOKENS_PER_COORDINATION_ACTION = 1500;
/** USD → EUR display rate (rough, labelled approximate in the UI). */ /** USD → EUR display rate (rough, labelled approximate in the UI). */
const USD_TO_EUR = 0.92; const USD_TO_EUR = 0.92;
@ -121,6 +132,9 @@ export interface AgentBudget {
/** costEur / budgetEur, clamped 0..1 (only when a budget is set). */ /** costEur / budgetEur, clamped 0..1 (only when a budget is set). */
budgetUsed?: number; budgetUsed?: number;
taskCount: number; taskCount: number;
/** Coordination actions attributed to this agent (reviews, handoffs,
* messages, approvals) the counter behind the activity estimate. */
actions: number;
} }
export interface BudgetReport { export interface BudgetReport {
@ -153,6 +167,7 @@ export function computeBudget(cwd: string): BudgetReport {
costEur: 0, costEur: 0,
budgetEur: r?.budgetEur, budgetEur: r?.budgetEur,
taskCount: 0, taskCount: 0,
actions: 0,
}; };
acc.set(name, a); acc.set(name, a);
} }
@ -161,7 +176,8 @@ export function computeBudget(cwd: string): BudgetReport {
// Seed rostered agents so they show even at zero. // Seed rostered agents so they show even at zero.
for (const r of roster) ensure(r.name); for (const r of roster) ensure(r.name);
for (const t of listTasks(cwd)) { const tasks = listTasks(cwd);
for (const t of tasks) {
const owner = t.assignedTo; const owner = t.assignedTo;
if (!owner) continue; if (!owner) continue;
const { tokens, estimated } = tokensForTask( const { tokens, estimated } = tokensForTask(
@ -183,6 +199,50 @@ export function computeBudget(cwd: string): BudgetReport {
} }
} }
// ── Architect / coordination activity (TSK-0226) ─────────────────────────
// Reviews, handoffs, messages and done/reopen decisions rarely leave the
// architect as a task OWNER — count them as actions and estimate activity
// tokens so the architect's work shows up like implementer work.
const actionCount = new Map<string, number>();
const bump = (name: string | undefined, n = 1) => {
if (!name) return;
actionCount.set(name, (actionCount.get(name) ?? 0) + n);
};
const preferredByRole = new Map<string, string>();
try {
for (const [role, cfg] of Object.entries(loadConfig(cwd).roles)) preferredByRole.set(role, cfg.preferredAgent);
} catch {
/* no config → role fallback stays empty */
}
for (const t of tasks) {
// A pending review is architect work in flight (reviewer ≠ assignee by construction).
if (t.reviewer) bump(t.reviewer);
// Approval: done by someone other than the task owner (e.g. architect approves).
// doneBy isn't indexed — read the entity file for done tasks only.
if (t.status === 'done') {
try {
const { task } = getTask(cwd, t.id);
if (task.doneBy && task.doneBy !== t.assignedTo) bump(task.doneBy);
} catch {
/* unreadable entity → skip */
}
}
}
for (const h of listHandoffs(cwd)) {
bump(h.fromAgent ?? (h.fromRole ? preferredByRole.get(h.fromRole) : undefined));
}
for (const m of listMessages(cwd)) {
bump(m.from);
}
for (const [name, count] of actionCount) {
const a = ensure(name);
const tokens = count * TOKENS_PER_COORDINATION_ACTION;
a.actions += count;
a.tokens += tokens;
a.estimatedTokens += tokens;
a.estimated = true;
}
const agents = Array.from(acc.values()); const agents = Array.from(acc.values());
for (const a of agents) { for (const a of agents) {
a.costEur = costEur(a.tokens, a.model); a.costEur = costEur(a.tokens, a.model);
@ -201,7 +261,7 @@ export function computeBudget(cwd: string): BudgetReport {
totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated }, totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated },
assumptions: { assumptions: {
tokensPerActiveMin: TOKENS_PER_ACTIVE_MIN, tokensPerActiveMin: TOKENS_PER_ACTIVE_MIN,
note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (live states capped at ${MAX_LIVE_ESTIMATE_MIN_PER_TASK} min/task, done fallbacks at ${MAX_ACTIVE_MIN_PER_TASK} min/task). Cost is a directional estimate at blended model rates, not billing.`, note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (live states capped at ${MAX_LIVE_ESTIMATE_MIN_PER_TASK} min/task, done fallbacks at ${MAX_ACTIVE_MIN_PER_TASK} min/task) plus coordination actions (reviews/handoffs/messages/approvals ≈ ${TOKENS_PER_COORDINATION_ACTION} tok each, see 'actions'). Cost is a directional estimate at blended model rates, not billing.`,
}, },
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
}; };

View File

@ -0,0 +1,216 @@
import { listTasks } from './taskService.js';
import { listMessages } from './messageService.js';
import { listAsks } from './askService.js';
import { listHandoffs } from './handoffService.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[];
/**
* Die Task, die der Agent GERADE HAELT (in_progress, von ihm geclaimt).
*
* Klingt trivial, war aber eine echte Sackgasse: der Work-Loop sucht nur
* Tasks mit Status `open`. Haelt ein Agent bereits eine in_progress-Task
* etwa nach einem Session-Neustart , greift der Busy-Guard und der Loop
* liefert NICHTS zurueck. Der Agent wartet dann auf neue Arbeit, die nie
* kommt, waehrend seine eigene Task unbearbeitet liegt. Deshalb muss der
* gehaltene Task ueberall auftauchen, wo ein Agent nach "was ist zu tun"
* fragt.
*/
heldTask?: string;
/** Vom Agenten eingereichte Tasks, die auf das Architekten-Review warten. */
awaitingReview: string[];
/**
* Vom Agenten gestellte Fragen, die inzwischen beantwortet sind.
*
* Warum das hierher gehört: `agenthub_ask(wait:true)` wartet nur 300 s. Der
* Architekt braucht für eine Architekturentscheidung regelmäßig länger (real:
* 18 min bei ASK-0004). Danach ist der Agent dormant und die Antwort erreicht
* ihn nie ein Mensch muss ihn wecken. Über den Check-in kommt sie beim
* nächsten Hub-Kontakt an.
*/
answeredAsks: Array<{ id: string; answer: 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: [],
answeredAsks: [],
unreadCount: 0,
messages: [],
};
for (const ask of listAsks(cwd, { status: 'answered' })) {
if (!isMine(ask.from)) continue;
pending.answeredAsks.push({ id: ask.id, answer: previewOf(ask.answer) });
}
// Adressierung EXAKT wie in findAddressedOpenTask (start.ts): Titel-Präfix
// `<name>:`, Handoff-Empfänger ODER assignedTo. Prüfte der Check-in nur
// assignedTo, meldete der Session-Start "nichts offen" — und der Work-Loop
// claimte unmittelbar danach eine Task, die er über das Titel-Präfix findet.
// Zwei Wahrheiten über dieselbe Frage sind genau das, was hier nicht passieren darf.
let addressedByHandoff = new Set<string>();
try {
addressedByHandoff = new Set(
listHandoffs(cwd)
.filter((h) => h.toAgent && isMine(h.toAgent) && h.taskId)
.map((h) => String(h.taskId)),
);
} catch {
// Handoffs nicht lesbar — Präfix/assignedTo tragen weiterhin.
}
const addressedToMe = (task: { id: string; title?: string; assignedTo?: string }) =>
[...names].some((n) => (task.title ?? '').toLowerCase().startsWith(`${n}:`)) ||
addressedByHandoff.has(task.id) ||
isMine(task.assignedTo);
for (const task of tasks) {
// Nur melden, wenn der Agent die Task nicht ohnehin im Blick hat: loggt er
// gerade AUF ihr, wäre der Hinweis Rauschen in jeder einzelnen Antwort.
if (
task.status === 'in_progress'
&& isMine(task.claimedBy ?? task.assignedTo)
&& task.id !== currentTaskId
) pending.heldTask = task.id;
else if (task.status === 'open' && addressedToMe(task)) 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.',
};
}
}
// `unread` UND `delivered`: `delivered` heißt nur "einmal ausgeliefert", nicht
// "gelesen" — erst read/acked schließt eine Nachricht ab. Filterte man hier
// auf `unread`, verschwände jede Nachricht aus dem Rückkanal, sobald sie
// einmal irgendwo aufgetaucht ist (z. B. beim Session-Start), ohne dass der
// Agent sie je beantwortet hat. Real passiert bei einer Architekten-Korrektur
// an kimi: Status `delivered`, im Check-in unsichtbar.
const unread = listMessages(cwd).filter(
(m) => (m.status === 'unread' || m.status === 'delivered') && 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.heldTask && !pending.interrupted) {
parts.push(`Du haeltst bereits ${pending.heldTask} — arbeite dort weiter und reiche sie ein, bevor du Neues anfaengst.`);
}
if (pending.unreadCount > 0) parts.push(`${pending.unreadCount} unbeantwortete Nachricht(en) — agenthub_inbox lesen, beantworten und mit agenthub_message quittieren.`);
if (pending.answeredAsks.length > 0) parts.push(`Deine Frage(n) ${pending.answeredAsks.map((a) => a.id).join(', ')} sind BEANTWORTET — lies die Antwort, bevor du weiterbaust.`);
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.heldTask !== undefined ||
pending.unreadCount > 0 ||
pending.answeredAsks.length > 0 ||
pending.waitingTasks.length > 0
);
}

View File

@ -4,15 +4,17 @@ import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js'; import { readEntity, writeEntity } from '../files.js';
import { HandoffSchema, type Handoff } from '../schema.js'; import { HandoffSchema, type Handoff } from '../schema.js';
import { Index } from '../index.js'; import { Index } from '../index.js';
import { resolveAgentName } from './identityService.js';
export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Handoff { export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Handoff {
const now = new Date().toISOString(); const now = new Date().toISOString();
const toAgent = options.toAgent ? resolveAgentName(cwd, options.toAgent) : undefined;
const handoff: Handoff = HandoffSchema.parse({ const handoff: Handoff = HandoffSchema.parse({
id: getNextId(cwd, 'handoff'), id: getNextId(cwd, 'handoff'),
fromRole: options.fromRole ?? 'user', fromRole: options.fromRole ?? 'user',
toRole: options.toRole ?? 'user', toRole: options.toRole ?? 'user',
fromAgent: options.fromAgent, fromAgent: options.fromAgent,
toAgent: options.toAgent, toAgent,
taskId: options.taskId, taskId: options.taskId,
summary: options.summary ?? 'Handoff', summary: options.summary ?? 'Handoff',
context: options.context ?? '', context: options.context ?? '',

View File

@ -0,0 +1,49 @@
import { loadConfig } from '../config.js';
export interface AgentIdentity {
canonical: string;
names: Set<string>;
}
/**
* Resolve an agent, configured alias, or role name to the canonical roster
* agent. Projects without an explicit roster keep accepting free-form agent
* names for backward compatibility.
*/
export function resolveAgentName(cwd: string, recipient: string): string {
const raw = recipient?.trim();
if (!raw) throw new Error('Agent recipient is required');
const key = raw.toLowerCase();
let config;
try {
config = loadConfig(cwd);
} catch {
return raw;
}
const role = Object.entries(config.roles).find(([name]) => name.toLowerCase() === key);
if (role) return role[1].preferredAgent;
const agents = config.agents;
if (!agents || Object.keys(agents).length === 0) return raw;
for (const [canonical, entry] of Object.entries(agents)) {
if (canonical.toLowerCase() === key || entry.aliases.some((alias) => alias.toLowerCase() === key)) {
return canonical;
}
}
throw new Error(`Unknown agent, alias, or role: "${raw}"`);
}
/** All names that should compare equal for task/handoff/inbox matching. */
export function agentIdentity(cwd: string, recipient: string): AgentIdentity {
const canonical = resolveAgentName(cwd, recipient);
const config = loadConfig(cwd);
const names = new Set<string>([canonical.toLowerCase()]);
const entry = config.agents?.[canonical];
for (const alias of entry?.aliases ?? []) names.add(alias.toLowerCase());
for (const [role, roleConfig] of Object.entries(config.roles)) {
if (roleConfig.preferredAgent.toLowerCase() === canonical.toLowerCase()) names.add(role.toLowerCase());
}
return { canonical, names };
}

View File

@ -4,6 +4,7 @@ import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js'; import { readEntity, writeEntity } from '../files.js';
import { MessageSchema, type Message } from '../schema.js'; import { MessageSchema, type Message } from '../schema.js';
import { Index } from '../index.js'; import { Index } from '../index.js';
import { agentIdentity, resolveAgentName } from './identityService.js';
function indexEntryFor(record: Message, filePath: string) { function indexEntryFor(record: Message, filePath: string) {
return { return {
@ -28,10 +29,11 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
if (!options.text) throw new Error('Message requires text'); if (!options.text) throw new Error('Message requires text');
const now = new Date().toISOString(); const now = new Date().toISOString();
const to = resolveAgentName(cwd, options.to);
const record: Message = MessageSchema.parse({ const record: Message = MessageSchema.parse({
id: getNextId(cwd, 'message'), id: getNextId(cwd, 'message'),
from: options.from, from: options.from,
to: options.to, to,
text: options.text, text: options.text,
taskId: options.taskId, taskId: options.taskId,
replyTo: options.replyTo, replyTo: options.replyTo,
@ -51,14 +53,17 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
} }
/** Agent aliases that should see the same inbox. Keep deliberately small. */ /** Agent aliases that should see the same inbox. Keep deliberately small. */
export function messageRecipientAliases(agent: string): Set<string> { export function messageRecipientAliases(cwdOrAgent: string, agent?: string): Set<string> {
const key = agent.toLowerCase(); if (agent) return agentIdentity(cwdOrAgent, agent).names;
const aliases = new Set([agent, key]); // Remote event filters do not have project config; new writes are already
// canonicalized server-side. Retain the legacy architect pair for old data.
const key = cwdOrAgent.toLowerCase();
const names = new Set([key]);
if (key === 'architect' || key === 'claude') { if (key === 'architect' || key === 'claude') {
aliases.add('architect'); names.add('architect');
aliases.add('claude'); names.add('claude');
} }
return aliases; return names;
} }
export interface InboxMessage { export interface InboxMessage {
@ -78,15 +83,18 @@ export interface InboxMessage {
* transitioned to `delivered` (a message the recipient has now been shown) AFTER * transitioned to `delivered` (a message the recipient has now been shown) AFTER
* filtering and BEFORE returning, so the returned rows reflect the new status. * filtering and BEFORE returning, so the returned rows reflect the new status.
* This is agent-scoped only the architect-wide `listMessages` never mutates. * This is agent-scoped only the architect-wide `listMessages` never mutates.
* (The `agenthub work` drainInbox path filters `unreadOnly` BEFORE this mutation *
* and then marks read, so the delivered intermediate is invisible there no * Nothing auto-marks a message `read`: a delivered-but-unacked message stays
* regress and no spin.) * visible in the default inbox view until the recipient explicitly acks/reads
* it (`message ack` / `message read`), so a message surfaced by a background
* work loop is never silently lost. Callers that wake on new mail filter
* `unreadOnly`, so a delivered message never re-wakes them (no spin).
*/ */
export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] { export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] {
const index = new Index(cwd); const index = new Index(cwd);
const all = index.list('message'); const all = index.list('message');
index.close(); index.close();
const recipients = messageRecipientAliases(agent); const recipients = messageRecipientAliases(cwd, agent);
const filtered = all const filtered = all
.filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase())) .filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase()))
.filter((m) => !opts.unreadOnly || m.status === 'unread'); .filter((m) => !opts.unreadOnly || m.status === 'unread');

View File

@ -53,6 +53,10 @@ export function syncOrgFromFile(cwd: string, from?: string): { count: number; fi
const org = parseOrg(block); const org = parseOrg(block);
const config = loadConfig(root); const config = loadConfig(root);
config.org = org; config.org = org;
for (const node of org) {
if (!node.agent || !node.dispatch || !config.agents?.[node.agent]) continue;
config.agents[node.agent].dispatch = node.dispatch;
}
saveConfig(root, config); saveConfig(root, config);
return { count: org.length, file }; return { count: org.length, file };
} }

View File

@ -0,0 +1,211 @@
import { listTasks } from './taskService.js';
import { listMessages } from './messageService.js';
import { getRoster } from './rosterService.js';
import { VERSION } from '../../version.js';
/**
* Per-agent presence (lastSeen) + the /health report (TSK-0226).
*
* lastSeen is stamped by the server routes on every announce / claim / review /
* log / message action. It is deliberately IN-MEMORY: presence is a live-view
* concern of the running hub process, not project data a restarted hub simply
* re-learns presence from the agents' next actions.
*
* Traffic light per agent:
* active (green) acted within ACTIVE_WINDOW_MS (2 min)
* busy (blue) has an in_progress task (shown as "busy-on-TSK-X")
* idle (gray) seen within STALE_WINDOW_MS (10 min), nothing in flight
* stale (red) not seen for > STALE_WINDOW_MS with an open assignment
*/
/** An agent that acted less than this long ago counts as active. */
export const ACTIVE_WINDOW_MS = 2 * 60_000;
/** Longer than this without any action ⇒ the agent is stale/offline. */
export const STALE_WINDOW_MS = 10 * 60_000;
/**
* Ab wann gilt ein Agent als DORMANT (der Architekt muss eingreifen)?
*
* `inLoop` allein taugt dafür NICHT: es ist false, sobald ein Agent einen Task
* ausführt ein hart arbeitender Agent sähe damit genauso aus wie ein toter.
* Entscheidend ist, wie lange der letzte Check-in her ist. Alles darunter ist
* normale Arbeit und darf keinen Alarm auslösen.
*/
export const DORMANT_AFTER_MS = 5 * 60_000;
export type AgentLight = 'active' | 'busy' | 'idle' | 'stale';
export interface AgentHealth {
name: string;
role: string;
dispatch: 'loop' | 'architect';
state: AgentLight;
/** Busy-on task (in_progress) or — for stale agents — the waiting open task. */
taskId?: string;
waitingTaskId?: string;
lastSeen?: string;
lastSeenAgoSec?: number;
inLoop: boolean;
loopSince?: string;
loopSinceAgoSec?: number;
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 {
status: 'ok';
version: string;
startedAt: string;
uptimeSec: number;
counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number };
agents: AgentHealth[];
indexErrors?: Array<{ filePath: string; error: string; at: string }>;
}
const lastSeen = new Map<string, number>();
const loops = new Map<string, { since?: number; exitAt?: number; exitReason?: string }>();
/** Stamp an agent's lastSeen (called by the routes on agent actions). */
export function stampSeen(agent: string, at: number = Date.now()): void {
const name = agent?.trim();
if (!name) return;
lastSeen.set(name, at);
}
export function enterLoop(agent: string, at: number = Date.now()): void {
const name = agent?.trim();
if (!name) return;
lastSeen.set(name, at);
loops.set(name, { since: at });
}
export function leaveLoop(agent: string, reason: string, at: number = Date.now()): void {
const name = agent?.trim();
if (!name) return;
lastSeen.set(name, at);
loops.set(name, { exitAt: at, exitReason: reason || 'ended' });
}
export function isAgentInLoop(agent: string): boolean {
return loops.get(agent)?.since !== undefined;
}
export function agentLoopStatus(agent: string): 'active' | 'inactive' | 'unknown' {
const loop = loops.get(agent);
if (!loop) return 'unknown';
return loop.since !== undefined ? 'active' : 'inactive';
}
/** Test hook: drop all presence state. */
export function resetPresence(): void {
lastSeen.clear();
loops.clear();
}
/** One agent's traffic light, derived from presence + its task load. */
export function agentLight(
name: string,
tasks: Array<{ id: string; status?: string; assignedTo?: string; claimedBy?: string }>,
now: number = Date.now(),
): Omit<AgentHealth, 'name' | 'role' | 'dispatch'> {
const seen = lastSeen.get(name);
const busy = tasks.find((t) => t.status === 'in_progress' && (t.assignedTo === name || t.claimedBy === name));
const waiting = tasks.find((t) => t.status === 'open' && t.assignedTo === name);
const seenAgo = seen === undefined ? undefined : now - seen;
const loop = loops.get(name);
let state: AgentLight;
if (seenAgo !== undefined && seenAgo < ACTIVE_WINDOW_MS) {
state = busy ? 'busy' : 'active';
} else if (busy) {
state = 'busy';
} else if (seenAgo !== undefined && seenAgo < STALE_WINDOW_MS) {
state = 'idle';
} else if (waiting) {
state = 'stale';
} else {
state = 'idle';
}
return {
state,
taskId: busy?.id ?? (state === 'stale' ? waiting?.id : undefined),
waitingTaskId: waiting?.id,
lastSeen: seen === undefined ? undefined : new Date(seen).toISOString(),
lastSeenAgoSec: seenAgo === undefined ? undefined : Math.max(0, Math.round(seenAgo / 1000)),
inLoop: loop?.since !== undefined,
loopSince: loop?.since === undefined ? undefined : new Date(loop.since).toISOString(),
loopSinceAgoSec: loop?.since === undefined ? undefined : Math.max(0, Math.round((now - loop.since) / 1000)),
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,
};
}
/** Compact hub health: status + version + uptime + counts + per-agent lights. */
export function computeHealth(cwd: string, startedAtMs: number, now: number = Date.now()): HealthReport {
const tasks = listTasks(cwd);
const roster = getRoster(cwd);
const roleByName = new Map(roster.map((r) => [r.name, r.role]));
const dispatchByName = new Map(roster.map((r) => [r.name, r.dispatch]));
// 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) => {
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',
version: VERSION,
startedAt: new Date(startedAtMs).toISOString(),
uptimeSec: Math.max(0, Math.round((now - startedAtMs) / 1000)),
counts: {
tasks: tasks.length,
open: tasks.filter((t) => t.status === 'open').length,
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
review: tasks.filter((t) => t.status === 'review').length,
unreadMessages: listMessages(cwd).filter((m) => m.status === 'unread').length,
},
agents,
};
}

View File

@ -0,0 +1,51 @@
import { loadConfig, saveConfig } from '../config.js';
import { Role, type Config } from '../schema.js';
function fallbackRole(config: Config, agent: string, excludingRole: string): string {
const preferred = Object.entries(config.roles)
.find(([role, spec]) => role !== excludingRole && spec.preferredAgent === agent);
return preferred?.[0] ?? 'implementer';
}
/**
* Change the preferred agent for a role and keep the named roster coherent.
*
* `roles.*.preferredAgent` is the routing authority (asks, watchdog, start),
* while `agents.*.role` drives health/board/MCP role discovery. Updating only
* one side makes a reachable server tell the newly selected architect that it
* is still an implementer, so both are changed atomically.
*/
export function setPreferredAgent(
cwd: string,
role: string,
agent: string,
): { role: string; agent: string; previousAgent: string } {
const nextAgent = agent.trim();
if (!nextAgent) throw new Error('agent must not be empty');
const config = loadConfig(cwd);
const parsedRole = Role.parse(role);
const roleSpec = config.roles[parsedRole];
if (!roleSpec) throw new Error(`Unknown role: ${role}`);
const previousAgent = roleSpec.preferredAgent;
config.roles[parsedRole] = { ...roleSpec, preferredAgent: nextAgent };
if (config.agents) {
const target = config.agents[nextAgent];
if (!target) throw new Error(`Unknown agent: ${nextAgent}`);
config.agents[nextAgent] = { ...target, role: parsedRole };
if (previousAgent !== nextAgent) {
const previous = config.agents[previousAgent];
if (previous?.role === role) {
config.agents[previousAgent] = {
...previous,
role: Role.parse(fallbackRole(config, previousAgent, parsedRole)),
};
}
}
}
saveConfig(cwd, config);
return { role: parsedRole, agent: nextAgent, previousAgent };
}

View File

@ -10,6 +10,7 @@ export interface RosterEntry {
kind?: string; kind?: string;
description?: string; description?: string;
budgetEur?: number; budgetEur?: number;
dispatch: 'loop' | 'architect';
} }
/** Infer a provider ("kind") from an agent name when the roster doesn't say. */ /** Infer a provider ("kind") from an agent name when the roster doesn't say. */
@ -38,13 +39,22 @@ export function getRoster(cwd: string): RosterEntry[] {
const config = loadConfig(cwd); const config = loadConfig(cwd);
if (config.agents && Object.keys(config.agents).length > 0) { if (config.agents && Object.keys(config.agents).length > 0) {
const preferredRole = new Map<string, string>();
for (const [role, spec] of Object.entries(config.roles)) {
// Architect is authoritative when one agent is preferred for several
// roles (the default Claude config also prefers it as reviewer).
if (!preferredRole.has(spec.preferredAgent) || role === 'architect') {
preferredRole.set(spec.preferredAgent, role);
}
}
return Object.entries(config.agents).map(([name, a]) => ({ return Object.entries(config.agents).map(([name, a]) => ({
name, name,
role: a.role, role: preferredRole.get(name) ?? a.role,
model: a.model, model: a.model,
kind: a.kind ?? inferKind(name), kind: a.kind ?? inferKind(name),
description: a.description, description: a.description,
budgetEur: a.budgetEur, budgetEur: a.budgetEur,
dispatch: a.dispatch,
})); }));
} }
@ -60,6 +70,6 @@ export function getRoster(cwd: string): RosterEntry[] {
return Array.from(names).map((name) => { return Array.from(names).map((name) => {
const kind = inferKind(name); const kind = inferKind(name);
return { name, role: roleByAgent.get(name) ?? 'implementer', kind, model: inferModel(kind) }; return { name, role: roleByAgent.get(name) ?? 'implementer', kind, model: inferModel(kind), dispatch: 'loop' };
}); });
} }

View File

@ -6,9 +6,11 @@ import { readEntity, writeEntity } from '../files.js';
import { TaskSchema, type Task } from '../schema.js'; import { TaskSchema, type Task } from '../schema.js';
import { Index } from '../index.js'; import { Index } from '../index.js';
import { loadConfig } from '../config.js'; import { loadConfig } from '../config.js';
import { resolveAgentName } from './identityService.js';
export function createTask(cwd: string, options: Partial<Task> = {}): Task { export function createTask(cwd: string, options: Partial<Task> = {}): Task {
const now = new Date().toISOString(); const now = new Date().toISOString();
const assignedTo = options.assignedTo ? resolveAgentName(cwd, options.assignedTo) : undefined;
const task: Task = TaskSchema.parse({ const task: Task = TaskSchema.parse({
id: getNextId(cwd, 'task'), id: getNextId(cwd, 'task'),
title: options.title ?? 'Untitled', title: options.title ?? 'Untitled',
@ -16,8 +18,8 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
status: 'open', status: 'open',
priority: options.priority ?? 'medium', priority: options.priority ?? 'medium',
role: options.role, role: options.role,
assignedTo: options.assignedTo, assignedTo,
claimedBy: options.claimedBy, claimedBy: options.claimedBy ? resolveAgentName(cwd, options.claimedBy) : undefined,
reviewer: options.reviewer, reviewer: options.reviewer,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -33,6 +35,50 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
return task; return task;
} }
/** Record work completed outside AgentHub without fabricating lifecycle events. */
export function recordExternalTask(
cwd: string,
options: Pick<Task, 'title'> & Partial<Task> & { doneBy: string },
): Task {
const now = new Date().toISOString();
const agent = resolveAgentName(cwd, options.doneBy);
const task: Task = TaskSchema.parse({
id: getNextId(cwd, 'task'),
title: options.title,
description: options.description ?? '',
status: 'done',
priority: options.priority ?? 'medium',
role: options.role ?? 'implementer',
assignedTo: agent,
claimedBy: agent,
doneBy: agent,
doneTokens: options.doneTokens,
doneDuration: options.doneDuration,
origin: 'external',
recordedAt: now,
createdAt: now,
updatedAt: now,
});
const filePath = join(getEntityDir(cwd, 'tasks'), `${task.id}.md`);
writeEntity(filePath, task, `# ${task.title}\n\n${task.description}\n\n> Nachgetragen: außerhalb von AgentHub erledigt durch ${agent}.`);
const index = new Index(cwd);
index.upsert(toIndexEntry(task, filePath));
index.close();
return task;
}
/** Architect explicitly starts a session-less roster agent for an open task. */
export function dispatchTask(cwd: string, id: string, agentName: string): Task {
const agent = resolveAgentName(cwd, agentName);
const roster = loadConfig(cwd).agents?.[agent];
if (roster?.dispatch !== 'architect') {
throw new Error(`${agent} is not architect-dispatched`);
}
const { task } = getTask(cwd, id);
if (task.status !== 'open') throw new Error(`Task ${id} cannot be dispatched from ${task.status}`);
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agent, claimedBy: agent });
}
export function listTasks(cwd: string, filters?: { status?: string; role?: string }): ReturnType<Index['list']> { export function listTasks(cwd: string, filters?: { status?: string; role?: string }): ReturnType<Index['list']> {
const index = new Index(cwd); const index = new Index(cwd);
const tasks = index.list('task', filters); const tasks = index.list('task', filters);
@ -72,6 +118,7 @@ export function updateTask(cwd: string, id: string, patch: Partial<Task>): Task
* then sees `in_progress` and is refused. * then sees `in_progress` and is refused.
*/ */
export function claimTask(cwd: string, id: string, agentName: string): Task { export function claimTask(cwd: string, id: string, agentName: string): Task {
agentName = resolveAgentName(cwd, agentName);
const { task } = getTask(cwd, id); const { task } = getTask(cwd, id);
// Idempotent: the same agent re-claiming its own in-progress task is a no-op. // Idempotent: the same agent re-claiming its own in-progress task is a no-op.
if (task.status === 'in_progress' && task.claimedBy === agentName) { if (task.status === 'in_progress' && task.claimedBy === agentName) {
@ -82,6 +129,39 @@ export function claimTask(cwd: string, id: string, agentName: string): Task {
`Task ${id} cannot be claimed — status is "${task.status}"${task.claimedBy ? ` (claimed by ${task.claimedBy})` : ''}.`, `Task ${id} cannot be claimed — status is "${task.status}"${task.claimedBy ? ` (claimed by ${task.claimedBy})` : ''}.`,
); );
} }
// Single-claim guard (TSK-0237): an agent holds at most ONE in_progress task.
// Second line of defense behind the work loop's own selection — a direct
// claim (CLI/MCP/board drag) must not bypass it either. The architect is
// exempt (coordinates several threads); there is no --force for agents.
// An uninitialized project (no config, e.g. bare service-level usage) simply
// has no architect exemption — the guard still applies.
let architect: string | undefined;
try {
architect = loadConfig(cwd).roles.architect?.preferredAgent;
} catch {
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 === '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} ${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.`,
);
}
}
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName, claimedBy: agentName }); return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName, claimedBy: agentName });
} }
@ -90,9 +170,16 @@ export function doneTask(
id: string, id: string,
meta?: { doneBy?: string; doneTokens?: number; doneDuration?: number }, meta?: { doneBy?: string; doneTokens?: number; doneDuration?: number },
): Task { ): Task {
const { task } = getTask(cwd, id);
if (task.status !== 'review') {
throw new Error(`Task ${id} cannot transition ${task.status} → done; expected review → done`);
}
if (!task.claimedBy) {
throw new Error(`Task ${id} cannot be completed without a known claimedBy implementer`);
}
return updateTask(cwd, id, { return updateTask(cwd, id, {
status: 'done', status: 'done',
doneBy: meta?.doneBy, doneBy: meta?.doneBy ?? task.claimedBy,
doneTokens: meta?.doneTokens, doneTokens: meta?.doneTokens,
doneDuration: meta?.doneDuration, doneDuration: meta?.doneDuration,
}); });
@ -100,6 +187,12 @@ export function doneTask(
export function reviewTask(cwd: string, id: string, reviewer?: string): Task { export function reviewTask(cwd: string, id: string, reviewer?: string): Task {
const { task } = getTask(cwd, id); const { task } = getTask(cwd, id);
if (task.status !== 'in_progress') {
throw new Error(`Task ${id} cannot transition ${task.status} → review; expected in_progress → review`);
}
if (!task.claimedBy) {
throw new Error(`Task ${id} cannot enter review without a known claimedBy implementer`);
}
const resolvedReviewer = reviewer?.trim() || getPreferredReviewer(cwd); const resolvedReviewer = reviewer?.trim() || getPreferredReviewer(cwd);
const patch: Partial<Task> = { status: 'review' }; const patch: Partial<Task> = { status: 'review' };
if (resolvedReviewer && resolvedReviewer !== task.assignedTo) { if (resolvedReviewer && resolvedReviewer !== task.assignedTo) {
@ -109,11 +202,33 @@ export function reviewTask(cwd: string, id: string, reviewer?: string): Task {
} }
export function cancelTask(cwd: string, id: string): Task { export function cancelTask(cwd: string, id: string): Task {
const { task } = getTask(cwd, id);
if (task.status === 'done' || task.status === 'cancelled') {
throw new Error(`Task ${id} is terminal (${task.status}) and cannot transition to cancelled`);
}
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 {
return updateTask(cwd, id, { status: 'open' }); const { task } = getTask(cwd, id);
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 });
} }
/** /**
@ -141,7 +256,12 @@ export function deleteTask(cwd: string, id: string): { id: string } {
* a specific agent and have a waiting daemon pick it up. * a specific agent and have a waiting daemon pick it up.
*/ */
export function assignTask(cwd: string, id: string, agentName: string): Task { export function assignTask(cwd: string, id: string, agentName: string): Task {
return updateTask(cwd, id, { assignedTo: agentName }); const resolved = resolveAgentName(cwd, agentName);
const { task } = getTask(cwd, id);
return updateTask(cwd, id, {
assignedTo: resolved,
claimedBy: task.status === 'in_progress' ? resolved : undefined,
});
} }
function getPreferredReviewer(cwd: string): string | undefined { function getPreferredReviewer(cwd: string): string | undefined {
@ -152,7 +272,7 @@ function getPreferredReviewer(cwd: string): string | undefined {
} }
} }
function toIndexEntry(task: Task, filePath: string) { export function toIndexEntry(task: Task, filePath: string) {
return { return {
id: task.id, id: task.id,
type: 'task', type: 'task',

View File

@ -59,6 +59,45 @@ export function startDiscoveryBroadcaster(getServerUrl: string | (() => string),
}; };
} }
/**
* Cheap reachability probe for a configured server URL: GET /status with a
* short timeout. Used to detect a stale saved address (e.g. agenthub.local
* after a network change) before a remote command hard-fails.
*/
export async function probeServer(url: string, timeoutMs = 1000): Promise<boolean> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(new URL('/status', url).toString(), { signal: controller.signal });
return res.ok;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
/**
* Self-heal a stale configured server URL: probe it, and if it is unreachable
* fall back to LAN discovery. Returns the URL to use for the call the
* configured one when it answers, otherwise the discovered one, otherwise
* `undefined` (nothing reachable). Probe/discovery are injectable for tests.
*/
export async function resolveReachableServerUrl(
configuredUrl: string,
options: {
probe?: (url: string) => Promise<boolean>;
discover?: (timeoutMs?: number) => Promise<string | undefined>;
discoverTimeoutMs?: number;
} = {},
): Promise<string | undefined> {
const probe = options.probe ?? probeServer;
if (await probe(configuredUrl)) return configuredUrl;
const discover = options.discover ?? discoverServer;
const discovered = await discover(options.discoverTimeoutMs ?? 2000);
return discovered && discovered !== configuredUrl ? discovered : undefined;
}
export function discoverServer(timeoutMs = 3000, port = DISCOVERY_PORT): Promise<string | undefined> { export function discoverServer(timeoutMs = 3000, port = DISCOVERY_PORT): Promise<string | undefined> {
return new Promise((resolve) => { return new Promise((resolve) => {
const socket = dgram.createSocket('udp4'); const socket = dgram.createSocket('udp4');

View File

@ -23,10 +23,11 @@ function resolveUrl(root: string): string {
return serverUrl ?? 'http://127.0.0.1:3377'; return serverUrl ?? 'http://127.0.0.1:3377';
} }
export function installMcp(cwd: string, opts: { print?: boolean } = {}): void { export function installMcp(cwd: string, opts: { print?: boolean; agent?: string } = {}): void {
const root = findProjectRoot(cwd) ?? cwd; const root = findProjectRoot(cwd) ?? cwd;
const url = resolveUrl(root); const url = resolveUrl(root);
const serverDef = { command: 'agenthub', args: ['mcp'], env: { AGENTHUB_SERVER: url } }; const args = ['mcp', ...(opts.agent ? ['--agent', opts.agent] : [])];
const serverDef = { command: 'agenthub', args, env: { AGENTHUB_SERVER: url } };
const out = (s = '') => process.stdout.write(s + '\n'); const out = (s = '') => process.stdout.write(s + '\n');
out(); out();
@ -62,11 +63,11 @@ export function installMcp(cwd: string, opts: { print?: boolean } = {}): void {
out('── Codex (~/.codex/config.toml) ──'); out('── Codex (~/.codex/config.toml) ──');
out(' [mcp_servers.agenthub]'); out(' [mcp_servers.agenthub]');
out(' command = "agenthub"'); out(' command = "agenthub"');
out(' args = ["mcp"]'); out(` args = ${JSON.stringify(args)}`);
out(` env = { AGENTHUB_SERVER = "${url}" }`); out(` env = { AGENTHUB_SERVER = "${url}" }`);
out(''); out('');
out('── Kimi (its MCP config) ──'); out('── Kimi (its MCP config) ──');
out(` stdio server — command: agenthub args: ["mcp"] env: AGENTHUB_SERVER=${url}`); out(` stdio server — command: agenthub args: ${JSON.stringify(args)} env: AGENTHUB_SERVER=${url}`);
out(''); out('');
out('On Windows use the Mac LAN IP (e.g. http://192.168.178.30:3377). Requires agenthub >= 0.7.0.'); out('On Windows use the Mac LAN IP (e.g. http://192.168.178.30:3377). Requires agenthub >= 0.7.0.');
out(''); out('');

158
src/mcp/pushChannel.ts Normal file
View File

@ -0,0 +1,158 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { parseSSEBuffer } from '../cli/commands/watch.js';
import { remoteClient } from '../cli/remoteClient.js';
/**
* Echter Push an den Agenten über MCP ohne blockierenden Tool-Call.
*
* WARUM DAS HIER STEHT (die Lehre aus einem ganzen Tag):
* Bisher konnte ein Agent nur empfangen, während er in `agenthub_work`
* blockierte. Kehrte der Aufruf leer zurück und das Modell startete ihn nicht
* neu, war der Agent unerreichbar kein Reopen, keine Nachricht kam an, und
* ein Mensch musste ihn anstoßen. Wir haben um diese Tatsache herum gebaut
* (Watchdog, Check-in-Kanal, längere Timeouts) statt sie aufzulösen.
*
* MCP kann das von Haus aus: der Server darf dem Client JEDERZEIT
* `notifications/message` schicken. Unsere Bridge ist ohnehin ein langlebiger
* Prozess pro Agent sie hängt sich dauerhaft an den SSE-Stream des Hubs und
* reicht relevante Ereignisse als MCP-Notification hoch. Kein Blockieren,
* kein Timeout, keine Loop-Disziplin nötig.
*
* VORAUSSETZUNG: Der Server MUSS die `logging`-Capability deklarieren
* `sendLoggingMessage` prüft `_capabilities.logging` und verwirft die
* Notification sonst STILL. Genau daran wäre dieser Weg unbemerkt gescheitert.
*
* OFFEN (client-abhängig, nicht von uns entscheidbar): ob der jeweilige
* CLI-Host die Notification dem Modell zeigt. Das ist empirisch zu prüfen und
* darf nicht behauptet werden. Deshalb bleibt der Check-in-Kanal als Netz
* bestehen dieser Push ist die Verbesserung, nicht der alleinige Verlass.
*/
/** Reconnect-Backoff, wenn der SSE-Stream abreißt (Hub-Neustart o. Ä.). */
const RECONNECT_DELAYS_MS = [1000, 3000, 8000];
interface PushState {
agent: string;
names: Set<string>;
stop: () => void;
}
let active: PushState | undefined;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Betrifft dieses Event den Agenten, für den wir pushen? */
function relevantFor(
event: { type?: string; action?: string; assignedTo?: string; status?: string },
names: Set<string>,
): boolean {
const target = String(event.assignedTo ?? '').toLowerCase();
if (target && names.has(target)) return true;
// Ohne Adressat ist ein Task-Event nur interessant, wenn es ein Reopen sein
// könnte — der Claim wird dabei abgeräumt, der Adressat bleibt aber stehen,
// also greift oben bereits `assignedTo`. Alles andere ignorieren wir bewusst:
// Rauschen entwertet den Kanal (Lehre aus dem Watchdog-Spam).
return false;
}
function describe(event: { type?: string; action?: string; id?: string; status?: string }): string {
const id = event.id ?? '?';
if (event.type === 'message') return `Neue Nachricht ${id} für dich — agenthub_inbox lesen.`;
if (event.type === 'ask') return `Deine Frage ${id} wurde beantwortet — agenthub_ask_list.`;
if (event.status === 'open') return `${id} wartet auf dich (neu oder zurückgegeben) — agenthub_work aufrufen.`;
if (event.status === 'cancelled') return `${id} wurde abgebrochen — STOPP, nicht weiterbauen.`;
return `${id}: ${event.type}/${event.action} (${event.status ?? '—'}).`;
}
/**
* Bindet die Bridge an einen Agenten und beginnt zu pushen. Idempotent:
* derselbe Agent startet den Stream nicht doppelt, ein anderer löst den alten ab.
*/
export function bindPushChannel(server: McpServer, serverUrl: string, agent: string): void {
const name = agent.trim().toLowerCase();
if (!name || active?.agent === name) return;
active?.stop();
let stopped = false;
let controller: AbortController | undefined;
let lastEventId: number | undefined;
const state: PushState = {
agent: name,
names: new Set([name]),
stop: () => {
stopped = true;
controller?.abort();
},
};
active = state;
// DIAGNOSE: Was deklariert der CLI-Host ueberhaupt? `sendLoggingMessage`
// nuetzt nichts, wenn der Client `logging` nicht deklariert — dann verwirft
// seine SDK-Seite die Notification, bevor das Modell sie je sieht. Genau das
// ist der Verdacht, nachdem codex und kimi den Push nicht wahrgenommen haben.
// Wir schreiben es weg, statt zu spekulieren.
try {
const caps = server.server.getClientCapabilities();
const dir = join(homedir(), '.cache', 'agenthub');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, `client-capabilities-${name}.json`),
JSON.stringify({ agent: name, at: new Date().toISOString(), capabilities: caps ?? null }, null, 2),
);
} catch { /* Diagnose darf den Kanal nie blockieren */ }
void (async () => {
// Aliase mitnehmen, damit ein umbenannter Agent seine Events weiter bekommt.
try {
const identity = await remoteClient.getAgentIdentity(serverUrl, agent);
for (const alias of identity.names) state.names.add(alias.toLowerCase());
} catch { /* Namensvergleich reicht */ }
let attempt = 0;
while (!stopped) {
try {
controller = new AbortController();
const res = await fetch(new URL('/events', serverUrl).toString(), {
signal: controller.signal,
headers: {
Accept: 'text/event-stream',
...(lastEventId !== undefined ? { 'Last-Event-ID': String(lastEventId) } : {}),
},
});
if (!res.body) throw new Error('SSE ohne Body');
attempt = 0;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!stopped) {
const { done, value } = await reader.read();
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
for (const event of events) {
// Cursor fuer ALLE Events fortschreiben, nicht nur fuer relevante:
// Der Server replayt global. Bliebe der Cursor bei fremden Events
// stehen, wuerden sie nach jedem Reconnect erneut uebertragen.
if (event.seq !== undefined) lastEventId = event.seq;
if (!relevantFor(event, state.names)) continue;
try {
await server.server.sendLoggingMessage({
level: 'info',
logger: 'agenthub',
data: { agenthub: describe(event), event },
});
} catch { /* Client mag Notifications nicht — kein Grund abzubrechen */ }
}
}
} catch { /* Hub weg, Neustart oder stop()-Abort — unten behandeln */ }
if (stopped) return;
await sleep(RECONNECT_DELAYS_MS[Math.min(attempt++, RECONNECT_DELAYS_MS.length - 1)]!);
}
})();
}

View File

@ -18,13 +18,32 @@ 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 { bindPushChannel } from './pushChannel.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, markMessageRead } from '../core/services/messageService.js'; import { createMessage, listInbox } from '../core/services/messageService.js';
import { addMemory, searchMemory } from '../core/services/memoryService.js'; import { addMemory, searchMemory } from '../core/services/memoryService.js';
import { createDecision } from '../core/services/decisionService.js'; import { createDecision } from '../core/services/decisionService.js';
import { getStatus } from '../core/services/statusService.js'; import { getStatus } from '../core/services/statusService.js';
import { discoverServer as discoverHubServer } from '../discovery.js'; import { discoverServer as discoverHubServer, resolveReachableServerUrl } from '../discovery.js';
import { VERSION } from '../version.js';
import { setPreferredAgent } from '../core/services/roleService.js';
export const DEFAULT_WORK_TIMEOUT_SEC = 50;
export const DEFAULT_TASK_LIST_LIMIT = 50;
export function boundedTaskList<T extends { updatedAt?: string; createdAt?: string }>(
tasks: T[],
limit: number = DEFAULT_TASK_LIST_LIMIT,
): { tasks: T[]; total: number; returned: number; omitted: number } {
const safeLimit = Math.max(1, Math.min(200, Math.floor(limit)));
const sorted = [...tasks].sort((a, b) =>
String(b.updatedAt ?? b.createdAt ?? '').localeCompare(String(a.updatedAt ?? a.createdAt ?? '')),
);
const selected = sorted.slice(0, safeLimit);
return { tasks: selected, total: tasks.length, returned: selected.length, omitted: tasks.length - selected.length };
}
/** /**
* AgentHub MCP server (TSK-0030). * AgentHub MCP server (TSK-0030).
@ -38,7 +57,10 @@ import { discoverServer as discoverHubServer } from '../discovery.js';
* *
* Transport: stdio (each agent's CLI spawns `agenthub mcp` as a subprocess). * Transport: stdio (each agent's CLI spawns `agenthub mcp` as a subprocess).
*/ */
function resolveContext(cwd: string): { root: string; serverUrl?: string } { export async function resolveMcpContext(
cwd: string,
options: Parameters<typeof resolveReachableServerUrl>[1] = {},
): Promise<{ root: string; serverUrl?: string }> {
const root = findProjectRoot(cwd) ?? cwd; const root = findProjectRoot(cwd) ?? cwd;
let serverUrl = process.env.AGENTHUB_SERVER || undefined; let serverUrl = process.env.AGENTHUB_SERVER || undefined;
if (!serverUrl) { if (!serverUrl) {
@ -48,7 +70,9 @@ function resolveContext(cwd: string): { root: string; serverUrl?: string } {
// no project config — local/none // no project config — local/none
} }
} }
return { root, serverUrl }; if (!serverUrl) return { root };
const reachable = await resolveReachableServerUrl(serverUrl, options);
return reachable ? { root, serverUrl: reachable } : { root };
} }
function asText(value: unknown) { function asText(value: unknown) {
@ -69,8 +93,14 @@ async function resolveReconnectUrl(currentUrl: string): Promise<string> {
return discovered || currentUrl; return discovered || currentUrl;
} }
/** Block on the SSE stream until findClaim() returns a task, or timeout. */ /**
function waitForTask<T>( * Block on the SSE stream until findClaim() returns a task, or timeout.
* Exported for the auto-wake regression tests (TSK-0230): the tests drive it
* with a findClaim that mimics the agenthub_work finder, proving the wait
* wakes on task_assign / incoming message via SSE AND via the polling
* fallback when SSE is down.
*/
export function waitForTask<T>(
serverUrl: string, serverUrl: string,
findClaim: (serverUrl: string) => Promise<T | null>, findClaim: (serverUrl: string) => Promise<T | null>,
timeoutSec: number, timeoutSec: number,
@ -82,21 +112,47 @@ function waitForTask<T>(
const deadline = Date.now() + Math.max(1, timeoutSec) * 1000; const deadline = Date.now() + Math.max(1, timeoutSec) * 1000;
const backoffs = [2000, 5000, 10000]; const backoffs = [2000, 5000, 10000];
let reconnectAttempt = 0; let reconnectAttempt = 0;
let lastEventId: number | undefined;
let poll: NodeJS.Timeout | undefined;
const finish = (v: T | null) => { const finish = (v: T | null) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
if (poll) clearInterval(poll);
try { controller?.abort(); } catch { /* already */ } try { controller?.abort(); } catch { /* already */ }
resolve(v); resolve(v);
}; };
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000); const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
// Polling fallback: a dropped SSE frame on an otherwise-open stream must
// never mean an infinite sleep — re-run findClaim every few seconds, so a
// lost event costs at most one poll interval (~4s).
let polling = false;
poll = setInterval(() => {
if (settled || polling) return;
polling = true;
void (async () => {
try {
const hit = await findClaim(currentUrl);
if (hit) { clearTimeout(timer); finish(hit); }
} catch {
/* best-effort: the SSE path and the next tick remain */
} finally {
polling = false;
}
})();
}, 4000);
poll.unref?.();
const waitLoop = async () => { const waitLoop = async () => {
while (!settled && remainingMs(deadline) > 0) { while (!settled && remainingMs(deadline) > 0) {
controller = new AbortController(); controller = new AbortController();
try { try {
const res = await fetch(new URL('/events', currentUrl).toString(), { const res = await fetch(new URL('/events', currentUrl).toString(), {
signal: controller.signal, signal: controller.signal,
headers: { Accept: 'text/event-stream' }, headers: {
Accept: 'text/event-stream',
...(lastEventId !== undefined ? { 'Last-Event-ID': String(lastEventId) } : {}),
},
}); });
if (!res.body) throw new Error('SSE response has no body'); if (!res.body) throw new Error('SSE response has no body');
// Close the gap: a task may have arrived between the initial check and now. // Close the gap: a task may have arrived between the initial check and now.
@ -113,6 +169,9 @@ function waitForTask<T>(
if (value) buffer += decoder.decode(value, { stream: true }); if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer); const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining; buffer = remaining;
for (const event of events) {
if (event.seq !== undefined) lastEventId = event.seq;
}
// Wake on a new task, a message, or an ask (decision routed to the // Wake on a new task, a message, or an ask (decision routed to the
// architect / answered back to the asker). // architect / answered back to the asker).
if (events.some((e) => e.type === 'task' || e.type === 'message' || e.type === 'ask')) { if (events.some((e) => e.type === 'task' || e.type === 'message' || e.type === 'ask')) {
@ -140,37 +199,60 @@ function waitForTask<T>(
}); });
} }
export async function startMcpServer(cwd: string): Promise<void> { export async function startMcpServer(cwd: string, options: { agent?: string } = {}): Promise<void> {
const { root, serverUrl } = resolveContext(cwd); const { root, serverUrl } = await resolveMcpContext(cwd);
const remote = !!serverUrl; const remote = !!serverUrl;
const server = new McpServer({ name: 'agenthub', version: '0.8.0' }); // `logging` MUSS deklariert werden: sendLoggingMessage prueft
// _capabilities.logging und verwirft die Notification sonst kommentarlos.
// Ohne diese Zeile gaebe es keinen Push — und man saehe nicht, warum.
const server = new McpServer(
{ name: 'agenthub', version: VERSION },
{ capabilities: { logging: {} } },
);
/** Bridge an den Agenten binden, sobald er sich zu erkennen gibt. */
const bindPush = (agent?: string) => {
if (remote && agent) bindPushChannel(server, serverUrl!, agent);
};
// Beim Start binden, wenn der Name bekannt ist (`agenthub mcp --agent codex`).
// Ohne das beginnt der Push erst mit dem ersten Tool-Aufruf — und genau in
// dem Fenster davor ist der Agent wieder taub, also fuer den Zustand, den
// wir beheben wollen.
bindPush(options.agent);
server.tool('agenthub_hello', 'Announce yourself to the hub (presence). Run once when you start.', server.tool('agenthub_hello', 'Announce yourself to the hub (presence). Run once when you start.',
{ agent: z.string(), role: z.string().optional() }, { agent: z.string(), role: z.string().optional() },
async ({ agent, role }) => { async ({ agent, role }) => {
if (remote) await remoteClient.announce(serverUrl!, agent, role ?? 'implementer'); if (remote) await remoteClient.announce(serverUrl!, agent, role ?? 'implementer');
bindPush(agent);
return asText(`AgentHub: ${agent} joined (${role ?? 'implementer'})`); return asText(`AgentHub: ${agent} joined (${role ?? 'implementer'})`);
}); });
server.tool('agenthub_work', server.tool('agenthub_work',
'Block until there is work for you, then return it. IMPLEMENTER: waits for a task addressed to you (newly delegated OR reopened), claims it, returns it with its handoff (loop: work -> implement -> agenthub_task_review -> work). ARCHITECT/REVIEWER: waits for any task submitted to review and returns the pending set to approve (agenthub_task_done) or reject (agenthub_task_reopen) — so review submissions reach you in realtime.', 'Block until there is work for you, then return it. The default 50s timeout stays below known MCP client limits; SSE still wakes immediately, so this only causes more empty wake-ups and does not add delivery latency. IMPLEMENTER: waits for a task addressed to you (newly delegated OR reopened), claims it, returns it with its handoff (loop: work -> implement -> agenthub_task_review -> work). ARCHITECT/REVIEWER: waits for any task submitted to review and returns the pending set to approve (agenthub_task_done) or reject (agenthub_task_reopen) — so review submissions reach you in realtime.',
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional(), unattended: z.boolean().optional() }, { agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional(), unattended: z.boolean().optional() },
async ({ agent, role, timeoutSec, unattended }) => { async ({ agent, role, timeoutSec, unattended }) => {
bindPush(agent);
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' }; const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
if (remote) await remoteClient.setLoop(serverUrl!, agent, true);
const leave = async (reason: string) => {
if (remote) {
try { await remoteClient.setLoop(ctx.serverUrl!, agent, false, reason); } catch { /* best-effort */ }
}
};
const reviewer = isReviewerRole(ctx.role); const reviewer = isReviewerRole(ctx.role);
const useServerUrl = (nextServerUrl?: string) => { const useServerUrl = (nextServerUrl?: string) => {
if (nextServerUrl) ctx.serverUrl = nextServerUrl; if (nextServerUrl) ctx.serverUrl = nextServerUrl;
return ctx.serverUrl!; return ctx.serverUrl!;
}; };
// Fetch + mark-read the agent's unread messages, so the work loop surfaces // Fetch the agent's unread messages so the work loop surfaces them.
// them once and doesn't spin on the same message. // Surfacing flips them `unread` → `delivered` (the listInbox read
// receipt), but nothing auto-marks them `read`: the message stays
// visible in the inbox until an explicit ack/read, and the loop wakes
// only on `unread`, so a delivered message never re-wakes it (no spin).
const drainInbox = async (nextServerUrl?: string) => { const drainInbox = async (nextServerUrl?: string) => {
const activeUrl = nextServerUrl ? useServerUrl(nextServerUrl) : ctx.serverUrl; const activeUrl = nextServerUrl ? useServerUrl(nextServerUrl) : ctx.serverUrl;
const msgs = remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true }); return remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
for (const m of msgs) {
try { if (remote) await remoteClient.markMessageRead(activeUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ }
}
return msgs;
}; };
const findWork = async (nextServerUrl?: string) => { const findWork = async (nextServerUrl?: string) => {
if (nextServerUrl) useServerUrl(nextServerUrl); if (nextServerUrl) useServerUrl(nextServerUrl);
@ -194,6 +276,7 @@ export async function startMcpServer(cwd: string): Promise<void> {
// tasks addressed to you). Returns the pending review set — never claims. // tasks addressed to you). Returns the pending review set — never claims.
const listPendingAsks = async (): Promise<Ask[]> => const listPendingAsks = async (): Promise<Ask[]> =>
remote ? await remoteClient.listAsks(ctx.serverUrl!, { status: 'pending' }) : listAsks(root, { status: 'pending' }); remote ? await remoteClient.listAsks(ctx.serverUrl!, { status: 'pending' }) : listAsks(root, { status: 'pending' });
let architectEventSeq: number | undefined;
const findReview = async (nextServerUrl?: string) => { const findReview = async (nextServerUrl?: string) => {
if (nextServerUrl) useServerUrl(nextServerUrl); if (nextServerUrl) useServerUrl(nextServerUrl);
const reviews = await listReviewTasks(ctx); const reviews = await listReviewTasks(ctx);
@ -208,6 +291,27 @@ export async function startMcpServer(cwd: string): Promise<void> {
}; };
} }
if (messages.length) return { reviews: [], asks: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' }; if (messages.length) return { reviews: [], asks: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' };
// The active architect also needs visibility into every task lifecycle
// transition, not just tasks that happen to enter review. Seed the
// durable cursor without replaying stale history, then return subsequent
// task events immediately (SSE) or within the polling fallback.
if (ctx.role.toLowerCase() === 'architect' && remote) {
const pulse = await remoteClient.getArchitectPulse(ctx.serverUrl!, architectEventSeq);
const seeded = architectEventSeq === undefined;
architectEventSeq = pulse.nextSeq;
if (!seeded) {
const taskEvents = pulse.events.filter((event) => event.type === 'task');
if (taskEvents.length) {
return {
reviews: [],
asks: [],
messages: [],
events: taskEvents,
note: 'Task status changed. Inspect the affected task, then re-arm agenthub_work.',
};
}
}
}
return null; return null;
}; };
@ -230,21 +334,76 @@ export async function startMcpServer(cwd: string): Promise<void> {
LOOP += ' UNATTENDED MODE: never pause for human input — when you need a decision, ' + LOOP += ' UNATTENDED MODE: never pause for human input — when you need a decision, ' +
'call agenthub_ask (it routes to the architect) and await the answer instead of stalling.'; 'call agenthub_ask (it routes to the architect) and await the answer instead of stalling.';
} }
// Haelt der Agent bereits eine Task, wartet der Finder ins Leere (er sucht
// nur `open`). Das nennen wir jetzt beim Namen, statt ihn blockieren zu
// lassen — sonst wartet er auf Arbeit, die er laengst hat.
if (!reviewer) {
const held = resolvePending(root, agent).heldTask;
if (held) {
const detail = remote ? await remoteClient.getTask(serverUrl!, held) : getTask(root, held);
return asText({
held,
body: (detail as { body?: string }).body,
note: `Du haeltst bereits ${held}. Arbeite dort weiter und reiche sie mit agenthub_task_review ein — erst danach bekommst du Neues.`,
loop: LOOP,
});
}
}
const immediate = await finder(); const immediate = await finder();
if (immediate) return asText({ ...immediate, loop: LOOP }); if (immediate) {
await leave('work delivered');
return asText({ ...immediate, loop: LOOP });
}
const emptyMsg = reviewer const emptyMsg = reviewer
? `No task in review for ${agent}, and no hub server to wait on.` ? `No task in review for ${agent}, and no hub server to wait on.`
: `No open task addressed to ${agent}, and no hub server to wait on.`; : `No open task addressed to ${agent}, and no hub server to wait on.`;
if (!remote) return asText(emptyMsg); if (!remote) return asText(emptyMsg);
const result = await waitForTask(serverUrl!, finder, timeoutSec ?? 300); // Timeout-Aufloesung: explizites Argument > Roster-Einstellung des Agenten
if (result) return asText({ ...result, loop: LOOP }); // > globaler Default. Der Roster-Wert existiert, weil die Grenze am
// MCP-CLIENT haengt: Kimi-Code bricht nach gut einer Minute ab, Codex
// vertraegt Minuten. Ein globaler Default zwingt alle auf das Minimum des
// schwaechsten Clients — der robustere Agent kehrt dann unnoetig oft leer
// zurueck, und jede Rueckkehr ist eine Gelegenheit, aus dem Loop zu fallen.
let rosterTimeout: number | undefined;
if (timeoutSec === undefined) {
try {
rosterTimeout = (await remoteClient.getAgentIdentity(serverUrl!, agent)).workTimeoutSec;
} catch { /* Roster nicht erreichbar — Default greift */ }
}
const effectiveTimeout = timeoutSec ?? rosterTimeout ?? DEFAULT_WORK_TIMEOUT_SEC;
let result;
try {
result = await waitForTask(serverUrl!, finder, effectiveTimeout);
} catch (err) {
await leave(`error: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
if (result) {
await leave('work delivered');
return asText({ ...result, loop: LOOP });
}
await leave('timeout');
const kind = reviewer ? 'review submission' : 'task or message'; const kind = reviewer ? 'review submission' : 'task or message';
return asText(`No ${kind} for ${agent} within ${timeoutSec ?? 300}s. ${LOOP}`); return asText(`No ${kind} for ${agent} within ${effectiveTimeout}s. ${LOOP}`);
}); });
server.tool('agenthub_task_list', 'List tasks, optionally filtered by status and/or role.', server.tool('agenthub_task_list', 'List tasks newest-first, optionally filtered by status and/or role. Defaults to 50 and reports total/omitted counts.',
{ status: z.string().optional(), role: z.string().optional() }, { status: z.string().optional(), role: z.string().optional(), limit: z.number().int().positive().max(200).optional() },
async ({ status, role }) => asText(remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, role }))); async ({ status, role, limit }) => {
const tasks = remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, role });
return asText(boundedTaskList(tasks, limit));
});
server.tool(
'agenthub_role_set',
'Switch the preferred agent for a role. Use this to hand the architect role to another agent during maintenance; the prior architect falls back to its other preferred role or implementer.',
{ role: z.enum(['architect', 'implementer', 'reviewer', 'tester']), agent: z.string() },
async ({ role, agent }) => asText(
remote
? await remoteClient.setPreferredAgent(serverUrl!, role, agent)
: setPreferredAgent(root, role, agent),
),
);
server.tool('agenthub_task_show', 'Show one task with its full body.', server.tool('agenthub_task_show', 'Show one task with its full body.',
{ id: z.string() }, { id: z.string() },
@ -268,7 +427,48 @@ export async function startMcpServer(cwd: string): Promise<void> {
server.tool('agenthub_task_review', 'Submit a finished task for architect review. Implementers use THIS, never agenthub_task_done.', server.tool('agenthub_task_review', 'Submit a finished task for architect review. Implementers use THIS, never agenthub_task_done.',
{ id: z.string() }, { id: z.string() },
async ({ id }) => asText(remote ? await remoteClient.reviewTask(serverUrl!, id) : reviewTask(root, id))); async ({ id }) => {
const submitted = remote ? await remoteClient.reviewTask(serverUrl!, id) : reviewTask(root, id);
if (!remote) {
return asText({ submitted, note: 'Submitted. Relaunch agenthub_work to stay reachable.' });
}
const agent = submitted.claimedBy ?? submitted.assignedTo;
if (!agent) return asText({ submitted, note: 'Submitted, but no implementer identity is available for continued waiting.' });
const ctx: AgentContext = {
serverUrl,
projectCwd: root,
agent,
role: submitted.role ?? 'implementer',
};
await remoteClient.setLoop(serverUrl!, agent, true);
const finder = async (nextServerUrl?: string) => {
if (nextServerUrl) ctx.serverUrl = nextServerUrl;
const found = await findAddressedOpenTask(ctx);
if (found) {
await remoteClient.claimTask(ctx.serverUrl!, found.task.id, agent);
const detail = await remoteClient.getTask(ctx.serverUrl!, found.task.id);
return { claimed: found.task, body: detail.body };
}
const messages = await remoteClient.getInbox(ctx.serverUrl!, agent, true);
return messages.length ? { claimed: null, messages } : null;
};
try {
const next = await waitForTask(serverUrl!, finder, DEFAULT_WORK_TIMEOUT_SEC);
await remoteClient.setLoop(ctx.serverUrl!, agent, false, next ? 'next work delivered' : 'timeout');
return asText({
submitted,
next,
note: next
? 'Review submitted and the implementer stayed reachable; next work/message is included.'
: `Review submitted; no next work arrived within ${DEFAULT_WORK_TIMEOUT_SEC}s.`,
});
} catch (err) {
try {
await remoteClient.setLoop(ctx.serverUrl!, agent, false, `error: ${err instanceof Error ? err.message : String(err)}`);
} catch { /* best-effort */ }
throw err;
}
});
server.tool('agenthub_task_reopen', 'Re-trigger a task after review (architect: send back to the implementer).', server.tool('agenthub_task_reopen', 'Re-trigger a task after review (architect: send back to the implementer).',
{ id: z.string() }, { id: z.string() },
@ -279,9 +479,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() },

362
src/server/agentHealth.ts Normal file
View File

@ -0,0 +1,362 @@
/**
* Agent health-check page, served at `GET /agent-health` (TSK-0230).
*
* One glance answers "which agents are reachable?" and proves it: per agent
* a test message can be sent whose delivery status (sent delivered read)
* flips LIVE when the agent's work loop auto-wakes (HOF-0086 #8 the button
* is the live proof that auto-wake works, no manual poke).
*
* Data sources (all reused, nothing reimplemented):
* - GET /health the TSK-0226 traffic light (active/busy/idle/stale),
* lastSeen, busy-on/waiting task.
* - GET /messages side-effect-free full list (listMessages) for the
* cross-messaging view + delivery tracking. NEVER the
* ?agent= inbox variant listInbox flips unreaddelivered
* as a read receipt and would fake delivery.
* - GET /events SSE. The browser's EventSource sends Last-Event-ID on
* reconnect natively, so the durable event log (TSK-0225)
* replays what it missed. When SSE drops, the page keeps
* polling every 3s and SAYS so (transport badge).
*/
import { loadConfig } from '../core/config.js';
import { computeHealth, type AgentHealth } from '../core/services/presenceService.js';
import { listMessages, type InboxMessage } from '../core/services/messageService.js';
import { designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, newTaskModalJs } from './ui-shared.js';
/** JSON for safe embedding in a <script> tag (no </script breakout). */
function embedJson(value: unknown): string {
return JSON.stringify(value).replace(/</g, '\\u003c');
}
interface HealthPageData {
health: ReturnType<typeof computeHealth>;
messages: InboxMessage[];
architect: string;
}
function pageData(cwd: string, startedAtMs: number): HealthPageData {
const config = loadConfig(cwd);
return {
health: computeHealth(cwd, startedAtMs),
messages: listMessages(cwd).slice(-200),
architect: config.roles.architect?.preferredAgent ?? 'claude',
};
}
/** Initial SSR rows — the JS re-renders live afterwards (same card shape). */
function renderAgentCardsSSR(agents: AgentHealth[]): string {
return agents
.map(
(a) => `<article class="agent-card" data-agent="${escapeHtml(a.name)}">
<header class="ac-head">
<span class="ac-name">${escapeHtml(a.name)}</span>
<span class="ac-role">${escapeHtml(a.role)}</span>
<span class="light light-${a.state}" data-light>${a.state}</span>
</header>
<div class="ac-meta" data-meta>${a.inLoop
? `in loop since ${escapeHtml(a.loopSince ?? 'now')}`
: a.loopExitReason
? `out of loop · ${escapeHtml(a.loopExitReason)}`
: 'loop state unknown'}</div>
<div class="ac-test">
<input class="ac-input" type="text" value="health-check ping" maxlength="200" aria-label="Test message" />
<button class="ac-send" type="button">Send test</button>
</div>
<div class="ac-track" data-track></div>
<ul class="ac-msgs" data-msgs></ul>
</article>`,
)
.join('');
}
export function renderAgentHealthHtml(cwd: string, startedAtMs: number): string {
const config = loadConfig(cwd);
const data = pageData(cwd, startedAtMs);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>AgentHub Agent Health</title>
<style>
${designTokensCss()}
${appHeaderCss()}
body { padding: 96px 20px 32px; }
main { max-width: 1120px; margin: 0 auto; display: grid; gap: 12px; }
.summary { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
.summary h1 { font-size: 18px; margin: 0; }
.chip { font: 11px/1 var(--font-mono); border: 1px solid var(--border); border-radius: 999px; padding: 5px 10px; color: var(--muted); white-space: nowrap; }
.chip b { color: var(--text); font-weight: 700; }
.spacer { flex: 1; }
/* Transport badge: SSE live vs polling fallback — visibility only. */
.transport { display: inline-flex; align-items: center; gap: 7px; font: 700 11px/1 var(--font-mono); border-radius: 999px; padding: 6px 12px; border: 1px solid var(--border); }
.transport .tdot { width: 8px; height: 8px; border-radius: 50%; }
.transport.sse { color: #adf2c7; border-color: rgba(34,197,94,.4); }
.transport.sse .tdot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.16); animation: tp 1.5s ease-in-out infinite; }
.transport.poll { color: #f5d08a; border-color: rgba(210,153,34,.45); }
.transport.poll .tdot { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.16); }
@keyframes tp { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
.agents { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 12px; align-items: start; }
.agent-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; display: grid; gap: 9px; min-width: 0; }
.ac-head { display: flex; align-items: baseline; gap: 8px; }
.ac-name { font-weight: 700; font-size: 14px; }
.ac-role { font: 10.5px/1 var(--font-mono); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.ac-head .light { margin-left: auto; }
/* The TSK-0226 traffic light, same four states, same colors. */
.light { font: 700 10px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; border-radius: 999px; padding: 4px 9px; border: 1px solid transparent; }
.light-active { color: #adf2c7; border-color: rgba(34,197,94,.4); background: rgba(34,197,94,.08); }
.light-busy { color: #a8d0ff; border-color: rgba(88,166,255,.4); background: rgba(88,166,255,.08); }
.light-idle { color: var(--muted); border-color: var(--border); }
.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; }
.ac-test { display: flex; gap: 7px; }
.ac-input { flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 7px; color: var(--text); font: 12px/1 var(--font-mono); padding: 7px 9px; }
.ac-input:focus { outline: none; border-color: var(--accent); }
.ac-send { border: 1px solid var(--border); background: var(--raised); color: var(--text); font: 600 11.5px/1 var(--font-sans); border-radius: 7px; padding: 0 12px; cursor: pointer; white-space: nowrap; }
.ac-send:hover { border-color: var(--accent); }
.ac-send[disabled] { opacity: .55; cursor: default; }
.ac-track { font: 11px/1.4 var(--font-mono); min-height: 15px; }
.ac-track .t-sent { color: var(--muted); }
.ac-track .t-delivered { color: #a8d0ff; }
.ac-track .t-read { color: #adf2c7; }
.ac-track .t-timeout { color: #ffb4b4; }
.ac-msgs { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; border-top: 1px solid var(--border); padding-top: 8px; }
.ac-msgs:empty { display: none; }
.ac-msgs li { display: grid; grid-template-columns: auto minmax(0,1fr) auto; gap: 7px; align-items: baseline; font: 11px/1.45 var(--font-mono); color: var(--muted); }
.ac-msgs .dir { flex: 0 0 auto; }
.ac-msgs .dir.out { color: #a8d0ff; }
.ac-msgs .dir.in { color: var(--status-review); }
.ac-msgs .txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.ac-msgs .mst { font-size: 9.5px; text-transform: uppercase; letter-spacing: .04em; }
.ac-msgs .mst.unread { color: var(--muted); }
.ac-msgs .mst.delivered { color: #a8d0ff; }
.ac-msgs .mst.read, .ac-msgs .mst.acked { color: #adf2c7; }
.ac-msgs-head { font: 700 9.5px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
</style>
</head>
<body>
${appHeader(config.projectName, 'health')}
<main>
<section class="summary">
<h1>Agent Health</h1>
<span class="chip">hub <b>v${escapeHtml(data.health.version)}</b></span>
<span class="chip">uptime <b data-uptime>${data.health.uptimeSec}s</b></span>
<span class="chip" data-counts></span>
<span class="spacer"></span>
<span class="transport poll" id="transport" title="Page transport: SSE (durable replay via Last-Event-ID) or 3s polling fallback">
<span class="tdot"></span><span id="transportLabel">polling fallback</span>
</span>
<span class="chip" id="seqChip" title="Last durable event seq seen (TSK-0225)">seq <b></b></span>
</section>
<section class="agents" id="agents">
${renderAgentCardsSSR(data.health.agents)}
</section>
</main>
<script>
window.__HEALTH_DATA__ = ${embedJson(data)};
</script>
<script>
(function() {
var data = window.__HEALTH_DATA__ || { health: { agents: [], counts: {}, uptimeSec: 0 }, messages: [], architect: 'claude' };
var ARCHITECT = data.architect;
var MSG_COUNT = 5; // last N cross-messages per agent
var TRACK_TIMEOUT_MS = 90000; // test-message delivery timeout
var track = {}; // agent -> { id, sentAt, timedOut }
var lastSeq = null, eventCount = 0;
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function agoSec(iso) { var t = Date.parse(iso); if (isNaN(t)) return null; return Math.max(0, Math.floor((Date.now() - t) / 1000)); }
function compact(s) { if (s == null) return '—'; if (s < 60) return s + 's'; var m = Math.floor(s / 60); if (m < 60) return m + 'm'; var h = Math.floor(m / 60); if (h < 48) return h + 'h'; return Math.floor(h / 24) + 'd'; }
function snippet(text, max) { var c = String(text || '').replace(/\\s+/g, ' ').trim(); return c.length <= max ? c : c.slice(0, max - 1) + '…'; }
function setTransport(mode) {
var el = document.getElementById('transport');
var label = document.getElementById('transportLabel');
if (!el || !label) return;
el.classList.toggle('sse', mode === 'sse');
el.classList.toggle('poll', mode !== 'sse');
label.textContent = mode === 'sse' ? 'SSE live' : 'polling fallback';
}
function setSeq() {
var chip = document.getElementById('seqChip');
if (chip) chip.innerHTML = 'seq <b>' + (lastSeq == null ? '—' : esc(String(lastSeq))) + '</b> · ' + eventCount + ' events';
}
function agentMessages(agent) {
return data.messages
.filter(function(m) { return m.from === agent || m.to === agent; })
.slice(-MSG_COUNT)
.reverse();
}
function trackStatus(agent) {
var t = track[agent];
if (!t) return '';
var msg = null;
for (var i = data.messages.length - 1; i >= 0; i--) {
if (data.messages[i].id === t.id) { msg = data.messages[i]; break; }
}
var st = msg ? (msg.status || 'unread') : 'unread';
if (st === 'unread' && Date.now() - t.sentAt > TRACK_TIMEOUT_MS) {
t.timedOut = true;
return '<span class="t-timeout">✕ timeout — no delivery within ' + Math.round(TRACK_TIMEOUT_MS / 1000) + 's (agent not reachable)</span>';
}
var elapsed = compact(Math.floor((Date.now() - t.sentAt) / 1000));
if (st === 'unread') return '<span class="t-sent">● sent ' + esc(t.id) + ' — waiting for delivery… (' + elapsed + ')</span>';
if (st === 'delivered') return '<span class="t-delivered">● delivered ' + esc(t.id) + ' after ' + elapsed + ' — agent auto-woke, waiting for ack…</span>';
return '<span class="t-read">● ' + esc(st) + ' ' + esc(t.id) + ' after ' + elapsed + ' — round trip complete</span>';
}
function render() {
var h = data.health;
var counts = h.counts || {};
var countsEl = document.querySelector('[data-counts]');
if (countsEl) countsEl.innerHTML = 'tasks <b>' + (counts.tasks || 0) + '</b> · open <b>' + (counts.open || 0) + '</b> · in&nbsp;progress <b>' + (counts.inProgress || 0) + '</b> · review <b>' + (counts.review || 0) + '</b> · unread <b>' + (counts.unreadMessages || 0) + '</b>';
var upEl = document.querySelector('[data-uptime]');
if (upEl) upEl.textContent = compact(h.uptimeSec);
var wrap = document.getElementById('agents');
if (!wrap) return;
// Ensure a card exists per agent (roster can grow while the page is open).
(h.agents || []).forEach(function(a) {
if (!wrap.querySelector('.agent-card[data-agent="' + esc(a.name) + '"]')) {
var el = document.createElement('article');
el.className = 'agent-card';
el.setAttribute('data-agent', a.name);
el.innerHTML = '<header class="ac-head"><span class="ac-name"></span><span class="ac-role"></span><span class="light" data-light></span></header>' +
'<div class="ac-meta" data-meta></div>' +
'<div class="ac-test"><input class="ac-input" type="text" value="health-check ping" maxlength="200" aria-label="Test message" />' +
'<button class="ac-send" type="button">Send test</button></div>' +
'<div class="ac-track" data-track></div><ul class="ac-msgs" data-msgs></ul>';
el.querySelector('.ac-name').textContent = a.name;
el.querySelector('.ac-role').textContent = a.role;
wrap.appendChild(el);
}
});
(h.agents || []).forEach(function(a) {
var card = wrap.querySelector('.agent-card[data-agent="' + esc(a.name) + '"]');
if (!card) return;
var light = card.querySelector('[data-light]');
light.className = 'light light-' + a.state;
light.textContent = a.state;
var seen = a.lastSeenAgoSec != null ? 'last seen ' + compact(a.lastSeenAgoSec) + ' ago' : 'never seen';
var task = a.taskId ? ' · <a href="/tasks/' + esc(a.taskId) + '">' + esc(a.taskId) + '</a>' : '';
var loop = a.inLoop
? ' · in loop for ' + compact(a.loopSinceAgoSec || 0)
: a.loopExitReason
? ' · out of loop ' + compact(a.loopExitAgoSec || 0) + ' ago (' + esc(a.loopExitReason) + ')'
: ' · loop unknown';
// 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;
var msgs = agentMessages(a.name);
var ul = card.querySelector('[data-msgs]');
var html = msgs.length ? '<li class="ac-msgs-head"><span>cross-messaging (last ' + msgs.length + ')</span><span></span><span></span></li>' : '';
html += msgs.map(function(m) {
var out = m.from === a.name;
var other = out ? m.to : m.from;
var when = agoSec(m.createdAt || m.updatedAt);
return '<li><span class="dir ' + (out ? 'out' : 'in') + '">' + (out ? '→' : '←') + ' ' + esc(other) + '</span>' +
'<span class="txt" title="' + esc(m.text) + '">' + esc(snippet(m.text, 90)) + '</span>' +
'<span class="mst ' + esc(m.status || 'unread') + '">' + esc(m.status || 'unread') + ' · ' + compact(when) + '</span></li>';
}).join('');
if (ul.innerHTML !== html) ul.innerHTML = html;
});
}
async function refresh() {
try {
var hr = await fetch('/health', { headers: { accept: 'application/json' } });
if (hr.ok) data.health = await hr.json();
// Full list — side-effect-free. The ?agent= inbox variant flips
// unread→delivered as a read receipt and would fake delivery here.
var mr = await fetch('/messages', { headers: { accept: 'application/json' } });
if (mr.ok) data.messages = (await mr.json()).slice(-200);
render();
} catch (e) {
setTransport('poll');
}
}
// Test-message send → live delivery tracking (the auto-wake live proof).
document.addEventListener('click', async function(e) {
var btn = e.target.closest && e.target.closest('.ac-send');
if (!btn || btn.disabled) return;
var card = btn.closest('.agent-card');
var agent = card.getAttribute('data-agent');
var input = card.querySelector('.ac-input');
var text = (input.value || 'health-check ping').trim() || 'health-check ping';
btn.disabled = true;
try {
var res = await fetch('/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ from: 'health-check', to: agent, text: text + ' · ' + new Date().toISOString() }),
});
if (!res.ok) throw new Error('POST /messages failed: ' + res.status);
var msg = await res.json();
track[agent] = { id: msg.id, sentAt: Date.now(), timedOut: false };
await refresh();
} catch (err) {
var tr = card.querySelector('[data-track]');
if (tr) tr.innerHTML = '<span class="t-timeout">✕ send failed: ' + esc(err.message || String(err)) + '</span>';
} finally {
btn.disabled = false;
}
});
// Transport: SSE primary (browser EventSource resends Last-Event-ID on
// reconnect → durable replay from TSK-0225), 3s polling as the fallback.
if ('EventSource' in window) {
var es = new EventSource('/events');
es.onopen = function() { setTransport('sse'); };
es.onmessage = function(ev) {
eventCount++;
if (ev.lastEventId) lastSeq = ev.lastEventId;
setSeq();
refresh();
};
es.onerror = function() { setTransport('poll'); };
window.addEventListener('pagehide', function() { es.close(); });
} else {
setTransport('poll');
}
setTransport('poll');
setSeq();
render();
setInterval(refresh, 3000);
})();
</script>
${taskModalHtml()}
${newTaskModalJs()}
</body>
</html>`;
}

View File

@ -58,6 +58,14 @@ export function modalsHtml(): string {
<option value="critical">critical</option> <option value="critical">critical</option>
</select> </select>
</label> </label>
<label class="modal-field modal-field-inline">
<span class="modal-label">Already completed outside AgentHub</span>
<input id="tmExternal" type="checkbox" />
</label>
<label class="modal-field" id="tmExternalByWrap" hidden>
<span class="modal-label">Completed by</span>
<input id="tmExternalBy" type="text" placeholder="agent name" />
</label>
<p class="modal-note">Created unassigned the architect picks it up and delegates it.</p> <p class="modal-note">Created unassigned the architect picks it up and delegates it.</p>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="modal-cancel" id="tmCancel">Cancel</button> <button type="button" class="modal-cancel" id="tmCancel">Cancel</button>
@ -227,17 +235,15 @@ export function columnsJs(opts: ColumnsJsOpts): string {
var status = byStatus(t.status); var status = byStatus(t.status);
var isOpen = openConsoles[t.id] ? true : false; var isOpen = openConsoles[t.id] ? true : false;
var live = status === 'in_progress'; var live = status === 'in_progress';
// Console lives inside the card while a task is worked (in_progress = live) // Console toggle lives in the card; the console itself opens as a
// and stays available in review so you can see what the agent did. // dropdown OVERLAY on <body> (see openConsole) — it spans the full
// column width instead of being squeezed into the card grid cell.
var console = (live || status === 'review') var console = (live || status === 'review')
? '<div class="card-console-wrap">' + ? '<div class="card-console-wrap">' +
'<button class="card-console-toggle" type="button" data-console-toggle="' + esc(t.id) + '" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' + '<button class="card-console-toggle" type="button" data-console-toggle="' + esc(t.id) + '" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
(live ? '<span class="cc-dot" aria-hidden="true"></span>' : '') + (live ? 'live console' : 'agent console') + (live ? '<span class="cc-dot" aria-hidden="true"></span>' : '') + (live ? 'live console' : 'agent console') +
'<span class="cc-chevron" aria-hidden="true">' + (isOpen ? '\\u25be' : '\\u25b8') + '</span>' + '<span class="cc-chevron" aria-hidden="true">' + (isOpen ? '\\u25be' : '\\u25b8') + '</span>' +
'</button>' + '</button>' +
'<div class="card-console" data-console-for="' + esc(t.id) + '"' + (isOpen ? '' : ' hidden') + '>' +
'<div class="card-console-body" data-console-body="' + esc(t.id) + '"><div class="cc-empty">waiting for output…</div></div>' +
'</div>' +
'</div>' '</div>'
: ''; : '';
// Progress ring on in-progress (work time) AND review cards (wait time) — // Progress ring on in-progress (work time) AND review cards (wait time) —
@ -461,13 +467,74 @@ export function columnsJs(opts: ColumnsJsOpts): string {
body.insertAdjacentHTML('beforeend', consoleLineHtml(entry)); body.insertAdjacentHTML('beforeend', consoleLineHtml(entry));
body.scrollTop = body.scrollHeight; body.scrollTop = body.scrollHeight;
} }
// ── Console dropdown overlays ────────────────────────────────────────────
// The console is NOT rendered inside the card anymore (the card grid cell
// is far too narrow for log lines, and .card{overflow:hidden} would clip
// any absolute panel). Instead each open console gets ONE overlay panel on
// <body>, position:fixed, anchored under its card and spanning the full
// column width — readable logs regardless of how many cards share a row.
function consoleOverlay(id) {
return document.querySelector('.console-overlay[data-console-for="' + id + '"]');
}
function ensureConsoleOverlay(id) {
var ov = consoleOverlay(id);
if (ov) return ov;
ov = document.createElement('div');
ov.className = 'console-overlay';
ov.setAttribute('data-console-for', id);
ov.innerHTML =
'<div class="console-overlay-head">' +
'<span class="console-overlay-title">' + esc(id) + ' · console</span>' +
'<button class="console-overlay-close" type="button" data-console-close="' + esc(id) + '" aria-label="Close console">&times;</button>' +
'</div>' +
'<div class="card-console-body" data-console-body="' + esc(id) + '"><div class="cc-empty">waiting for output…</div></div>';
document.body.appendChild(ov);
return ov;
}
function positionConsoleOverlay(id) {
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
var ov = consoleOverlay(id);
if (!tog || !ov) return;
var card = tog.closest('.card');
var column = tog.closest('.column');
if (!card || !column) return;
var inset = 13; // column padding (12) + border (1)
var colRect = column.getBoundingClientRect();
var cardRect = card.getBoundingClientRect();
ov.style.left = Math.round(colRect.left + inset) + 'px';
ov.style.width = Math.round(colRect.width - inset * 2) + 'px';
ov.style.top = Math.round(cardRect.bottom + 6) + 'px';
}
function openConsole(id) {
ensureConsoleOverlay(id);
positionConsoleOverlay(id);
loadConsole(id);
}
function closeConsole(id) {
var ov = consoleOverlay(id);
if (ov) ov.remove();
}
// After every board re-render / scroll / resize: keep the open overlays
// glued to their (possibly moved) cards; close orphans whose card is gone.
function repositionConsoles() {
Object.keys(openConsoles).forEach(function(id) {
if (!openConsoles[id]) return;
if (!document.querySelector('[data-console-toggle="' + id + '"]')) {
openConsoles[id] = false;
closeConsole(id);
return;
}
ensureConsoleOverlay(id);
positionConsoleOverlay(id);
});
}
// Re-open any consoles that were expanded before a re-render (cards rebuild // Re-open any consoles that were expanded before a re-render (cards rebuild
// their innerHTML, so the panel state must be re-applied + reloaded). // their innerHTML, so the panel state must be re-applied + reloaded).
function reapplyConsoles() { function reapplyConsoles() {
repositionConsoles();
Object.keys(openConsoles).forEach(function(id) { Object.keys(openConsoles).forEach(function(id) {
if (!openConsoles[id]) return; if (openConsoles[id]) loadConsole(id);
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
if (panel) { panel.hidden = false; loadConsole(id); }
}); });
} }
function setConn(state, label) { function setConn(state, label) {
@ -563,6 +630,7 @@ export function columnsJs(opts: ColumnsJsOpts): string {
var data = null; var data = null;
try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {} try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {}
if (type === 'task') { refresh(); feedFromEvent(data); } if (type === 'task') { refresh(); feedFromEvent(data); }
if (type === 'agent' && window.refreshAgentHealth) window.refreshAgentHealth();
refreshBudget(); refreshBudget();
}; };
source.onerror = function() { source.onerror = function() {
@ -624,6 +692,11 @@ export function columnsJs(opts: ColumnsJsOpts): string {
if (!res.ok) throw new Error('create failed (' + res.status + ')'); if (!res.ok) throw new Error('create failed (' + res.status + ')');
return res.json(); return res.json();
} }
async function recordTask(body) {
var res = await fetch('/tasks/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('record failed (' + res.status + ')');
return res.json();
}
async function patchTask(id, body) { async function patchTask(id, body) {
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) { if (!res.ok) {
@ -633,6 +706,13 @@ export function columnsJs(opts: ColumnsJsOpts): string {
} }
return res.json(); return res.json();
} }
async function dispatchTask(id, agent) {
var res = await fetch('/tasks/' + encodeURIComponent(id) + '/dispatch', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent: agent })
});
if (!res.ok) throw new Error('dispatch failed (' + res.status + ')');
return res.json();
}
function flashCard(id) { function flashCard(id) {
var el = document.querySelector('.card[data-id="' + id + '"]'); var el = document.querySelector('.card[data-id="' + id + '"]');
if (!el) return; if (!el) return;
@ -661,8 +741,14 @@ export function columnsJs(opts: ColumnsJsOpts): string {
// in the title, else mark it as a manual board claim. // in the title, else mark it as a manual board claim.
var titledAgent = assigned ? '' : agentFromTitle(id); var titledAgent = assigned ? '' : agentFromTitle(id);
var agent = assigned || titledAgent || 'manual'; var agent = assigned || titledAgent || 'manual';
await patchTask(id, { status: 'in_progress', assignedTo: agent }); var rosterAgent = (AGENTS || []).find(function(a) { return a.name === agent; });
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : '')); if (rosterAgent && rosterAgent.dispatch === 'architect') {
await dispatchTask(id, agent);
toast(id + ' \\u2192 architect started @' + agent);
} else {
await patchTask(id, { status: 'in_progress', assignedTo: agent });
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : ''));
}
} else if (status === 'review') { } else if (status === 'review') {
var reviewed = await patchTask(id, { status: 'review' }); var reviewed = await patchTask(id, { status: 'review' });
toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : '')); toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : ''));
@ -809,24 +895,67 @@ export function columnsJs(opts: ColumnsJsOpts): string {
}).then(function(ok) { if (ok) deleteTask(id); }); }).then(function(ok) { if (ok) deleteTask(id); });
}); });
// Clicks/selection inside the console body must not navigate to the task page. // Clicks/selection inside the console overlay must not trigger card behavior.
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (e.target.closest && e.target.closest('.card-console')) e.preventDefault(); if (e.target.closest && e.target.closest('.console-overlay')) e.preventDefault();
}); });
// Live-console toggle lives inside the card <a> — stop the navigation. // Live-console toggle lives inside the card <a> — stop the navigation.
// Only ONE console overlay at a time: opening one closes any other.
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var tog = e.target.closest && e.target.closest('[data-console-toggle]'); var tog = e.target.closest && e.target.closest('[data-console-toggle]');
if (!tog) return; if (!tog) return;
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
var id = tog.getAttribute('data-console-toggle'); var id = tog.getAttribute('data-console-toggle');
var willOpen = !openConsoles[id]; var willOpen = !openConsoles[id];
if (willOpen) {
Object.keys(openConsoles).forEach(function(otherId) {
if (otherId === id || !openConsoles[otherId]) return;
openConsoles[otherId] = false;
var ot = document.querySelector('[data-console-toggle="' + otherId + '"]');
if (ot) {
ot.setAttribute('aria-expanded', 'false');
var oc = ot.querySelector('.cc-chevron'); if (oc) oc.textContent = '\\u25b8';
}
closeConsole(otherId);
});
}
openConsoles[id] = willOpen; openConsoles[id] = willOpen;
tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8'; var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8';
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]'); if (willOpen) openConsole(id);
if (panel) panel.hidden = !willOpen; else closeConsole(id);
if (willOpen) loadConsole(id);
}); });
// The overlay's own close button.
document.addEventListener('click', function(e) {
var btn = e.target.closest && e.target.closest('[data-console-close]');
if (!btn) return;
e.preventDefault(); e.stopPropagation();
var id = btn.getAttribute('data-console-close');
openConsoles[id] = false;
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
if (tog) {
tog.setAttribute('aria-expanded', 'false');
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = '\\u25b8';
}
closeConsole(id);
});
// Dropdown behavior: a pointerdown OUTSIDE overlay + toggle closes it.
document.addEventListener('pointerdown', function(e) {
if (e.target.closest && (e.target.closest('.console-overlay') || e.target.closest('[data-console-toggle]'))) return;
Object.keys(openConsoles).forEach(function(id) {
if (!openConsoles[id]) return;
openConsoles[id] = false;
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
if (tog) {
tog.setAttribute('aria-expanded', 'false');
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = '\\u25b8';
}
closeConsole(id);
});
});
// Keep overlays glued to their cards on scroll (any container) + resize.
document.addEventListener('scroll', repositionConsoles, true);
window.addEventListener('resize', repositionConsoles);
// ── Task-detail modal ────────────────────────────────────────────────── // ── Task-detail modal ──────────────────────────────────────────────────
(function() { (function() {
@ -845,6 +974,13 @@ export function columnsJs(opts: ColumnsJsOpts): string {
var form = document.getElementById('tmForm'); var form = document.getElementById('tmForm');
if (!btn || !modal || !form) return; if (!btn || !modal || !form) return;
var titleInput = document.getElementById('tmTitleInput'); var titleInput = document.getElementById('tmTitleInput');
var externalInput = document.getElementById('tmExternal');
var externalBy = document.getElementById('tmExternalBy');
var externalByWrap = document.getElementById('tmExternalByWrap');
externalInput.addEventListener('change', function() {
externalByWrap.hidden = !externalInput.checked;
externalBy.required = externalInput.checked;
});
function openModal() { function openModal() {
modal.hidden = false; modal.hidden = false;
btn.setAttribute('aria-expanded', 'true'); btn.setAttribute('aria-expanded', 'true');
@ -868,8 +1004,10 @@ export function columnsJs(opts: ColumnsJsOpts): string {
var priority = document.getElementById('tmPriority').value || undefined; var priority = document.getElementById('tmPriority').value || undefined;
try { try {
// No assignee/role: the task lands open in the architect's lap to route. // No assignee/role: the task lands open in the architect's lap to route.
var task = await postTask({ title: title, description: description, priority: priority }); var task = externalInput.checked
toast(task.id + ' created'); ? await recordTask({ title: title, description: description, priority: priority, doneBy: (externalBy.value || '').trim() })
: await postTask({ title: title, description: description, priority: priority });
toast(task.id + (externalInput.checked ? ' recorded as external work' : ' created'));
closeModal(); closeModal();
await refresh(); await refreshBudget(); flashCard(task.id); await refresh(); await refreshBudget(); flashCard(task.id);
} catch (err) { toast(err.message || 'create failed', { error: true }); } } catch (err) { toast(err.message || 'create failed', { error: true }); }

View File

@ -46,6 +46,13 @@ ${backlogSeries.toString()}
${areaPath.toString()} ${areaPath.toString()}
${laneChips.toString()} ${laneChips.toString()}
window.__b2UpdateKpis = function (tasks, agentColor) { window.__b2UpdateKpis = function (tasks, agentColor) {
// Compact durations keep chips short: 25m → 3h → 4d.
function fmtMin(m) {
if (m < 60) return m + 'm';
var h = Math.round(m / 60);
if (h < 48) return h + 'h';
return Math.round(h / 24) + 'd';
}
var kpiPrev = window.__b2KpiPrev || (window.__b2KpiPrev = {}); var kpiPrev = window.__b2KpiPrev || (window.__b2KpiPrev = {});
function flashKpi(card, value) { function flashKpi(card, value) {
if (kpiPrev[card] !== undefined && kpiPrev[card] !== value) { if (kpiPrev[card] !== undefined && kpiPrev[card] !== value) {
@ -82,7 +89,9 @@ window.__b2UpdateKpis = function (tasks, agentColor) {
el.innerHTML = capped.visible.map(function (c) { el.innerHTML = capped.visible.map(function (c) {
var color = agentColor ? agentColor(c.name) : '#a5b4fc'; var color = agentColor ? agentColor(c.name) : '#a5b4fc';
var initial = c.name.charAt(0).toUpperCase(); var initial = c.name.charAt(0).toUpperCase();
var time = c.minutes == null ? '' : (status === 'review' ? 'prüft ' : '') + c.minutes + 'm'; // No text prefix — keeps chips narrow enough for a single line (the
// card label already says REVIEW / IN PROGRESS).
var time = c.minutes == null ? '' : fmtMin(c.minutes);
var dot = status === 'in_progress' ? '<span class="b2-cdot"></span>' : ''; var dot = status === 'in_progress' ? '<span class="b2-cdot"></span>' : '';
return '<span class="b2-achip"><i style="background:' + color + '">' + initial + '</i>' + dot + time + '</span>'; return '<span class="b2-achip"><i style="background:' + color + '">' + initial + '</i>' + dot + time + '</span>';
}).join('') + (capped.hidden > 0 ? '<span class="b2-achip b2-more">+' + capped.hidden + '</span>' : ''); }).join('') + (capped.hidden > 0 ? '<span class="b2-achip b2-more">+' + capped.hidden + '</span>' : '');

View File

@ -22,6 +22,10 @@ export function sidebarHtml(): string {
</div> </div>
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div> <div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
</div> </div>
<div class="b2-panel b2-glass" id="b2AgentsPanel">
<h6>Agents</h6>
<div class="b2-agents" id="b2Agents"><div class="budget-empty">no agents yet</div></div>
</div>
<div class="b2-panel b2-glass"> <div class="b2-panel b2-glass">
<h6>Live</h6> <h6>Live</h6>
<div class="b2-feed" id="b2Feed"></div> <div class="b2-feed" id="b2Feed"></div>
@ -61,5 +65,35 @@ window.__b2FeedPush = function (html) {
feed.insertBefore(div, feed.firstChild); feed.insertBefore(div, feed.firstChild);
while (feed.children.length > 5) feed.removeChild(feed.lastChild); while (feed.children.length > 5) feed.removeChild(feed.lastChild);
}; };
// ── Agent health traffic lights (GET /health) ─────────────────────────────
// active (green) <2min since last action · busy (blue, on TSK-X) ·
// idle (gray) · stale/offline (red, >10min with an open assignment).
window.__b2RenderAgents = function (report) {
var box = document.getElementById('b2Agents');
if (!box) return;
var agents = report && report.agents ? report.agents : [];
if (!agents.length) { box.innerHTML = '<div class="budget-empty">no agents yet</div>'; return; }
box.innerHTML = agents.map(function (a) {
var loop = a.dispatch === 'architect'
? (a.taskId ? 'architect started' : a.waitingTaskId ? 'wartet auf Architekten-Start' : 'architect dispatch')
: a.inLoop
? 'loop ' + compact(a.loopSinceAgoSec || 0)
: a.loopExitReason
? 'out ' + compact(a.loopExitAgoSec || 0) + ' · ' + a.loopExitReason
: 'loop ?';
var work = a.taskId ? a.taskId : a.waitingTaskId ? a.waitingTaskId + ' waiting' : '';
var detail = loop + (work ? ' · ' + work : '');
return '<div class="b2-agent-row" title="' + esc(a.name) + ': ' + esc(detail) + '">' +
'<span class="b2-light st-' + esc(a.state) + '" aria-hidden="true"></span>' +
'<span class="b2-agent-name">' + esc(a.name) + '</span>' +
'<span class="b2-agent-state">' + esc(detail) + '</span></div>';
}).join('');
};
async function refreshAgentHealth() {
try { window.__b2RenderAgents(await getJSON('/health')); } catch (_) {}
}
window.refreshAgentHealth = refreshAgentHealth;
refreshAgentHealth();
setInterval(refreshAgentHealth, 5000);
${v1BudgetJs}`; ${v1BudgetJs}`;
} }

View File

@ -95,7 +95,7 @@ export function boardV2Css(): string {
.b2-val small { font-size: 13px; color: var(--b2-muted); font-weight: 500; } .b2-val small { font-size: 13px; color: var(--b2-muted); font-weight: 500; }
.b2-miniarea { margin-top: 8px; } .b2-miniarea { margin-top: 8px; }
.b2-spark { stroke-dasharray: 400; stroke-dashoffset: 400; animation: b2-draw 1.6s .3s forwards ease-out; } .b2-spark { stroke-dasharray: 400; stroke-dashoffset: 400; animation: b2-draw 1.6s .3s forwards ease-out; }
.b2-chips { display: flex; gap: 6px; margin-top: 10px; align-items: center; flex-wrap: wrap; min-height: 24px; } .b2-chips { display: flex; gap: 6px; margin-top: 10px; align-items: center; flex-wrap: nowrap; overflow: hidden; min-height: 24px; }
.b2-achip { .b2-achip {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex; align-items: center; gap: 5px;
font: 11px/1 var(--mono); padding: 3px 9px 3px 4px; font: 11px/1 var(--mono); padding: 3px 9px 3px 4px;
@ -215,9 +215,8 @@ export function boardV2Css(): string {
.card:hover .card-del, .card:focus-within .card-del { opacity: 1; } .card:hover .card-del, .card:focus-within .card-del { opacity: 1; }
.card-del:hover { color: #fca5a5; border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.12); } .card-del:hover { color: #fca5a5; border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.12); }
/* ── In-card live console (ported) ────────────────────────────────── */ /* ── Console dropdown overlay (full column width, floats above cards) ── */
.card-console-wrap { margin-top: 9px; } .card-console-wrap { margin-top: 9px; }
.card:has(.card-console:not([hidden])) { min-height: 230px; }
.card-console-toggle { display: inline-flex; align-items: center; gap: 6px; .card-console-toggle { display: inline-flex; align-items: center; gap: 6px;
background: transparent; border: 1px solid var(--b2-border); color: var(--b2-muted); background: transparent; border: 1px solid var(--b2-border); color: var(--b2-muted);
font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em; font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em;
@ -228,14 +227,27 @@ export function boardV2Css(): string {
box-shadow: 0 0 0 3px rgba(56,189,248,.16); animation: ccPulse 1.6s ease-in-out infinite; } box-shadow: 0 0 0 3px rgba(56,189,248,.16); animation: ccPulse 1.6s ease-in-out infinite; }
.card-console-toggle .cc-chevron { font-size: 9px; opacity: .75; } .card-console-toggle .cc-chevron { font-size: 9px; opacity: .75; }
@keyframes ccPulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } } @keyframes ccPulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
.card-console { margin-top: 7px; } /* The panel itself lives on <body> (position:fixed, JS-anchored under its
.card-console[hidden] { display: none; } card) so it escapes .card{overflow:hidden} and the narrow grid cell. */
.card-console-body { max-height: 180px; overflow-y: auto; background: rgba(0,0,0,.28); .console-overlay { position: fixed; z-index: 70; background: var(--b2-raised);
border: 1px solid var(--b2-border); border-radius: 10px; padding: 8px 10px 10px;
box-shadow: 0 18px 44px rgba(2,6,18,.55); animation: consoleDrop 160ms ease-out; }
@keyframes consoleDrop { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.console-overlay-head { display: flex; align-items: center; justify-content: space-between; gap: 10px;
margin-bottom: 6px; font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--b2-muted); }
.console-overlay-close { border: 1px solid var(--b2-border); background: transparent; color: var(--b2-muted);
width: 18px; height: 18px; border-radius: 50%; font: 12px/1 var(--mono); cursor: pointer; display: inline-grid; place-items: center; }
.console-overlay-close:hover { color: var(--text); border-color: var(--accent); }
.card-console-body { max-height: 260px; overflow-y: auto; background: rgba(0,0,0,.28);
border: 1px solid var(--b2-border); border-radius: 8px; padding: 8px 10px; border: 1px solid var(--b2-border); border-radius: 8px; padding: 8px 10px;
font: 11px/1.55 var(--mono); color: #cbd5e1; } font: 11px/1.55 var(--mono); color: #cbd5e1; }
.cc-line { display: flex; gap: 8px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 1px 0; } .cc-line { display: flex; gap: 8px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 1px 0; }
.cc-line .cc-ts { color: var(--b2-muted); opacity: .8; flex: 0 0 auto; } .cc-line .cc-ts { color: var(--b2-muted); opacity: .8; flex: 0 0 auto; }
.cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; } .cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; }
/* flex:1 + min-width:0 the text must claim the line's remaining width and
wrap inside it. Without this, overflow-wrap:anywhere collapses the flex
item to its min-content width (~1 char) and the log renders vertically. */
.cc-line .cc-text { flex: 1 1 auto; min-width: 0; }
.cc-line.level-error .cc-text { color: #fca5a5; } .cc-line.level-error .cc-text { color: #fca5a5; }
.cc-line.level-warn .cc-text { color: #fcd34d; } .cc-line.level-warn .cc-text { color: #fcd34d; }
.cc-line.level-bridge .cc-text { color: #c4b5fd; } .cc-line.level-bridge .cc-text { color: #c4b5fd; }
@ -323,6 +335,17 @@ export function boardV2Css(): string {
.b2-feed .pulse { color: var(--b2-green); animation: b2-blink 1.6s infinite; } .b2-feed .pulse { color: var(--b2-green); animation: b2-blink 1.6s infinite; }
.b2-feed .f-time { color: var(--b2-dim); font-size: 10px; margin-left: auto; flex: 0 0 auto; } .b2-feed .f-time { color: var(--b2-dim); font-size: 10px; margin-left: auto; flex: 0 0 auto; }
/* ── Agent health lights ──────────────────────────────────────────── */
.b2-agents { margin-top: 8px; display: grid; gap: 2px; }
.b2-agent-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font: 12px/1.3 var(--mono); }
.b2-light { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; background: #94a3b8; }
.b2-light.st-active { background: var(--b2-green); box-shadow: 0 0 0 3px rgba(52,211,153,.16); }
.b2-light.st-busy { background: var(--accent); box-shadow: 0 0 0 3px rgba(56,189,248,.16); }
.b2-light.st-idle { background: #94a3b8; }
.b2-light.st-stale { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,.16); }
.b2-agent-name { color: var(--text); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.b2-agent-state { margin-left: auto; color: var(--b2-muted); font-size: 10.5px; white-space: nowrap; }
/* ── Modals (ported v1) ───────────────────────────────────────────── */ /* ── Modals (ported v1) ───────────────────────────────────────────── */
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start; .modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start;
justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62); justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62);

73
src/server/eventLog.ts Normal file
View File

@ -0,0 +1,73 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { getIndexPath } from '../core/paths.js';
import type { AgentHubEvent } from './events.js';
export interface LoggedAgentHubEvent extends AgentHubEvent {
seq: number;
}
let cachedPath: string | undefined;
let cachedDb: Database.Database | undefined;
function dbFor(cwd: string): Database.Database {
const path = getIndexPath(cwd);
if (cachedDb && cachedPath === path) return cachedDb;
cachedDb?.close();
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path);
db.exec(`
CREATE TABLE IF NOT EXISTS hub_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
createdAt TEXT NOT NULL,
eventJson TEXT NOT NULL
);
`);
cachedPath = path;
cachedDb = db;
return db;
}
export function appendHubEvent(cwd: string, event: AgentHubEvent): LoggedAgentHubEvent {
const eventJson = JSON.stringify({ ...event, seq: undefined });
const info = dbFor(cwd)
.prepare('INSERT INTO hub_events (createdAt, eventJson) VALUES (@createdAt, @eventJson)')
.run({ createdAt: new Date().toISOString(), eventJson });
return { ...event, seq: Number(info.lastInsertRowid) };
}
export function listHubEventsAfter(cwd: string, afterSeq: number, limit = 1000): LoggedAgentHubEvent[] {
const rows = dbFor(cwd)
.prepare('SELECT seq, eventJson FROM hub_events WHERE seq > @afterSeq ORDER BY seq ASC LIMIT @limit')
.all({ afterSeq, limit }) as Array<{ seq: number; eventJson: string }>;
const events: LoggedAgentHubEvent[] = [];
for (const row of rows) {
try {
events.push({ ...(JSON.parse(row.eventJson) as AgentHubEvent), seq: row.seq });
} catch {
// Ignore corrupt historical rows; new appends always write valid JSON.
}
}
return events;
}
export function countHubEventsAfter(cwd: string, afterSeq: number): number {
const row = dbFor(cwd)
.prepare('SELECT COUNT(*) AS count FROM hub_events WHERE seq > ?')
.get(afterSeq) as { count: number };
return row.count;
}
export function latestHubEventSeq(cwd: string): number {
const row = dbFor(cwd)
.prepare('SELECT COALESCE(MAX(seq), 0) AS seq FROM hub_events')
.get() as { seq: number };
return row.seq;
}
export function closeHubEventLogForTests(): void {
cachedDb?.close();
cachedDb = undefined;
cachedPath = undefined;
}

View File

@ -1,9 +1,12 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { appendHubEvent } from './eventLog.js';
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'ask' | 'agent'; export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'ask' | 'agent';
export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left'; export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left';
export interface AgentHubEvent { export interface AgentHubEvent {
/** Monotonic durable sequence id for replayable hub-change events. */
seq?: number;
type: AgentHubEventType; type: AgentHubEventType;
action: AgentHubEventAction; action: AgentHubEventAction;
/** Entity id, or — for `agent` presence events — the agent name. */ /** Entity id, or — for `agent` presence events — the agent name. */
@ -12,6 +15,8 @@ export interface AgentHubEvent {
status?: string; status?: string;
role?: string; role?: string;
assignedTo?: string; assignedTo?: string;
/** Absender bei message-Events — erlaubt Empfängern, System-Post zu filtern. */
from?: string;
claimedBy?: string; claimedBy?: string;
reviewer?: string; reviewer?: string;
} }
@ -60,6 +65,11 @@ eventBus.setMaxListeners(0);
// signature; the other path sees it via `seenRecently()` and stays silent. // signature; the other path sees it via `seenRecently()` and stays silent.
const DEDUP_TTL_MS = 15_000; const DEDUP_TTL_MS = 15_000;
const recentlyEmitted = new Map<string, number>(); const recentlyEmitted = new Map<string, number>();
let durableEventCwd: string | undefined;
export function configureDurableEvents(cwd: string): void {
durableEventCwd = cwd;
}
/** Stable key for a single logical mutation of one entity revision. */ /** Stable key for a single logical mutation of one entity revision. */
export function signatureOf(type: string, id: string, stamp: string | undefined): string { export function signatureOf(type: string, id: string, stamp: string | undefined): string {
@ -96,5 +106,13 @@ export function seenRecently(signature: string): boolean {
*/ */
export function emitChange(event: AgentHubEvent, stamp: string | undefined): void { export function emitChange(event: AgentHubEvent, stamp: string | undefined): void {
markEmitted(signatureOf(event.type, event.id, stamp)); markEmitted(signatureOf(event.type, event.id, stamp));
if (durableEventCwd) {
try {
eventBus.publish(appendHubEvent(durableEventCwd, event));
} catch {
eventBus.publish(event);
}
return;
}
eventBus.publish(event); eventBus.publish(event);
} }

View File

@ -4,6 +4,9 @@ import { readEntity } from '../core/files.js';
import { getEntityDir, type EntityType } from '../core/paths.js'; import { getEntityDir, type EntityType } from '../core/paths.js';
import { emitChange, seenRecently, signatureOf } from './events.js'; import { emitChange, seenRecently, signatureOf } from './events.js';
import type { AgentHubEvent, AgentHubEventType } from './events.js'; import type { AgentHubEvent, AgentHubEventType } from './events.js';
import { TaskSchema } from '../core/schema.js';
import { toIndexEntry } from '../core/services/taskService.js';
import { Index } from '../core/index.js';
/** /**
* Filesystem-watch layer. * Filesystem-watch layer.
@ -32,6 +35,17 @@ const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [
// fs.watch can fire several events (rename + change) for a single write, and a // fs.watch can fire several events (rename + change) for a single write, and a
// file may be observed mid-write. Coalesce per-path bursts before reading. // file may be observed mid-write. Coalesce per-path bursts before reading.
const DEBOUNCE_MS = 40; const DEBOUNCE_MS = 40;
const RETRY_MS = 100;
const MAX_READ_ATTEMPTS = 3;
const indexErrors = new Map<string, { filePath: string; error: string; at: string }>();
export function getFsWatchErrors(): Array<{ filePath: string; error: string; at: string }> {
return [...indexErrors.values()];
}
export function resetFsWatchErrors(): void {
indexErrors.clear();
}
function str(value: unknown): string | undefined { function str(value: unknown): string | undefined {
return value === undefined || value === null ? undefined : String(value); return value === undefined || value === null ? undefined : String(value);
@ -104,13 +118,15 @@ export function startEntityWatcher(cwd: string): () => void {
const key = `${type}:${name}`; const key = `${type}:${name}`;
const existing = timers.get(key); const existing = timers.get(key);
if (existing) clearTimeout(existing); if (existing) clearTimeout(existing);
timers.set( const filePath = getEntityDir(cwd, dir) + '/' + name;
key, const schedule = (attempt: number, delay: number) => {
setTimeout(() => { timers.set(key, setTimeout(() => {
timers.delete(key); timers.delete(key);
processFile(getEntityDir(cwd, dir) + '/' + name, type); const error = processFile(cwd, filePath, type);
}, DEBOUNCE_MS), if (error && attempt < MAX_READ_ATTEMPTS) schedule(attempt + 1, RETRY_MS);
); }, delay));
};
schedule(1, DEBOUNCE_MS);
}); });
watchers.push(watcher); watchers.push(watcher);
} catch { } catch {
@ -126,13 +142,26 @@ export function startEntityWatcher(cwd: string): () => void {
}; };
} }
function processFile(filePath: string, type: AgentHubEventType): void { function processFile(cwd: string, filePath: string, type: AgentHubEventType): Error | undefined {
let fm: Record<string, unknown>; let fm: Record<string, unknown>;
try { try {
fm = readEntity(filePath).frontmatter; const entity = readEntity(filePath);
} catch { fm = entity.frontmatter;
// Deleted again, or read mid-write -> skip; a later settled write re-fires. if (type === 'task') {
return; const task = TaskSchema.parse(fm);
const index = new Index(cwd);
try {
index.upsert(toIndexEntry(task, filePath));
} finally {
index.close();
}
}
indexErrors.delete(filePath);
} catch (cause) {
const error = cause instanceof Error ? cause : new Error(String(cause));
indexErrors.set(filePath, { filePath, error: error.message, at: new Date().toISOString() });
console.error(`AgentHub fsWatch: could not reindex ${filePath}: ${error.message}`);
return error;
} }
const built = toEvent(type, fm); const built = toEvent(type, fm);

View File

@ -4,6 +4,7 @@ import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js'
import { startMdnsAdvertise, type MdnsHandle } from './mdns.js'; import { startMdnsAdvertise, type MdnsHandle } from './mdns.js';
import { startEntityWatcher } from './fsWatch.js'; import { startEntityWatcher } from './fsWatch.js';
import { startStatusAutoRefresh } from './statusRefresh.js'; import { startStatusAutoRefresh } from './statusRefresh.js';
import { startWatchdog } from './watchdog.js';
export function buildApp(cwd: string) { export function buildApp(cwd: string) {
const app = Fastify({ logger: false }); const app = Fastify({ logger: false });
@ -32,11 +33,14 @@ export async function startServer(cwd: string, options: { port?: number; host?:
const stopWatcher = startEntityWatcher(cwd); const stopWatcher = startEntityWatcher(cwd);
// Keep status/latest.md fresh on every change so agents never read a stale snapshot. // Keep status/latest.md fresh on every change so agents never read a stale snapshot.
const stopStatusRefresh = startStatusAutoRefresh(cwd); const stopStatusRefresh = startStatusAutoRefresh(cwd);
// Stuck-task watchdog: re-notify unclaimed priority tasks, flag silent ones.
const stopWatchdog = startWatchdog(cwd);
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
broadcaster?.stop(); broadcaster?.stop();
mdns?.stop(); mdns?.stop();
stopWatcher(); stopWatcher();
stopStatusRefresh(); stopStatusRefresh();
stopWatchdog();
}); });
try { try {

View File

@ -1,5 +1,5 @@
import { FastifyInstance, FastifyReply } from 'fastify'; import { FastifyInstance, FastifyReply } from 'fastify';
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask, deleteTask } from '../core/services/taskService.js'; import { createTask, recordExternalTask, dispatchTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask, deleteTask } from '../core/services/taskService.js';
import { getTaskActivity } from '../core/services/activityService.js'; import { getTaskActivity } from '../core/services/activityService.js';
import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js'; import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
@ -11,20 +11,27 @@ import { getStatus, updateStatus } from '../core/services/statusService.js';
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
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, DORMANT_AFTER_MS } from '../core/services/presenceService.js';
import { resolvePending, hasPending } from '../core/services/checkinService.js';
import { setPreferredAgent } from '../core/services/roleService.js';
import { loadConfig, saveConfig } from '../core/config.js'; import { loadConfig, saveConfig } from '../core/config.js';
import { agentIdentity } from '../core/services/identityService.js';
import { renderActivityHtml } from './activity.js'; import { renderActivityHtml } from './activity.js';
import { renderAgentHealthHtml } from './agentHealth.js';
import { renderBoardHtml } from './board/index.js'; import { renderBoardHtml } from './board/index.js';
import { renderTeamHtml } from './team.js'; import { renderTeamHtml } from './team.js';
import { renderArchiveHtml } from './archive.js'; import { renderArchiveHtml } from './archive.js';
import { renderDecisionsHtml } from './decisions.js'; import { renderDecisionsHtml } from './decisions.js';
import { renderMessagesHtml } from './messages.js'; import { renderMessagesHtml } from './messages.js';
import { renderTaskDetailHtml } from './taskDetail.js'; import { renderTaskDetailHtml } from './taskDetail.js';
import { eventBus, emitChange } from './events.js'; import { configureDurableEvents, eventBus, emitChange } from './events.js';
import type { AgentHubEvent } from './events.js'; import type { AgentHubEvent } from './events.js';
import { countHubEventsAfter, latestHubEventSeq, listHubEventsAfter } from './eventLog.js';
import type { Task, Handoff, Decision, Memory, Message, Ask } from '../core/schema.js'; import type { Task, Handoff, Decision, Memory, Message, Ask } from '../core/schema.js';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { join, extname, normalize } from 'node:path'; import { join, extname, normalize } from 'node:path';
import { getFsWatchErrors } from './fsWatch.js';
const SSE_KEEPALIVE_MS = 10_000; const SSE_KEEPALIVE_MS = 10_000;
@ -46,6 +53,10 @@ function wantsHtml(request: { headers: { accept?: string } }): boolean {
} }
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> { export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
configureDurableEvents(cwd);
// Uptime anchor for /health (buildApp ≈ server start).
const startedAtMs = Date.now();
// Default dynamic responses to no-store so board reloads/fetches never reuse // Default dynamic responses to no-store so board reloads/fetches never reuse
// stale task JSON or HTML. Static asset routes override this with cacheable // stale task JSON or HTML. Static asset routes override this with cacheable
// headers, and the SSE route writes its own raw no-cache header. // headers, and the SSE route writes its own raw no-cache header.
@ -53,9 +64,130 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
reply.header('Cache-Control', 'no-store, must-revalidate'); reply.header('Cache-Control', 'no-store, must-revalidate');
}); });
// Hub health: status + version + uptime + compact counts + per-agent lights.
app.get('/health', async () => ({
...computeHealth(cwd, startedAtMs),
indexErrors: getFsWatchErrors(),
}));
app.get('/roles', async () => loadConfig(cwd).roles);
app.patch('/roles/:role', async (request, reply) => {
const { role } = request.params as { role: string };
const { agent } = request.body as { agent?: string };
if (!agent) return badRequest(reply, 'agent is required');
try {
return setPreferredAgent(cwd, role, agent);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : String(err));
}
});
app.get('/architect/pulse', async (request) => {
const query = request.query as { sinceSeq?: string; since?: string };
const rawSince = query.sinceSeq ?? query.since;
const explicitSince = rawSince !== undefined;
const sinceSeq = Math.max(0, Number.parseInt(rawSince ?? '0', 10) || 0);
const config = loadConfig(cwd);
const architect = config.roles.architect?.preferredAgent ?? 'claude';
const health = computeHealth(cwd, startedAtMs);
const eventLimit = 5;
const eventBase = explicitSince ? sinceSeq : Math.max(0, latestHubEventSeq(cwd) - eventLimit);
const rawEvents = listHubEventsAfter(cwd, eventBase, eventLimit);
const events = rawEvents.map(({ seq, type, action, id, status, assignedTo }) => ({
seq, type, action, id, status, assignedTo,
}));
const nextSeq = rawEvents.length ? rawEvents[rawEvents.length - 1].seq : latestHubEventSeq(cwd);
const availableEvents = countHubEventsAfter(cwd, explicitSince ? sinceSeq : 0);
const eventsOmitted = Math.max(0, availableEvents - rawEvents.length);
const allMessages = listMessages(cwd)
.filter((message) => message.to === architect && message.status === 'unread');
const messageLimit = 5;
return {
at: new Date().toISOString(),
nextSeq,
reviews: listTasks(cwd, { status: 'review' }).map((task) => ({
id: task.id,
assignedTo: task.assignedTo,
updatedAt: task.updatedAt,
})),
// Zwei verschiedene Zustände, die nicht gleich behandelt werden dürfen:
//
// a) `loopExitReason` gesetzt → der Agent hat den Loop AUSDRÜCKLICH
// verlassen ("turn ended"). Er hört ab sofort nicht mehr zu; ein
// Reopen erreicht ihn nie. Sofort melden, ohne Wartezeit.
// b) kein Exit-Grund, aber ein Task in Arbeit → der Agent FÜHRT gerade
// aus. `inLoop` ist dabei immer false; ohne Zeitbedingung wäre jeder
// hart arbeitende Agent "dormant" und der Architekt würde Leute
// anstoßen, die einwandfrei laufen. Erst nach DORMANT_AFTER_MS ohne
// Check-in ist das ein Fall für mich.
dormantAgents: health.agents
.filter((agent) => {
if (agent.inLoop) return false;
if (agent.loopExitReason) return true;
if (!agent.taskId) return false;
return agent.lastSeenAgoSec === undefined || agent.lastSeenAgoSec * 1000 >= DORMANT_AFTER_MS;
})
.map((agent) => ({
name: agent.name,
taskId: agent.taskId,
lastSeenAgoSec: agent.lastSeenAgoSec,
loopExitAgoSec: agent.loopExitAgoSec,
loopExitReason: agent.loopExitReason,
})),
// Der Fall, der den Menschen sonst zum Postman macht: eine Task ist an
// einen Agenten adressiert, aber er sitzt nicht im Work-Loop. Er ist dann
// nicht "dormant" (er lebt vielleicht sogar), hoert aber nicht zu — die
// Arbeit bleibt liegen, ohne dass irgendwo ein Fehler erscheint. Real
// erlebt: codex war `active` (75s zuvor gesehen) mit inLoop=false,
// waehrend TSK-0288 offen an ihn adressiert dalag.
notListening: health.agents
.filter((agent) => !agent.inLoop && agent.pendingCount > 0)
.map((agent) => ({
name: agent.name,
waitingTasks: agent.pendingCount,
taskId: agent.waitingTaskId,
lastSeenAgoSec: agent.lastSeenAgoSec,
hint: `${agent.name} hat offene Arbeit, sitzt aber nicht im Work-Loop — anstossen oder Session neu starten.`,
})),
messages: allMessages.slice(0, messageLimit).map(({ id, from, taskId, createdAt }) => ({
id, from, taskId, createdAt,
})),
events,
omitted: {
messages: Math.max(0, allMessages.length - messageLimit),
events: eventsOmitted,
},
};
});
app.get('/agents/:agent/identity', async (request, reply) => {
try {
const identity = agentIdentity(cwd, (request.params as { agent: string }).agent);
// workTimeoutSec gehoert fachlich zur Agenten-Identitaet: "wer bin ich und
// wie lange darf ich blockieren". Der MCP-Server holt es sich hier, statt
// dass jeder Client es raten muss.
let workTimeoutSec: number | undefined;
try {
workTimeoutSec = loadConfig(cwd).agents?.[identity.canonical]?.workTimeoutSec;
} catch { /* ohne Config bleibt der Default */ }
return { canonical: identity.canonical, names: [...identity.names], workTimeoutSec };
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : String(err));
}
});
// Agent health-check page (TSK-0230): reachability traffic light per agent
// (same /health data), test-message send with live delivery tracking,
// cross-messaging view, and the page's own transport mode (SSE vs polling).
app.get('/agent-health', async (_request, reply) =>
reply.type('text/html; charset=utf-8').send(renderAgentHealthHtml(cwd, startedAtMs)),
);
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and // Static, self-contained Trello-like board. Polls /tasks, /handoffs and
// /decisions on the same origin; no build step, no deps. Cached once — the // /decisions on the same origin; no build step, no deps. Cached once — the
// markup is constant, only the data it fetches changes. // markup is constant, only the data it fetches changes.
// Wurzel-URL auf das Board leiten. Ohne das antwortet http://<host>:3377/
// mit 404 — wer "laeuft der Hub?" pruefen will, oeffnet aber genau diese
// Adresse und haelt den Hub faelschlich fuer tot (real passiert).
app.get('/', async (_request, reply) => reply.redirect('/board', 302));
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName); const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml)); app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
@ -121,6 +253,12 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// (events.ts) ensures each change is delivered exactly once. // (events.ts) ensures each change is delivered exactly once.
app.get('/events', async (request, reply) => { app.get('/events', async (request, reply) => {
const { role } = request.query as { role?: string }; const { role } = request.query as { role?: string };
const lastEventIdHeader = request.headers['last-event-id'];
const lastEventId =
typeof lastEventIdHeader === 'string'
? Number.parseInt(lastEventIdHeader, 10)
: Number.parseInt((request.query as { lastEventId?: string }).lastEventId ?? '', 10);
const replayAfterSeq = Number.isFinite(lastEventId) && lastEventId >= 0 ? lastEventId : undefined;
// Take full control of the raw response so Fastify doesn't interfere. // Take full control of the raw response so Fastify doesn't interfere.
reply.hijack(); reply.hijack();
@ -152,17 +290,26 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
} }
}; };
const listener = (event: AgentHubEvent) => { const writeEvent = (event: AgentHubEvent) => {
// Server-side role filter: skip tasks that belong to a different role. // Server-side role filter: skip tasks that belong to a different role.
// Handoffs, decisions and memory always pass through. // Handoffs, decisions and memory always pass through.
if (role && event.type === 'task' && event.role !== undefined && event.role !== role) { if (role && event.type === 'task' && event.role !== undefined && event.role !== role) {
return; return;
} }
writeSse(`data: ${JSON.stringify(event)}\n\n`); const idLine = event.seq !== undefined ? `id: ${event.seq}\n` : '';
writeSse(`${idLine}data: ${JSON.stringify(event)}\n\n`);
}; };
const listener = (event: AgentHubEvent) => writeEvent(event);
eventBus.on('change', listener); eventBus.on('change', listener);
if (replayAfterSeq !== undefined) {
for (const event of listHubEventsAfter(cwd, replayAfterSeq)) {
writeEvent(event);
}
}
// Task-log lines ride a NAMED `task-log` SSE event so the board's generic // Task-log lines ride a NAMED `task-log` SSE event so the board's generic
// onmessage handler ignores them; only the task-detail live console listens. // onmessage handler ignores them; only the task-detail live console listens.
const logListener = (payload: unknown) => { const logListener = (payload: unknown) => {
@ -188,6 +335,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
app.post('/announce', async (request, reply) => { app.post('/announce', async (request, reply) => {
const { agent, role, action } = request.body as { agent?: string; role?: string; action?: string }; const { agent, role, action } = request.body as { agent?: string; role?: string; action?: string };
if (!agent) return badRequest(reply, 'agent is required'); if (!agent) return badRequest(reply, 'agent is required');
stampSeen(agent);
const ev: AgentHubEvent = { const ev: AgentHubEvent = {
type: 'agent', type: 'agent',
action: action === 'left' ? 'left' : 'joined', action: action === 'left' ? 'left' : 'joined',
@ -197,6 +345,24 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
eventBus.publish(ev); eventBus.publish(ev);
return { ok: true, agent, action: ev.action }; return { ok: true, agent, action: ev.action };
}); });
app.post('/agents/:agent/loop', async (request, reply) => {
const agent = (request.params as { agent: string }).agent;
const { active, reason } = request.body as { active?: boolean; reason?: string };
try {
const canonical = agentIdentity(cwd, agent).canonical;
if (active) enterLoop(canonical);
else leaveLoop(canonical, reason ?? 'ended');
eventBus.publish({
type: 'agent',
action: active ? 'joined' : 'left',
id: canonical,
status: active ? 'in_loop' : `out_of_loop:${reason ?? 'ended'}`,
});
return { ok: true, agent: canonical, active: !!active, reason };
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : String(err));
}
});
// ─── Status ────────────────────────────────────────────────────────────── // ─── Status ──────────────────────────────────────────────────────────────
app.get('/status', async () => ({ body: getStatus(cwd) })); app.get('/status', async () => ({ body: getStatus(cwd) }));
@ -263,6 +429,16 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
); );
return task; return task;
}); });
app.post('/tasks/record', async (request, reply) => {
try {
const task = recordExternalTask(cwd, request.body as Parameters<typeof recordExternalTask>[1]);
logTaskStatus(task.id, `Nachgetragen: außerhalb von AgentHub erledigt durch ${task.doneBy}`, task.doneBy);
emitChange({ type: 'task', action: 'created', id: task.id, title: task.title, status: task.status, role: task.role, assignedTo: task.assignedTo, claimedBy: task.claimedBy }, task.updatedAt);
return task;
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Could not record external work');
}
});
app.delete('/tasks/:id', async (request, reply) => { app.delete('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
@ -314,8 +490,40 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
} catch (err) { } catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid log line'); return badRequest(reply, err instanceof Error ? err.message : 'Invalid log line');
} }
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) => {
const { id } = request.params as { id: string };
const { agent } = request.body as { agent?: string };
try {
const task = dispatchTask(cwd, id, agent ?? '');
logTaskStatus(id, `Architect started ${task.claimedBy}`, 'architect');
emitChange({ type: 'task', action: 'updated', id, title: task.title, status: task.status, role: task.role, assignedTo: task.assignedTo, claimedBy: task.claimedBy }, task.updatedAt);
return task;
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Dispatch failed');
}
}); });
app.patch('/tasks/:id', async (request, reply) => { app.patch('/tasks/:id', async (request, reply) => {
@ -325,7 +533,12 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// Assign without claiming: address an open task to an agent (no status // Assign without claiming: address an open task to an agent (no status
// change). Fires task/updated so a waiting `agenthub work` auto-claims it. // change). Fires task/updated so a waiting `agenthub work` auto-claims it.
if (patch.assignedTo !== undefined && patch.status === undefined) { if (patch.assignedTo !== undefined && patch.status === undefined) {
const assigned = assignTask(cwd, id, patch.assignedTo); let assigned: Task;
try {
assigned = assignTask(cwd, id, patch.assignedTo);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid assignee');
}
logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo); logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo);
emitChange( emitChange(
{ {
@ -345,7 +558,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
} }
let task: Task; let task: Task;
switch (patch.status) { try {
switch (patch.status) {
case 'in_progress': case 'in_progress':
{ {
const current = getTask(cwd, id).task; const current = getTask(cwd, id).task;
@ -353,11 +567,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// claimTask is race-guarded (open-only). Surface a lost race / non-open // claimTask is race-guarded (open-only). Surface a lost race / non-open
// claim as a clean 400 so the board drag reverts gracefully instead of // claim as a clean 400 so the board drag reverts gracefully instead of
// 500-ing, and a second agent can't clobber the first's claim. // 500-ing, and a second agent can't clobber the first's claim.
try { task = claimTask(cwd, id, agent);
task = claimTask(cwd, id, agent); stampSeen(agent);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Cannot claim task');
}
} }
break; break;
case 'done': case 'done':
@ -366,9 +577,12 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
doneTokens: patch.doneTokens as number | undefined, doneTokens: patch.doneTokens as number | undefined,
doneDuration: patch.doneDuration as number | undefined, doneDuration: patch.doneDuration as number | undefined,
}); });
if (task.doneBy) stampSeen(task.doneBy);
break; break;
case 'review': case 'review':
task = reviewTask(cwd, id, patch.reviewer); task = reviewTask(cwd, id, patch.reviewer);
// The submitter (assignee) is the acting agent here.
if (task.assignedTo) stampSeen(task.assignedTo);
break; break;
case 'cancelled': case 'cancelled':
task = cancelTask(cwd, id); task = cancelTask(cwd, id);
@ -376,8 +590,13 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
case 'open': case 'open':
task = reopenTask(cwd, id); task = reopenTask(cwd, id);
break; break;
default: default:
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled'); return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Invalid task transition';
logTaskStatus(id, `Rejected transition${patch.status ? `${patch.status}` : ''}: ${message}`, patch.assignedTo);
return badRequest(reply, message);
} }
// Uniformly log the transition for every status-changing caller (claim / // Uniformly log the transition for every status-changing caller (claim /
@ -487,6 +706,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
} catch (err) { } catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid message'); return badRequest(reply, err instanceof Error ? err.message : 'Invalid message');
} }
stampSeen(message.from);
emitChange( emitChange(
{ {
type: 'message', type: 'message',
@ -494,6 +714,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
id: message.id, id: message.id,
title: `${message.from}${message.to}`, title: `${message.from}${message.to}`,
assignedTo: message.to, assignedTo: message.to,
from: message.from,
}, },
message.updatedAt, message.updatedAt,
); );

View File

@ -190,7 +190,7 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
.log-line { display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere; } .log-line { display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere; }
.log-ts { color:var(--muted);white-space:nowrap;flex:0 0 auto; } .log-ts { color:var(--muted);white-space:nowrap;flex:0 0 auto; }
.log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; } .log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; }
.log-text { color:var(--text);min-width:0; } .log-text { color:var(--text);flex:1 1 auto;min-width:0; }
.log-line[data-level="status"] .log-text { color:var(--status-review); } .log-line[data-level="status"] .log-text { color:var(--status-review); }
.log-line[data-level="warn"] .log-text { color:var(--status-review); } .log-line[data-level="warn"] .log-text { color:var(--status-review); }
.log-line[data-level="error"] .log-text { color:#F85149; } .log-line[data-level="error"] .log-text { color:#F85149; }

View File

@ -291,7 +291,7 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
// identically on the non-board pages. // identically on the non-board pages.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task'; export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task' | 'health';
/** CSS for ONLY the shared header (board v2 reuses this inside boardV2Css). */ /** CSS for ONLY the shared header (board v2 reuses this inside boardV2Css). */
export function appHeaderOnlyCss(): string { export function appHeaderOnlyCss(): string {
@ -408,6 +408,7 @@ export function appHeader(projectName: string, current: HeaderPage): string {
<nav class="b2-nav" aria-label="Primary"> <nav class="b2-nav" aria-label="Primary">
${link('Board', '/board', 'board')} ${link('Board', '/board', 'board')}
${link('Team', '/team', 'team')} ${link('Team', '/team', 'team')}
${link('Health', '/agent-health', 'health')}
${link('Activity', '/activity', 'activity')} ${link('Activity', '/activity', 'activity')}
${link('Messages', '/messages', 'messages')} ${link('Messages', '/messages', 'messages')}
${link('Decisions', '/decisions', 'decisions')} ${link('Decisions', '/decisions', 'decisions')}

226
src/server/watchdog.ts Normal file
View File

@ -0,0 +1,226 @@
import { loadConfig } from '../core/config.js';
import type { Config, WatchdogConfig } from '../core/schema.js';
import { listTasks, getTask } from '../core/services/taskService.js';
import { readTaskLog, appendTaskLog } from '../core/services/taskLogService.js';
import { createMessage } from '../core/services/messageService.js';
import { emitChange, eventBus } from './events.js';
import { agentLoopStatus } from '../core/services/presenceService.js';
import { resolveAgentName } from '../core/services/identityService.js';
/**
* Server-side stuck-task watchdog (TSK-0226).
*
* Nobody should have to wake agents manually or spot priority inversions by
* hand the hub re-notifies autonomously:
*
* (a) OPEN + assigned but unclaimed for > 3 min (high/critical) or > 10 min
* (medium/low) re-emit the task event (SSE) so a waiting `work` loop
* re-wakes, send the assignee an `unread` reminder message (work loops
* wake on unread mail), and log a board-visible warn line on the task.
* (b) IN_PROGRESS with no task-log line for > 15 min alert the architect
* (visibility only reassignment stays an architect decision).
*
* Anti-spam: a given task is re-alerted only after its threshold has elapsed
* again since the last alert (per task + alert kind).
*
* All thresholds + the interval are configurable via the `watchdog` section of
* agenthub.config.json; absent config the schema defaults (see schema.ts).
*/
/** last alert timestamp per `${taskId}:${kind}` — in-memory, per hub process. */
const lastAlertAt = new Map<string, number>();
function architectName(config: Config): string {
return config.roles.architect?.preferredAgent ?? 'claude';
}
function thresholdFor(priority: string | undefined, cfg: WatchdogConfig): number {
return priority === 'high' || priority === 'critical' ? cfg.unclaimedHighMs : cfg.unclaimedDefaultMs;
}
function cooledDown(key: string, threshold: number, now: number): boolean {
const last = lastAlertAt.get(key);
return last === undefined || now - last >= threshold;
}
/** Board-visible alert: a warn line on the task's live console + SSE fan-out. */
function logAlert(cwd: string, id: string, text: string): void {
try {
const entry = appendTaskLog(cwd, id, { text, agent: 'watchdog', level: 'warn' });
eventBus.publishLog({ taskId: id, ...entry });
} catch {
/* best-effort */
}
}
/**
* One watchdog scan. Exported for tests; `startWatchdog` drives it on a timer.
* Returns the number of alerts raised (for tests/diagnostics).
*/
export function runWatchdogScan(cwd: string, now: number = Date.now()): number {
const config = loadConfig(cwd);
const cfg = config.watchdog;
let alerts = 0;
for (const t of listTasks(cwd)) {
// (a) OPEN + addressed to an agent, but never claimed.
if (t.status === 'open' && t.assignedTo && !t.claimedBy) {
let priority: string | undefined;
let title: string | undefined = t.title;
try {
const full = getTask(cwd, t.id).task;
priority = full.priority;
title = full.title;
} catch {
/* fall back to the index row */
}
const threshold = thresholdFor(priority, cfg);
const waitingMs = now - Date.parse(t.updatedAt || t.createdAt);
const key = `${t.id}:unclaimed`;
let assignee = t.assignedTo;
try { assignee = resolveAgentName(cwd, t.assignedTo); } catch { /* invalid legacy target is reported below */ }
const dispatch = config.agents?.[assignee]?.dispatch ?? 'loop';
const shouldAlert = dispatch === 'architect' ? !lastAlertAt.has(key) : cooledDown(key, threshold, now);
if (waitingMs >= threshold && shouldAlert) {
lastAlertAt.set(key, now);
alerts++;
if (dispatch === 'architect') {
const architect = architectName(config);
try {
createMessage(cwd, {
from: 'agenthub',
to: architect,
text: `${t.id} wartet auf ${assignee} — der wird von dir gestartet, nicht von selbst.`,
taskId: t.id,
});
} catch { /* best-effort */ }
logAlert(cwd, t.id, `Watchdog: wartet auf Architekten-Start von ${assignee}`);
continue;
}
if (agentLoopStatus(assignee) === 'inactive') {
const architect = architectName(config);
try {
createMessage(cwd, {
from: 'agenthub',
to: architect,
text: `Watchdog: ${t.id} is assigned to ${assignee}, but that agent is not in agenthub_work — manual/session wake may be required.`,
taskId: t.id,
});
} catch {
/* best-effort */
}
logAlert(cwd, t.id, `Watchdog: ${assignee} is out of the work loop — alerted ${architect}`);
}
// 1. Re-emit the task event so SSE subscribers / work loops re-wake.
emitChange(
{
type: 'task',
action: 'updated',
id: t.id,
title,
status: t.status,
role: t.role,
assignedTo: t.assignedTo,
claimedBy: t.claimedBy,
reviewer: t.reviewer,
},
t.updatedAt,
);
// 2. Unread reminder message — work loops wake on unread mail.
try {
createMessage(cwd, {
from: 'agenthub',
to: t.assignedTo,
text: `Reminder: ${t.id} wartet auf dich`,
taskId: t.id,
});
} catch {
/* best-effort */
}
// 3. Board-visible alert.
logAlert(cwd, t.id, `Watchdog: unclaimed for ${Math.round(waitingMs / 60_000)}m — re-notified ${t.assignedTo}`);
}
continue;
}
// (b) IN_PROGRESS but silent — no task-log line for too long.
if (t.status === 'in_progress') {
const log = readTaskLog(cwd, t.id, 1);
const lastActivity = log.length > 0 ? Date.parse(log[log.length - 1].ts) : Date.parse(t.updatedAt || t.createdAt);
const silentMs = now - (isNaN(lastActivity) ? now : lastActivity);
const key = `${t.id}:stale`;
if (silentMs >= cfg.staleInProgressMs && cooledDown(key, cfg.staleInProgressMs, now)) {
lastAlertAt.set(key, now);
alerts++;
const architect = architectName(config);
try {
createMessage(cwd, {
from: 'agenthub',
to: architect,
text: `Watchdog: ${t.id} is in_progress by ${t.claimedBy ?? t.assignedTo ?? '?'} but silent for ${Math.round(silentMs / 60_000)}m — no auto-reassign, your call.`,
taskId: t.id,
});
} catch {
/* best-effort */
}
logAlert(cwd, t.id, `Watchdog: silent for ${Math.round(silentMs / 60_000)}m — alerted ${architect}`);
}
}
if (t.status === 'review') {
const waitingMs = now - Date.parse(t.updatedAt || t.createdAt);
const key = `${t.id}:review`;
if (waitingMs >= cfg.staleReviewMs && cooledDown(key, cfg.staleReviewMs, now)) {
lastAlertAt.set(key, now);
alerts++;
const architect = architectName(config);
try {
createMessage(cwd, {
from: 'agenthub',
to: architect,
text: `Watchdog: ${t.id} has waited in review for ${Math.round(waitingMs / 60_000)}m — approve or reopen it.`,
taskId: t.id,
});
} catch {
/* best-effort */
}
logAlert(cwd, t.id, `Watchdog: review waiting ${Math.round(waitingMs / 60_000)}m — alerted ${architect}`);
}
}
}
return alerts;
}
/**
* Start the watchdog loop. Returns a stop function (clears the timer).
* The interval is unref'd so it can never keep a process alive on its own.
*/
export function startWatchdog(cwd: string): () => void {
let timer: NodeJS.Timeout | undefined;
let cfg: WatchdogConfig;
try {
cfg = loadConfig(cwd).watchdog;
} catch {
return () => { /* no config, no watchdog */ };
}
if (!cfg.enabled) return () => { /* disabled via config */ };
timer = setInterval(() => {
try {
runWatchdogScan(cwd);
} catch {
/* the watchdog must never crash the server */
}
}, cfg.intervalMs);
timer.unref?.();
return () => {
if (timer) clearInterval(timer);
};
}
/** Test hook: drop all cooldown state. */
export function resetWatchdog(): void {
lastAlertAt.clear();
}

2
src/version.ts Normal file
View File

@ -0,0 +1,2 @@
/** Single source of truth for the agenthub version (package.json, CLI, MCP, /health). */
export const VERSION = '0.10.2';

View File

@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs'; import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { createTask, claimTask, doneTask } from '../src/core/services/taskService.js'; import { createTask, claimTask, reviewTask, doneTask } from '../src/core/services/taskService.js';
import { createHandoff } from '../src/core/services/handoffService.js'; import { createHandoff } from '../src/core/services/handoffService.js';
import { addMemory } from '../src/core/services/memoryService.js'; import { addMemory } from '../src/core/services/memoryService.js';
import { getTaskActivity } from '../src/core/services/activityService.js'; import { getTaskActivity } from '../src/core/services/activityService.js';
@ -41,6 +41,8 @@ describe('activityService', () => {
it('includes a status event with tokens/duration when task is done with metadata', () => { it('includes a status event with tokens/duration when task is done with metadata', () => {
const task = createTask(cwd, { title: 'Finish me', role: 'implementer' }); const task = createTask(cwd, { title: 'Finish me', role: 'implementer' });
claimTask(cwd, task.id, 'codex');
reviewTask(cwd, task.id);
doneTask(cwd, task.id, { doneBy: 'claude', doneTokens: 8500, doneDuration: 120_000 }); doneTask(cwd, task.id, { doneBy: 'claude', doneTokens: 8500, doneDuration: 120_000 });
const items = getTaskActivity(cwd, task.id); const items = getTaskActivity(cwd, task.id);
const status = items.find((i) => i.kind === 'status'); const status = items.find((i) => i.kind === 'status');

15
tests/agent-setup.test.ts Normal file
View File

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { codexHookBlock } from '../src/cli/commands/agentSetup.js';
describe('Codex AgentHub lifecycle hooks', () => {
it('installs a host-native Stop-hook worker in addition to SessionStart context', () => {
const block = codexHookBlock('codex', 'implementer');
expect(block).toContain('[[hooks.SessionStart]]');
expect(block).toContain('agenthub hook-context --agent codex');
expect(block).toContain('[[hooks.Stop]]');
expect(block).toContain('agenthub hook-stop --agent codex');
expect(block).not.toContain('--role implementer');
expect(block).toContain('timeout = 86400');
});
});

99
tests/agentHealth.test.ts Normal file
View File

@ -0,0 +1,99 @@
/**
* Regression tests for the agent health-check page (TSK-0230, HOF-0085).
* - GET /agent-health serves the page with the per-agent traffic light
* (same agentLight data as /health reused, not duplicated).
* - The test-message wiring + cross-messaging data ship with the page.
* - The page surfaces the transport mode (SSE vs polling fallback).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js';
describe('GET /agent-health (TSK-0230)', () => {
let cwd: string;
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-agent-health-'));
init(cwd, { projectName: 'health-test', yes: true });
app = buildApp(cwd);
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('serves the health-check page as HTML with nav + roster agents', async () => {
const res = await app.inject({ method: 'GET', url: '/agent-health' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
const html = res.payload;
expect(html).toContain('<title>AgentHub Agent Health</title>');
expect(html).toContain('health-test');
// Nav link from the shared header.
expect(html).toContain('/agent-health');
// Roster agents get a card with a traffic light each.
expect(html).toContain('data-agent="claude"');
expect(html).toContain('data-agent="codex"');
expect(html).toContain('data-light');
});
it('marks an unclaimed assigned agent as stale (TSK-0226 ampel, reused)', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'waiting work', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { assignedTo: 'codex' } });
const res = await app.inject({ method: 'GET', url: '/agent-health' });
const html = res.payload;
// codex was never seen + has an open assignment ⇒ stale (red), per agentLight.
expect(html).toContain('light-stale');
});
it('ships the test-message send + delivery-tracking wiring', async () => {
const res = await app.inject({ method: 'GET', url: '/agent-health' });
const html = res.payload;
expect(html).toContain('ac-send');
expect(html).toContain('Send test');
expect(html).toContain('data-track');
// Delivery tracking observes the side-effect-free full list (never the
// ?agent= inbox variant, whose read receipt would fake delivery).
expect(html).toContain("fetch('/messages'");
expect(html).not.toContain("fetch('/messages?agent=");
});
it('embeds cross-messaging data (direction, status) for the per-agent view', async () => {
await app.inject({
method: 'POST',
url: '/messages',
payload: { from: 'claude', to: 'codex', text: 'cross-wire check' },
});
const res = await app.inject({ method: 'GET', url: '/agent-health' });
const html = res.payload;
expect(html).toContain('cross-wire check');
expect(html).toContain('__HEALTH_DATA__');
expect(html).toContain('cross-messaging');
});
it('surfaces the transport mode badge (SSE live vs polling fallback)', async () => {
const res = await app.inject({ method: 'GET', url: '/agent-health' });
const html = res.payload;
expect(html).toContain('id="transport"');
expect(html).toContain('polling fallback');
expect(html).toContain('SSE live');
// The page consumes the durable event seq (TSK-0225) via EventSource.
expect(html).toContain("new EventSource('/events')");
expect(html).toContain('lastEventId');
});
it('keeps the /health JSON endpoint intact alongside the page', async () => {
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
const json = res.json() as { status: string; agents: Array<{ name: string; state: string }> };
expect(json.status).toBe('ok');
expect(json.agents.map((a) => a.name)).toContain('claude');
});
});

View File

@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { createTask, claimTask, reviewTask, doneTask } from '../src/core/services/taskService.js';
import { createHandoff } from '../src/core/services/handoffService.js';
import { createMessage } from '../src/core/services/messageService.js';
import { computeBudget, TOKENS_PER_COORDINATION_ACTION } from '../src/core/services/budgetService.js';
/** Architect coordination work must be visible in the budget report (TSK-0226). */
describe('budget: architect activity', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-budget-'));
init(cwd, { projectName: 'budget-test', yes: true });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('counts reviews, handoffs and messages as architect activity', () => {
// Implementer-owned task; the architect only coordinates around it.
createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, 'TSK-0001', 'codex');
reviewTask(cwd, 'TSK-0001', 'claude');
createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', fromAgent: 'claude', toAgent: 'codex', taskId: 'TSK-0001', summary: 'scope' });
createMessage(cwd, { from: 'claude', to: 'codex', text: 'please review' });
const report = computeBudget(cwd);
const claude = report.agents.find((a) => a.name === 'claude');
expect(claude).toBeDefined();
// reviewer + handoff + message = 3 actions, all estimated (never silent fabrication).
expect(claude!.actions).toBe(3);
expect(claude!.tokens).toBe(3 * TOKENS_PER_COORDINATION_ACTION);
expect(claude!.estimated).toBe(true);
expect(claude!.estimatedTokens).toBe(claude!.tokens);
expect(claude!.costEur).toBeGreaterThan(0);
});
it('a done approval by someone other than the owner counts as an action', () => {
createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, 'TSK-0001', 'codex');
reviewTask(cwd, 'TSK-0001', 'claude');
// Architect approves: doneBy = claude, owner = codex.
doneTask(cwd, 'TSK-0001', { doneBy: 'claude', doneTokens: 8000 });
const report = computeBudget(cwd);
const claude = report.agents.find((a) => a.name === 'claude')!;
// review + approval; the implementer keeps the real task tokens.
expect(claude.actions).toBe(2);
const codex = report.agents.find((a) => a.name === 'codex')!;
expect(codex.realTokens).toBe(8000);
// No double count: claude's action estimate does not include the 8000.
expect(claude.tokens).toBe(2 * TOKENS_PER_COORDINATION_ACTION);
});
});

230
tests/autowake.test.ts Normal file
View File

@ -0,0 +1,230 @@
/**
* Regression tests for TSK-0230 (HOF-0086): realtime auto-wake / auto-claim.
*
* An agent sitting in the work-wait CLI `agenthub work` AND the MCP
* `agenthub_work` path (waitForTask) must wake with NO manual poke when
* (a) the architect assigns an existing open task to it (task_assign), or
* (b) a message arrives for it,
* over the SSE stream AND over the polling fallback alone (SSE down).
* With default settings the auto-claim must land within 10s.
*
* Background: TSK-0230 was assigned + messaged to kimi-ah and the waiting
* loop did not claim it the CEO had to poke the agent manually. These
* tests pin the wake path so that class of failure cannot regress.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { workAgent } from '../src/cli/commands/work.js';
import { findAddressedOpenTask, type AgentContext } from '../src/cli/commands/start.js';
import { remoteClient } from '../src/cli/remoteClient.js';
import { waitForTask } from '../src/mcp/server.js';
import { startServer } from '../src/server/index.js';
import type { Task } from '../src/core/schema.js';
const AGENT = 'kimi';
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/** Create an open implementer task that is NOT addressed to AGENT. */
async function createUnaddressedTask(serverUrl: string): Promise<string> {
const res = await fetch(`${serverUrl}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'someone-else: not for kimi', role: 'implementer' }),
});
const task = (await res.json()) as Task;
return task.id;
}
/** The architect's task_assign: address the open task to AGENT (no claim). */
async function assignTask(serverUrl: string, id: string): Promise<void> {
await fetch(`${serverUrl}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assignedTo: AGENT }),
});
}
async function postMessage(serverUrl: string, text: string): Promise<void> {
await fetch(`${serverUrl}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'claude', to: AGENT, text }),
});
}
async function getTaskViaApi(serverUrl: string, id: string): Promise<Task> {
// GET /tasks/:id returns { task, body }, not a flat Task.
const res = (await fetch(`${serverUrl}/tasks/${id}`).then((r) => r.json())) as { task: Task };
return res.task;
}
/**
* Simulate "SSE down, REST fine": requests to /events hang open without ever
* delivering a frame (the nasty case a dropped event on an otherwise-open
* stream), everything else passes through to the real fetch. The wait may
* then only wake via the polling fallback (TSK-0224).
*/
function breakSseOnly(): void {
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (new URL(url).pathname === '/events') {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () =>
reject(new DOMException('The operation was aborted.', 'AbortError')),
);
});
}
return realFetch(input, init);
}) as typeof fetch;
}
describe('auto-wake on task_assign / message (TSK-0230)', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
const realFetch = globalThis.fetch;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-autowake-'));
init(cwd, { projectName: 'autowake-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
globalThis.fetch = realFetch;
await server.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
});
// ── CLI work loop ─────────────────────────────────────────────────────────
it('CLI: auto-claims an assigned task via SSE (no poke)', async () => {
const taskId = await createUnaddressedTask(server.url);
const workDone = workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 8 });
await sleep(200);
const t0 = Date.now();
await assignTask(server.url, taskId);
await workDone;
// SSE is live: the wake must be near-instant, far below the 10s bound.
expect(Date.now() - t0).toBeLessThan(2_000);
const task = await getTaskViaApi(server.url, taskId);
expect(task.status).toBe('in_progress');
expect(task.assignedTo).toBe(AGENT);
}, 12_000);
it('CLI: auto-claims an assigned task within ≤10s with SSE DOWN (polling fallback only, default interval)', async () => {
breakSseOnly();
const taskId = await createUnaddressedTask(server.url);
// Default pollIntervalMs (4000) on purpose: this is the ≤10s acceptance proof.
const workDone = workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 10 });
await sleep(300);
const t0 = Date.now();
await assignTask(server.url, taskId);
await workDone;
expect(Date.now() - t0).toBeLessThan(10_000);
const task = await getTaskViaApi(server.url, taskId);
expect(task.status).toBe('in_progress');
expect(task.assignedTo).toBe(AGENT);
}, 15_000);
it('CLI: wakes on an incoming message with SSE DOWN (delivered, not read)', async () => {
breakSseOnly();
const workDone = workAgent({
serverUrl: server.url,
projectCwd: cwd,
agent: AGENT,
role: 'implementer',
timeoutSec: 8,
pollIntervalMs: 400,
});
await sleep(300);
await postMessage(server.url, 'wake without poke (sse down)');
await workDone; // resolves because the message woke the loop, not on timeout
const inbox = (await fetch(`${server.url}/messages?agent=${AGENT}`).then((r) => r.json())) as Array<{
text: string;
status: string;
}>;
const m = inbox.find((x) => x.text.includes('wake without poke'));
expect(m).toBeDefined();
// Surfaced (unread → delivered) but NOT auto-read — a timeout would have left it unread.
expect(m?.status).toBe('delivered');
}, 12_000);
// ── MCP agenthub_work path (waitForTask) ──────────────────────────────────
/** Mimics the agenthub_work finder: addressed-open-task lookup + claim. */
function mcpWorkFinder(ctx: AgentContext) {
return async (url: string) => {
ctx.serverUrl = url;
const found = await findAddressedOpenTask(ctx);
if (!found) return null;
await remoteClient.claimTask(url, found.task.id, ctx.agent);
return found.task;
};
}
it('MCP: waitForTask auto-claims an assigned task via SSE (no poke)', async () => {
const ctx: AgentContext = { serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer' };
const taskId = await createUnaddressedTask(server.url);
const waiting = waitForTask(server.url, mcpWorkFinder(ctx), 8);
await sleep(200);
const t0 = Date.now();
await assignTask(server.url, taskId);
const claimed = await waiting;
// SSE is live: the wake must be near-instant, far below the 10s bound.
expect(Date.now() - t0).toBeLessThan(2_000);
expect(claimed?.id).toBe(taskId);
const task = await getTaskViaApi(server.url, taskId);
expect(task.status).toBe('in_progress');
expect(task.assignedTo).toBe(AGENT);
}, 12_000);
it('MCP: waitForTask auto-claims an assigned task within ≤10s with SSE DOWN (polling fallback only)', async () => {
breakSseOnly();
const ctx: AgentContext = { serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer' };
const taskId = await createUnaddressedTask(server.url);
const waiting = waitForTask(server.url, mcpWorkFinder(ctx), 10);
await sleep(300);
const t0 = Date.now();
await assignTask(server.url, taskId);
const claimed = await waiting;
expect(Date.now() - t0).toBeLessThan(10_000);
expect(claimed?.id).toBe(taskId);
const task = await getTaskViaApi(server.url, taskId);
expect(task.status).toBe('in_progress');
}, 15_000);
it('MCP: waitForTask wakes on an incoming message with SSE DOWN', async () => {
breakSseOnly();
const finder = async (url: string) => {
const msgs = await remoteClient.getInbox(url, AGENT, true);
return msgs.length ? { messages: msgs } : null;
};
const waiting = waitForTask(server.url, finder, 8);
await sleep(300);
await postMessage(server.url, 'mcp message wake (sse down)');
const hit = await waiting;
expect(hit).not.toBeNull();
expect(hit?.messages[0]?.text).toContain('mcp message wake');
// listInbox read receipt: surfaced as delivered, never auto-read.
expect(hit?.messages[0]?.status).toBe('delivered');
}, 12_000);
});

View File

@ -27,4 +27,7 @@ describe('kpiJs', () => {
expect(js).toContain('DAY_MS'); expect(js).toContain('DAY_MS');
expect(js).toContain('function dayStart'); expect(js).toContain('function dayStart');
}); });
it('keeps chips single-line friendly (no wide text prefix)', () => {
expect(kpiJs()).not.toContain('prüft ');
});
}); });

View File

@ -13,4 +13,9 @@ describe('boardV2Css', () => {
it('respects prefers-reduced-motion', () => { it('respects prefers-reduced-motion', () => {
expect(boardV2Css()).toContain('@media (prefers-reduced-motion: reduce)'); expect(boardV2Css()).toContain('@media (prefers-reduced-motion: reduce)');
}); });
it('keeps kpi chips on a single line', () => {
const css = boardV2Css();
expect(css).toMatch(/\.b2-chips \{[^}]*flex-wrap: nowrap/);
expect(css).toMatch(/\.b2-chips \{[^}]*overflow: hidden/);
});
}); });

View File

@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createTask, claimTask, reviewTask, reopenTask, cancelTask, assignTask } from '../src/core/services/taskService.js';
import { createMessage, markMessageDelivered } from '../src/core/services/messageService.js';
import { resolvePending, hasPending } from '../src/core/services/checkinService.js';
import { createAsk, answerAsk } from '../src/core/services/askService.js';
/**
* TSK-0274 der Check-in-Kanal.
*
* Kern der Sache: ein Agent, der einen Task AUSFÜHRT, empfängt keine Events.
* Diese Tests sichern den einzigen Weg ab, auf dem er trotzdem erfährt, dass
* sich etwas an SEINER Arbeit geändert hat.
*/
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'agenthub-checkin-'));
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
function openTaskFor(agent: string, title = 'test task') {
const task = createTask(cwd, { title: `${agent}: ${title}`, role: 'implementer' });
assignTask(cwd, task.id, agent);
return task.id;
}
describe('resolvePending — Reopen unter laufender Arbeit', () => {
it('meldet interrupted, wenn der gehaltene Task zurückgegeben wurde', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
reviewTask(cwd, id);
reopenTask(cwd, id);
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted).toBeDefined();
expect(pending.interrupted?.taskId).toBe(id);
expect(pending.interrupted?.status).toBe('open');
// Die Handlungsanweisung muss unmissverständlich sein — der Agent soll
// NICHT weiterbauen und NICHT einreichen.
expect(pending.interrupted?.action).toMatch(/STOPP/);
expect(hasPending(pending)).toBe(true);
});
it('meldet interrupted, wenn der Architekt eine laufende Arbeit entzieht (in_progress → open)', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
reopenTask(cwd, id); // Entzug ohne Umweg über review
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted?.taskId).toBe(id);
expect(pending.interrupted?.status).toBe('open');
});
it('meldet interrupted bei Abbruch', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
cancelTask(cwd, id);
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted?.status).toBe('cancelled');
expect(pending.interrupted?.reason).toMatch(/abgebrochen/i);
});
it('meldet KEIN interrupted, solange der Task normal läuft', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted).toBeUndefined();
expect(hasPending(pending)).toBe(false);
});
it('verwechselt Agenten nicht — fremder Claim gilt als interrupted', () => {
const id = openTaskFor('kimi');
claimTask(cwd, id, 'kimi');
// codex glaubt, an diesem Task zu arbeiten — tut er aber nicht.
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted?.taskId).toBe(id);
});
});
describe('resolvePending — Nachrichten und Zuweisungen', () => {
it('liefert ungelesene Nachrichten mit Vorschau', () => {
createMessage(cwd, { from: 'claude', to: 'codex', text: 'Bitte Reihenfolge umdrehen.' });
const pending = resolvePending(cwd, 'codex');
expect(pending.unreadCount).toBe(1);
expect(pending.messages[0]?.from).toBe('claude');
expect(pending.messages[0]?.preview).toContain('Reihenfolge');
expect(pending.note).toMatch(/unbeantwortete/i);
});
it('nennt offene, an den Agenten adressierte Tasks', () => {
const id = openTaskFor('codex');
const pending = resolvePending(cwd, 'codex');
expect(pending.waitingTasks).toContain(id);
});
it('nennt eingereichte Tasks, die auf das Review warten', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
reviewTask(cwd, id);
const pending = resolvePending(cwd, 'codex');
expect(pending.awaitingReview).toContain(id);
});
it('bleibt klein, wenn nichts anliegt', () => {
const pending = resolvePending(cwd, 'codex');
expect(hasPending(pending)).toBe(false);
expect(pending.note).toBeUndefined();
expect(JSON.stringify(pending).length).toBeLessThan(200);
});
it('begrenzt die Nachrichten-Vorschau, damit die Antwort klein bleibt', () => {
for (let i = 0; i < 12; i += 1) {
createMessage(cwd, { from: 'claude', to: 'codex', text: `Nachricht ${i} `.repeat(40) });
}
const pending = resolvePending(cwd, 'codex');
expect(pending.unreadCount).toBe(12);
expect(pending.messages.length).toBeLessThanOrEqual(3);
// Die Antwort fließt in JEDEN Log-Aufruf — sie darf nicht ausufern.
expect(JSON.stringify(pending).length).toBeLessThan(1500);
});
});
describe('resolvePending — beantwortete Fragen (Ask-Timeout-Lücke)', () => {
it('liefert die Antwort auf eine eigene Frage nach — auch lange nach dem 300s-Wait', () => {
const ask = createAsk(cwd, { from: 'kimi', to: 'claude', question: 'Welcher Weg?' });
answerAsk(cwd, ask.id, 'Nimm Variante B.', 'claude');
const pending = resolvePending(cwd, 'kimi');
// agenthub_ask(wait) gibt nach 300s auf; braucht der Architekt laenger,
// erreicht die Antwort den dormanten Agenten sonst NIE.
expect(pending.answeredAsks.map((a) => a.id)).toContain(ask.id);
expect(pending.answeredAsks[0]?.answer).toContain('Variante B');
expect(pending.note).toMatch(/BEANTWORTET/);
expect(hasPending(pending)).toBe(true);
});
it('zeigt fremde Antworten nicht an', () => {
const ask = createAsk(cwd, { from: 'codex', to: 'claude', question: 'X?' });
answerAsk(cwd, ask.id, 'Y', 'claude');
expect(resolvePending(cwd, 'kimi').answeredAsks).toHaveLength(0);
});
});
describe('resolvePending — `delivered` zaehlt weiter', () => {
it('zeigt eine bereits ausgelieferte, aber unbeantwortete Nachricht weiter an', () => {
const msg = createMessage(cwd, { from: 'claude', to: 'kimi', text: 'Korrektur: nimm doch Variante A.' });
markMessageDelivered(cwd, msg.id);
const pending = resolvePending(cwd, 'kimi');
// `delivered` heisst nur "einmal aufgetaucht". Filterte der Check-in auf
// `unread`, ginge eine Architekten-Korrektur still verloren.
expect(pending.unreadCount).toBe(1);
expect(hasPending(pending)).toBe(true);
});
});
describe('resolvePending — Adressierung wie im Work-Loop', () => {
it('erkennt eine Task am Titel-Praefix, auch ohne assignedTo', () => {
// Genau der Fall, der den Session-Start luegen liess: Task traegt das
// Praefix, aber kein assignedTo — der Work-Loop haette sie sofort geclaimt.
const task = createTask(cwd, { title: 'codex: Blocker-Redesign', role: 'implementer' });
const pending = resolvePending(cwd, 'codex');
expect(pending.waitingTasks).toContain(task.id);
expect(hasPending(pending)).toBe(true);
});
it('beansprucht keine fremd adressierte Task', () => {
createTask(cwd, { title: 'kimi: nicht meins', role: 'implementer' });
expect(resolvePending(cwd, 'codex').waitingTasks).toHaveLength(0);
});
});
describe('resolvePending — bereits gehaltene Task', () => {
it('nennt die in_progress-Task, die der Agent haelt', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
const pending = resolvePending(cwd, 'codex');
// Der Work-Loop sucht nur `open` — ohne diesen Hinweis wartet ein Agent
// nach dem Neustart auf neue Arbeit, waehrend seine eigene liegt.
expect(pending.heldTask).toBe(id);
expect(pending.note).toMatch(/haeltst bereits/i);
expect(hasPending(pending)).toBe(true);
});
it('meldet keine fremde in_progress-Task als gehalten', () => {
const id = openTaskFor('kimi');
claimTask(cwd, id, 'kimi');
expect(resolvePending(cwd, 'codex').heldTask).toBeUndefined();
});
});

111
tests/health.test.ts Normal file
View File

@ -0,0 +1,111 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js';
import { resetPresence } from '../src/core/services/presenceService.js';
import { VERSION } from '../src/version.js';
interface HealthBody {
status: string;
version: string;
startedAt: string;
uptimeSec: number;
counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number };
agents: Array<{
name: string; role: string; state: string; taskId?: string; lastSeen?: string; lastSeenAgoSec?: number;
inLoop: boolean; loopSince?: string; loopExitReason?: string;
}>;
}
describe('GET /health + lastSeen stamping (TSK-0226)', () => {
let cwd: string;
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-health-'));
init(cwd, { projectName: 'health-test', yes: true });
resetPresence();
app = buildApp(cwd);
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
const getHealth = async (): Promise<HealthBody> => {
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
return JSON.parse(res.payload) as HealthBody;
};
it('returns the compact hub health shape', async () => {
const h = await getHealth();
expect(h.status).toBe('ok');
expect(h.version).toBe(VERSION);
expect(typeof h.uptimeSec).toBe('number');
expect(h.uptimeSec).toBeGreaterThanOrEqual(0);
expect(typeof h.startedAt).toBe('string');
expect(h.counts).toMatchObject({ tasks: 0, open: 0, inProgress: 0, review: 0, unreadMessages: 0 });
expect(Array.isArray(h.agents)).toBe(true);
// The init roster seeds the default preferred agents.
expect(h.agents.map((a) => a.name)).toContain('claude');
});
it('stamps lastSeen on announce → agent shows active', async () => {
await app.inject({ method: 'POST', url: '/announce', payload: { agent: 'kimi', role: 'implementer' } });
const h = await getHealth();
const kimi = h.agents.find((a) => a.name === 'kimi');
expect(kimi).toBeDefined();
expect(kimi!.state).toBe('active');
expect(typeof kimi!.lastSeen).toBe('string');
expect(kimi!.lastSeenAgoSec).toBeLessThan(120);
});
it('shows work-loop entry and the latest exit reason', async () => {
await app.inject({ method: 'POST', url: '/agents/codex/loop', payload: { active: true } });
let codex = (await getHealth()).agents.find((a) => a.name === 'codex')!;
expect(codex.inLoop).toBe(true);
expect(codex.loopSince).toBeDefined();
await app.inject({
method: 'POST',
url: '/agents/codex/loop',
payload: { active: false, reason: 'client timeout' },
});
codex = (await getHealth()).agents.find((a) => a.name === 'codex')!;
expect(codex.inLoop).toBe(false);
expect(codex.loopExitReason).toBe('client timeout');
});
it('stamps lastSeen on message send and counts the unread message', async () => {
await app.inject({ method: 'POST', url: '/messages', payload: { from: 'codex', to: 'claude', text: 'ping' } });
const h = await getHealth();
const codex = h.agents.find((a) => a.name === 'codex');
expect(codex!.state).toBe('active');
expect(h.counts.unreadMessages).toBe(1);
});
it('stamps lastSeen on claim → agent shows busy-on-TSK-X', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
const h = await getHealth();
const codex = h.agents.find((a) => a.name === 'codex');
expect(codex!.state).toBe('busy');
expect(codex!.taskId).toBe('TSK-0001');
expect(h.counts.inProgress).toBe(1);
});
it('stamps lastSeen on task-log lines and review submissions', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer', assignedTo: 'kimi' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } });
await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'working', agent: 'kimi' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
const h = await getHealth();
const kimi = h.agents.find((a) => a.name === 'kimi');
expect(kimi!.state).toBe('active');
expect(kimi!.lastSeen).toBeDefined();
expect(h.counts.review).toBe(1);
});
});

View File

@ -0,0 +1,37 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
import { agentIdentity, resolveAgentName } from '../src/core/services/identityService.js';
describe('agent identity resolution', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-identity-'));
init(cwd, { projectName: 'identity-test', yes: true });
const config = loadConfig(cwd);
config.agents = {
claude: { role: 'architect', aliases: ['windows-claude'] },
kimi: { role: 'implementer', aliases: ['kimi-ah'] },
};
config.roles.architect.preferredAgent = 'claude';
config.roles.implementer.preferredAgent = 'kimi';
saveConfig(cwd, config);
});
afterEach(() => rmSync(cwd, { recursive: true, force: true }));
it('maps aliases and roles to the canonical roster agent', () => {
expect(resolveAgentName(cwd, 'kimi-ah')).toBe('kimi');
expect(resolveAgentName(cwd, 'implementer')).toBe('kimi');
expect(resolveAgentName(cwd, 'architect')).toBe('claude');
expect(agentIdentity(cwd, 'kimi').names).toEqual(new Set(['kimi', 'kimi-ah', 'implementer']));
});
it('rejects an unknown recipient when a roster is configured', () => {
expect(() => resolveAgentName(cwd, 'ghost-agent')).toThrow(/unknown agent, alias, or role/i);
});
});

91
tests/mcp.test.ts Normal file
View File

@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
import {
DEFAULT_TASK_LIST_LIMIT,
DEFAULT_WORK_TIMEOUT_SEC,
boundedTaskList,
resolveMcpContext,
} from '../src/mcp/server.js';
import { installMcp } from '../src/mcp/install.js';
describe('MCP context resolution', () => {
let cwd: string;
const originalEnv = process.env.AGENTHUB_SERVER;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-mcp-'));
init(cwd, { projectName: 'mcp-test', yes: true });
delete process.env.AGENTHUB_SERVER;
});
afterEach(() => {
if (originalEnv === undefined) delete process.env.AGENTHUB_SERVER;
else process.env.AGENTHUB_SERVER = originalEnv;
rmSync(cwd, { recursive: true, force: true });
});
it('self-heals a stale configured server URL before MCP tools call remote APIs', async () => {
const config = loadConfig(cwd);
config.serverUrl = 'http://agenthub.local:3377';
saveConfig(cwd, config);
const context = await resolveMcpContext(cwd, {
probe: async () => false,
discover: async () => 'http://127.0.0.1:3377',
});
expect(context).toEqual({ root: cwd, serverUrl: 'http://127.0.0.1:3377' });
});
});
describe('MCP bounded defaults', () => {
it('uses a client-safe work timeout', () => {
expect(DEFAULT_WORK_TIMEOUT_SEC).toBe(50);
});
it('returns task lists newest-first with explicit truncation metadata', () => {
const tasks = Array.from({ length: 55 }, (_, i) => ({
id: `TSK-${String(i).padStart(4, '0')}`,
updatedAt: new Date(Date.UTC(2026, 0, i + 1)).toISOString(),
}));
const result = boundedTaskList(tasks);
expect(result).toMatchObject({ total: 55, returned: DEFAULT_TASK_LIST_LIMIT, omitted: 5 });
expect(result.tasks[0].id).toBe('TSK-0054');
expect(result.tasks.at(-1)?.id).toBe('TSK-0005');
});
});
describe('MCP installation', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-mcp-install-'));
init(cwd, { projectName: 'mcp-install-test', yes: true });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('binds the push bridge at process startup when --agent is provided', () => {
installMcp(cwd, { agent: 'codex' });
const json = JSON.parse(readFileSync(join(cwd, '.mcp.json'), 'utf-8')) as {
mcpServers: { agenthub: { args: string[] } };
};
expect(json.mcpServers.agenthub.args).toEqual(['mcp', '--agent', 'codex']);
});
it('keeps the shared project config agent-neutral by default', () => {
installMcp(cwd);
const json = JSON.parse(readFileSync(join(cwd, '.mcp.json'), 'utf-8')) as {
mcpServers: { agenthub: { args: string[] } };
};
expect(json.mcpServers.agenthub.args).toEqual(['mcp']);
});
});

View File

@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { bindPushChannel } from '../src/mcp/pushChannel.js';
function sseFrame(seq: number): Uint8Array {
return new TextEncoder().encode(
`id: ${seq}\ndata: ${JSON.stringify({
type: 'task',
action: 'updated',
id: 'TSK-0042',
status: 'open',
assignedTo: 'codex',
})}\n\n`,
);
}
describe('MCP push channel replay cursor', () => {
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
vi.restoreAllMocks();
});
it('reconnects with Last-Event-ID after receiving a durable event', async () => {
const eventHeaders: Headers[] = [];
let eventRequests = 0;
let resolveSecondRequest!: () => void;
const secondRequest = new Promise<void>((resolve) => {
resolveSecondRequest = resolve;
});
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/agents/codex/identity')) {
return new Response(JSON.stringify({ canonical: 'codex', names: ['codex'] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
eventHeaders.push(new Headers(init?.headers));
eventRequests += 1;
if (eventRequests === 1) {
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(sseFrame(42));
controller.close();
},
}), { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
}
resolveSecondRequest();
return new Response(new ReadableStream<Uint8Array>({
start() {
// Keep the retry connected; the test only needs its request headers.
},
}), { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
}) as typeof fetch;
const sendLoggingMessage = vi.fn(async () => undefined);
const fakeServer = {
server: {
getClientCapabilities: () => null,
sendLoggingMessage,
},
} as unknown as McpServer;
bindPushChannel(fakeServer, 'http://agenthub.test', 'codex');
await secondRequest;
expect(sendLoggingMessage).toHaveBeenCalledTimes(1);
expect(eventHeaders).toHaveLength(2);
expect(eventHeaders[0].get('Last-Event-ID')).toBeNull();
expect(eventHeaders[1].get('Last-Event-ID')).toBe('42');
}, 5000);
});

View File

@ -0,0 +1,210 @@
/**
* Regression tests for the realtime reliability patch (TSK-0224):
* (a) polling fallback claims a task even when its SSE event is lost;
* (b) parseSSEBuffer accepts CRLF frames and joins multiple data: lines;
* (c) delivered-but-unacked messages stay visible in the inbox;
* (d) discovery self-heal falls back when the configured URL is dead.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { workAgent } from '../src/cli/commands/work.js';
import { parseSSEBuffer } from '../src/cli/commands/watch.js';
import { startServer } from '../src/server/index.js';
import { createMessage, listInbox, getMessage } from '../src/core/services/messageService.js';
import { probeServer, resolveReachableServerUrl } from '../src/discovery.js';
import type { Task } from '../src/core/schema.js';
// ─── (a) polling fallback: claim despite a lost SSE event ────────────────────
describe('work — polling fallback (lost SSE event)', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
const realFetch = globalThis.fetch;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-poll-'));
init(cwd, { projectName: 'poll-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
// Suppress the SSE channel: /events returns an open stream that never
// delivers a frame. Every other request passes through to the real server.
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
if (url.endsWith('/events')) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
init?.signal?.addEventListener('abort', () => {
try {
controller.close();
} catch {
/* already closed */
}
});
// Never enqueue: the SSE frame is "lost".
},
});
return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
}
return realFetch(input, init);
}) as typeof fetch;
});
afterEach(async () => {
globalThis.fetch = realFetch;
await server.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
});
it('claims a task via the polling fallback when its SSE event never arrives', async () => {
const workDone = workAgent({
serverUrl: server.url,
projectCwd: cwd,
agent: 'kimi',
role: 'implementer',
timeoutSec: 8,
pollIntervalMs: 100,
});
// Delegate a task after the wait started. The SSE event is swallowed by the
// stub above, so only the polling fallback can notice it.
await new Promise((r) => setTimeout(r, 300));
await realFetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'kimi: claimed via polling', role: 'implementer' }),
});
await workDone; // must resolve via the poll, long before the 8s timeout
const tasks = (await realFetch(`${server.url}/tasks`).then((r) => r.json())) as Task[];
const mine = tasks.find((t) => (t.title ?? '').startsWith('kimi:'));
expect(mine).toBeDefined();
expect(mine?.status).toBe('in_progress');
expect(mine?.assignedTo).toBe('kimi');
}, 10000);
});
// ─── (b) CRLF-tolerant SSE parser ────────────────────────────────────────────
describe('parseSSEBuffer — CRLF + multi-line data', () => {
it('parses CRLF-framed events', () => {
const buf = 'data: {"type":"task","action":"created","id":"TSK-0001"}\r\n\r\n';
const { events, remaining } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(events[0].id).toBe('TSK-0001');
expect(remaining).toBe('');
});
it('parses mixed LF/CRLF frames and CRLF keepalives', () => {
const buf =
':\r\n\r\n' +
'data: {"type":"memory","action":"created","id":"MEM-001"}\n\n' +
'data: {"type":"decision","action":"created","id":"DEC-001"}\r\n\r\n';
const { events, remaining } = parseSSEBuffer(buf);
expect(events).toHaveLength(2);
expect(events[0].id).toBe('MEM-001');
expect(events[1].id).toBe('DEC-001');
expect(remaining).toBe('');
});
it('joins multiple data: lines of one event with a newline', () => {
const buf = 'data: {"type":"task",\r\ndata: "id":"TSK-0002"}\r\n\r\n';
const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(events[0].type).toBe('task');
expect(events[0].id).toBe('TSK-0002');
});
it('keeps an incomplete CRLF tail in `remaining`', () => {
const buf = 'data: {"type":"task","id":"TSK-0001"}\r\n\r\ndata: {"type":"deci';
const { events, remaining } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(remaining).toBe('data: {"type":"deci');
});
});
// ─── (c) delivered-unacked messages stay visible ─────────────────────────────
describe('message semantics — delivered stays visible until explicit ack/read', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-msg-vis-'));
init(cwd, { projectName: 'msg-vis', yes: true });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('a surfaced (delivered) message stays in the default inbox and never re-wakes', () => {
const msg = createMessage(cwd, { from: 'claude', to: 'kimi', text: 'look at this' });
// The work-loop drain path: fetch unread. First call surfaces it and flips
// it to delivered; nothing marks it read.
const first = listInbox(cwd, 'kimi', { unreadOnly: true });
expect(first).toHaveLength(1);
expect(first[0].status).toBe('delivered');
expect(getMessage(cwd, msg.id).message.status).toBe('delivered');
// No spin: the unread-filtered wake check is empty on the next pass…
expect(listInbox(cwd, 'kimi', { unreadOnly: true })).toHaveLength(0);
// …but the info stays visible in the default inbox until explicit ack/read.
const inbox = listInbox(cwd, 'kimi');
expect(inbox.map((m) => m.id)).toContain(msg.id);
expect(inbox.find((m) => m.id === msg.id)?.status).toBe('delivered');
});
});
// ─── (d) discovery self-heal ─────────────────────────────────────────────────
describe('discovery self-heal — stale configured URL', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-heal-'));
init(cwd, { projectName: 'heal-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
});
it('falls back to the discovered URL when the configured one is dead', async () => {
const discovered = await resolveReachableServerUrl('http://127.0.0.1:1', {
discover: async () => server.url,
});
expect(discovered).toBe(server.url);
});
it('keeps the configured URL when it answers (discovery not consulted)', async () => {
let discoverCalled = false;
const resolved = await resolveReachableServerUrl(server.url, {
discover: async () => {
discoverCalled = true;
return 'http://example.invalid:9';
},
});
expect(resolved).toBe(server.url);
expect(discoverCalled).toBe(false);
});
it('returns undefined when the configured URL is dead and nothing is discovered', async () => {
const resolved = await resolveReachableServerUrl('http://127.0.0.1:1', {
discover: async () => undefined,
});
expect(resolved).toBeUndefined();
});
it('probeServer distinguishes a live server from a dead one', async () => {
expect(await probeServer(server.url)).toBe(true);
expect(await probeServer('http://127.0.0.1:1', 300)).toBe(false);
});
});

57
tests/role-switch.test.ts Normal file
View File

@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
import { setPreferredAgent } from '../src/core/services/roleService.js';
import { getRoster } from '../src/core/services/rosterService.js';
describe('preferred role switching', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-role-switch-'));
init(cwd, { projectName: 'role-switch', yes: true });
const config = loadConfig(cwd);
config.agents = {
claude: { role: 'architect', dispatch: 'loop', aliases: [] },
codex: { role: 'implementer', dispatch: 'loop', aliases: [] },
kimi: { role: 'implementer', dispatch: 'loop', aliases: [] },
};
saveConfig(cwd, config);
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('promotes codex and moves claude to its remaining reviewer role', () => {
expect(setPreferredAgent(cwd, 'architect', 'codex')).toEqual({
role: 'architect',
agent: 'codex',
previousAgent: 'claude',
});
const config = loadConfig(cwd);
expect(config.roles.architect.preferredAgent).toBe('codex');
expect(config.agents?.codex.role).toBe('architect');
expect(config.agents?.claude.role).toBe('reviewer');
expect(getRoster(cwd).find((a) => a.name === 'codex')?.role).toBe('architect');
});
it('restores claude and returns codex to implementer', () => {
setPreferredAgent(cwd, 'architect', 'codex');
setPreferredAgent(cwd, 'architect', 'claude');
const config = loadConfig(cwd);
expect(config.agents?.claude.role).toBe('architect');
expect(config.agents?.codex.role).toBe('implementer');
expect(getRoster(cwd).find((a) => a.name === 'claude')?.role).toBe('architect');
});
it('rejects unknown agents without changing the config', () => {
expect(() => setPreferredAgent(cwd, 'architect', 'nobody')).toThrow('Unknown agent');
expect(loadConfig(cwd).roles.architect.preferredAgent).toBe('claude');
});
});

View File

@ -4,6 +4,7 @@ import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { buildApp } from '../src/server/index.js'; import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js'; import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
describe('server routes', () => { describe('server routes', () => {
let cwd: string; let cwd: string;
@ -37,6 +38,57 @@ describe('server routes', () => {
expect(JSON.parse(res.payload)).toHaveLength(1); expect(JSON.parse(res.payload)).toHaveLength(1);
}); });
it('returns a compact architect pulse with reviews, dormant agents, messages, and event delta', async () => {
await app.inject({
method: 'POST', url: '/tasks',
payload: { title: 'Pulse review', role: 'implementer', assignedTo: 'codex' },
});
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ method: 'POST', url: '/agents/codex/loop', payload: { active: true } });
await app.inject({
method: 'POST', url: '/agents/codex/loop',
payload: { active: false, reason: 'turn ended' },
});
await app.inject({
method: 'POST', url: '/messages',
payload: { from: 'codex', to: 'architect', text: 'review ready' },
});
const res = await app.inject({ method: 'GET', url: '/architect/pulse?sinceSeq=0' });
expect(res.statusCode).toBe(200);
const pulse = JSON.parse(res.payload);
expect(pulse.reviews).toEqual([
expect.objectContaining({ id: 'TSK-0001', assignedTo: 'codex' }),
]);
expect(pulse.dormantAgents).toEqual([
expect.objectContaining({ name: 'codex', loopExitReason: 'turn ended' }),
]);
expect(pulse.messages).toEqual([
expect.objectContaining({ from: 'codex' }),
]);
expect(pulse.events.length).toBeGreaterThan(0);
expect(pulse.nextSeq).toBeGreaterThan(0);
expect(pulse.omitted).toEqual(expect.objectContaining({ messages: 0, events: expect.any(Number) }));
expect(pulse.reviews[0].title).toBeUndefined();
const delta = await app.inject({ method: 'GET', url: `/architect/pulse?since=${pulse.nextSeq}` });
const deltaPulse = JSON.parse(delta.payload);
expect(deltaPulse.events).toEqual([]);
expect(deltaPulse.nextSeq).toBe(pulse.nextSeq);
expect(delta.payload.length).toBeLessThan(1000);
});
it('returns 400 when assigning to an unknown agent', async () => {
const config = loadConfig(cwd);
config.agents = { codex: { role: 'implementer', dispatch: 'loop' } };
saveConfig(cwd, config);
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { assignedTo: 'unknown-agent' } });
expect(res.statusCode).toBe(400);
expect(res.payload).toContain('Unknown agent');
});
it('returns 404 for unknown task', async () => { it('returns 404 for unknown task', async () => {
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999' }); const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999' });
expect(res.statusCode).toBe(404); expect(res.statusCode).toBe(404);
@ -64,11 +116,33 @@ describe('server routes', () => {
it('PATCH /tasks/:id → review', async () => { it('PATCH /tasks/:id → review', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).status).toBe('review'); expect(JSON.parse(res.payload).status).toBe('review');
}); });
it('rejects open → review and records the failed transition in the task log', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.payload).error).toMatch(/open.*review/i);
const log = JSON.parse((await app.inject({ method: 'GET', url: '/tasks/TSK-0001/log' })).payload).log;
expect(log.some((entry: { text: string }) => entry.text.includes('Rejected transition'))).toBe(true);
});
it('preserves claimedBy through review and done', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({
method: 'PATCH', url: '/tasks/TSK-0001',
payload: { status: 'in_progress', assignedTo: 'codex' },
});
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
const done = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
expect(done.statusCode).toBe(200);
expect(JSON.parse(done.payload)).toMatchObject({ status: 'done', claimedBy: 'codex', doneBy: 'codex' });
});
it('a second claim of the same task returns a clean 400 (race guard, board does not 500) — TSK-0007', async () => { it('a second claim of the same task returns a clean 400 (race guard, board does not 500) — TSK-0007', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const first = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } }); const first = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } });
@ -83,6 +157,7 @@ describe('server routes', () => {
it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => { it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const task = JSON.parse(res.payload); const task = JSON.parse(res.payload);
@ -103,8 +178,8 @@ describe('server routes', () => {
it('PATCH /tasks/:id → open (reopen)', async () => { it('PATCH /tasks/:id → open (reopen)', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
// First cancel it, then reopen await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'cancelled' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).status).toBe('open'); expect(JSON.parse(res.payload).status).toBe('open');
@ -113,6 +188,7 @@ describe('server routes', () => {
it('review and cancelled tasks appear in GET /tasks list', async () => { it('review and cancelled tasks appear in GET /tasks list', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'B', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'B', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0002', payload: { status: 'cancelled' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0002', payload: { status: 'cancelled' } });
@ -203,6 +279,8 @@ describe('server routes', () => {
it('GET /tasks/:id/activity includes tokens/duration from done metadata', async () => { it('GET /tasks/:id/activity includes tokens/duration from done metadata', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: '/tasks/TSK-0001', url: '/tasks/TSK-0001',
@ -251,6 +329,16 @@ describe('server routes', () => {
decisions: ['DEC-0001'], decisions: ['DEC-0001'],
}, },
}); });
await app.inject({
method: 'PATCH',
url: '/tasks/TSK-0001',
payload: { status: 'in_progress', assignedTo: 'codex' },
});
await app.inject({
method: 'PATCH',
url: '/tasks/TSK-0001',
payload: { status: 'review' },
});
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: '/tasks/TSK-0001', url: '/tasks/TSK-0001',
@ -269,6 +357,8 @@ describe('server routes', () => {
it('serves the activity page with tasks only — messaging split out to /messages', async () => { it('serves the activity page with tasks only — messaging split out to /messages', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: '/tasks/TSK-0001', url: '/tasks/TSK-0001',

203
tests/singleClaim.test.ts Normal file
View File

@ -0,0 +1,203 @@
/**
* Regression tests for TSK-0237: single-claim semantics in the work loop.
*
* Bug (24.07. ~01:54): after the TSK-0230 auto-wake, an agent's waiting work
* loop claimed EVERY task assigned to it in parallel (codex held TSK-0222 +
* TSK-0235 at once) the board lied about who works on what.
*
* Soll:
* (a) several assigned tasks the loop claims exactly ONE (highest
* priority, oldest createdAt breaks ties); the rest stays open.
* (b) the next auto-claim happens only after the active task reaches
* review/done (or is reopened back).
* (c) server-side guard: a claim while holding an in_progress task error
* (architect exempt, no --force for agents).
* (d) the TSK-0230 auto-wake suite keeps passing.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { workAgent } from '../src/cli/commands/work.js';
import { startServer } from '../src/server/index.js';
import { createTask, claimTask, getTask } from '../src/core/services/taskService.js';
import type { Task } from '../src/core/schema.js';
const AGENT = 'kimi';
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function createAndAssign(serverUrl: string, title: string, priority: string): Promise<string> {
const res = await fetch(`${serverUrl}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, role: 'implementer', priority }),
});
const task = (await res.json()) as Task;
await fetch(`${serverUrl}/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assignedTo: AGENT }),
});
return task.id;
}
async function taskViaApi(serverUrl: string, id: string): Promise<Task> {
const res = (await fetch(`${serverUrl}/tasks/${id}`).then((r) => r.json())) as { task: Task };
return res.task;
}
async function claimViaApi(serverUrl: string, id: string, agent: string): Promise<Response> {
return fetch(`${serverUrl}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'in_progress', assignedTo: agent }),
});
}
describe('single-claim semantics (TSK-0237)', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-single-claim-'));
init(cwd, { projectName: 'single-claim-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
});
it('(a) claims exactly ONE of several assigned tasks — highest priority first', async () => {
const low = await createAndAssign(server.url, 'kimi: low prio', 'low');
await sleep(10);
const high = await createAndAssign(server.url, 'kimi: high prio', 'high');
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
const highTask = await taskViaApi(server.url, high);
expect(highTask.status).toBe('in_progress');
expect(highTask.assignedTo).toBe(AGENT);
// The other task must stay open — no parallel claim.
const lowTask = await taskViaApi(server.url, low);
expect(lowTask.status).toBe('open');
expect(lowTask.assignedTo).toBe(AGENT);
// A second work run while busy claims nothing (single-claim), it just waits.
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 1 });
expect((await taskViaApi(server.url, low)).status).toBe('open');
}, 10_000);
it('(a2) breaks priority ties by oldest createdAt', async () => {
const older = await createAndAssign(server.url, 'kimi: older medium', 'medium');
await sleep(10);
const newer = await createAndAssign(server.url, 'kimi: newer medium', 'medium');
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
expect((await taskViaApi(server.url, older)).status).toBe('in_progress');
expect((await taskViaApi(server.url, newer)).status).toBe('open');
}, 10_000);
/**
* 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');
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
expect((await taskViaApi(server.url, first)).status).toBe('in_progress');
expect((await taskViaApi(server.url, second)).status).toBe('open');
const patch = (id: string, body: Record<string, unknown>) =>
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');
}, 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');
const t2 = await createAndAssign(server.url, 'kimi: blocked', 'medium');
const ok = await claimViaApi(server.url, t1, AGENT);
expect(ok.status).toBe(200);
const blocked = await claimViaApi(server.url, t2, AGENT);
expect(blocked.status).toBe(400);
const body = (await blocked.json()) as { error?: string; message?: string };
expect(JSON.stringify(body)).toContain(t1);
// The blocked task stays open.
expect((await taskViaApi(server.url, t2)).status).toBe('open');
// Architect (default config: claude) may hold several threads.
const t3 = await createAndAssign(server.url, 'claude: thread one', 'medium');
const t4 = await createAndAssign(server.url, 'claude: thread two', 'medium');
expect((await claimViaApi(server.url, t3, 'claude')).status).toBe(200);
expect((await claimViaApi(server.url, t4, 'claude')).status).toBe(200);
}, 10_000);
it('(c2) claimTask unit: guard throws, same-task re-claim stays idempotent', () => {
const a = createTask(cwd, { title: 'kimi: a', role: 'implementer' });
const b = createTask(cwd, { title: 'kimi: b', role: 'implementer' });
claimTask(cwd, a.id, AGENT);
expect(() => claimTask(cwd, b.id, AGENT)).toThrowError(new RegExp(a.id));
// Idempotent re-claim of the SAME task is still a no-op.
expect(() => claimTask(cwd, a.id, AGENT)).not.toThrow();
// Architect exempt.
const c = createTask(cwd, { title: 'claude: x', role: 'architect' });
const d = createTask(cwd, { title: 'claude: y', role: 'architect' });
claimTask(cwd, c.id, 'claude');
expect(() => claimTask(cwd, d.id, 'claude')).not.toThrow();
const { task } = getTask(cwd, b.id);
expect(task.status).toBe('open');
});
});

View File

@ -8,7 +8,7 @@
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync } from 'fs'; import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { buildApp } from '../src/server/index.js'; import { buildApp } from '../src/server/index.js';
@ -17,8 +17,9 @@ import { eventBus } from '../src/server/events.js';
import type { AgentHubEvent } from '../src/server/events.js'; import type { AgentHubEvent } from '../src/server/events.js';
import { parseSSEBuffer, formatEvent, watchEvents } from '../src/cli/commands/watch.js'; import { parseSSEBuffer, formatEvent, watchEvents } from '../src/cli/commands/watch.js';
import { init } from '../src/cli/commands/init.js'; import { init } from '../src/cli/commands/init.js';
import { startEntityWatcher } from '../src/server/fsWatch.js'; import { getFsWatchErrors, resetFsWatchErrors, startEntityWatcher } from '../src/server/fsWatch.js';
import { createTask, claimTask } from '../src/core/services/taskService.js'; import { createTask, claimTask, listTasks } from '../src/core/services/taskService.js';
import { listHubEventsAfter } from '../src/server/eventLog.js';
// ─── 1. Unit: SSE buffer parser ────────────────────────────────────────────── // ─── 1. Unit: SSE buffer parser ──────────────────────────────────────────────
@ -74,6 +75,19 @@ describe('parseSSEBuffer', () => {
const { events } = parseSSEBuffer(buf); const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(0); expect(events).toHaveLength(0);
}); });
it('attaches the SSE id field as event.seq', () => {
const buf = 'id: 42\ndata: {"type":"task","action":"created","id":"TSK-0042"}\n\n';
const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ id: 'TSK-0042', seq: 42 });
});
it('ignores named task-log events in the generic change parser', () => {
const buf = 'event: task-log\ndata: {"taskId":"TSK-0001","text":"progress"}\n\n';
const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(0);
});
}); });
// ─── 2. Unit: formatEvent ──────────────────────────────────────────────────── // ─── 2. Unit: formatEvent ────────────────────────────────────────────────────
@ -168,15 +182,18 @@ describe('eventBus mutations', () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Bus task', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Bus task', role: 'implementer' } });
expect(collected).toHaveLength(1); expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'task', action: 'created', id: 'TSK-0001', title: 'Bus task', role: 'implementer' }); expect(collected[0]).toMatchObject({ type: 'task', action: 'created', id: 'TSK-0001', title: 'Bus task', role: 'implementer' });
expect(collected[0].seq).toBe(1);
}); });
it('emits task/updated when PATCH /tasks/:id changes status', async () => { it('emits task/updated when PATCH /tasks/:id changes status', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
collected.length = 0; // clear the created event await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
expect(collected).toHaveLength(1); expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', id: 'TSK-0001', status: 'done' }); expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', id: 'TSK-0001', status: 'done' });
expect(collected[0].seq).toBe(4);
}); });
it('emits task/updated with assignedTo when a task is claimed', async () => { it('emits task/updated with assignedTo when a task is claimed', async () => {
@ -190,6 +207,7 @@ describe('eventBus mutations', () => {
it('emits task/updated review when an implementer submits for review', async () => { it('emits task/updated review when an implementer submits for review', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
collected.length = 0; collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
expect(collected).toHaveLength(1); expect(collected).toHaveLength(1);
@ -198,6 +216,7 @@ describe('eventBus mutations', () => {
it('emits task/updated open when the architect reopens after review', async () => { it('emits task/updated open when the architect reopens after review', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
collected.length = 0; collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
@ -441,6 +460,67 @@ describe('SSE stream e2e', () => {
}, 5000); }, 5000);
}); });
describe('SSE durable replay', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-sse-replay-'));
init(cwd, { projectName: 'sse-replay-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close();
rmSync(cwd, { recursive: true, force: true });
});
it('persists monotonic ids and replays missed events after Last-Event-ID', async () => {
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'first', role: 'implementer' }),
});
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'second', role: 'implementer' }),
});
expect(listHubEventsAfter(cwd, 0).map((e) => e.seq)).toEqual([1, 2]);
const controller = new AbortController();
const res = await fetch(`${server.url}/events`, {
signal: controller.signal,
headers: { Accept: 'text/event-stream', 'Last-Event-ID': '1' },
});
expect(res.ok).toBe(true);
expect(res.body).toBeTruthy();
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
const deadline = Date.now() + 1500;
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
const parsed = parseSSEBuffer(buffer);
buffer = parsed.remaining;
if (parsed.events.length) {
await reader.cancel();
controller.abort();
expect(parsed.events).toHaveLength(1);
expect(parsed.events[0]).toMatchObject({ seq: 2, id: 'TSK-0002', title: 'second' });
return;
}
}
await reader.cancel().catch(() => undefined);
controller.abort();
throw new Error('Timed out waiting for replayed event');
}, 5000);
});
// ─── 5. fsWatch: CLI / direct file writes also emit (TSK-0006 part 2) ───────── // ─── 5. fsWatch: CLI / direct file writes also emit (TSK-0006 part 2) ─────────
// The whole point of the watcher: a `agenthub task create` (no --server) writes // The whole point of the watcher: a `agenthub task create` (no --server) writes
// the entity file directly, bypassing the REST emit path — yet a connected // the entity file directly, bypassing the REST emit path — yet a connected
@ -483,6 +563,7 @@ describe('fsWatch emits for non-REST (CLI/file) writes', () => {
stop(); stop();
eventBus.off('change', onChange); eventBus.off('change', onChange);
collected.length = 0; collected.length = 0;
resetFsWatchErrors();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}); });
@ -509,6 +590,37 @@ describe('fsWatch emits for non-REST (CLI/file) writes', () => {
expect(ev).toBeDefined(); expect(ev).toBeDefined();
expect(ev).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'windows-claude' }); expect(ev).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'windows-claude' });
}, 4000); }, 4000);
it('reindexes every settled direct task-file edit', async () => {
const task = createTask(cwd, { title: 'manual edit', role: 'implementer' });
const file = join(cwd, '.agenthub', 'tasks', `${task.id}.md`);
await waitFor(collected, (e) => e.id === task.id);
for (const status of ['in_progress', 'review']) {
const next = readFileSync(file, 'utf-8')
.replace(/^status: .*$/m, `status: ${status}`)
.replace(/^updatedAt: .*$/m, `updatedAt: ${new Date().toISOString()}`);
writeFileSync(file, next);
const deadline = Date.now() + 2000;
while (Date.now() < deadline && listTasks(cwd).find((t) => t.id === task.id)?.status !== status) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(listTasks(cwd).find((t) => t.id === task.id)?.status).toBe(status);
}
}, 6000);
it('retains the last good index row and exposes invalid frontmatter', async () => {
const task = createTask(cwd, { title: 'invalid edit', role: 'implementer' });
const file = join(cwd, '.agenthub', 'tasks', `${task.id}.md`);
await waitFor(collected, (e) => e.id === task.id);
writeFileSync(file, readFileSync(file, 'utf-8').replace(/^status: .*$/m, 'status: definitely_invalid'));
await new Promise((resolve) => setTimeout(resolve, 500));
expect(listTasks(cwd).find((t) => t.id === task.id)?.status).toBe('open');
expect(getFsWatchErrors()).toEqual([
expect.objectContaining({ filePath: file, error: expect.stringMatching(/status/i) }),
]);
}, 4000);
}); });
// ─── 6. fsWatch + REST dedup: each change is delivered exactly once ──────────── // ─── 6. fsWatch + REST dedup: each change is delivered exactly once ────────────

View File

@ -4,10 +4,11 @@ import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { import {
createTask, listTasks, getTask, createTask, listTasks, getTask,
claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, recordExternalTask, dispatchTask,
} from '../src/core/services/taskService.js'; } from '../src/core/services/taskService.js';
import { init } from '../src/cli/commands/init.js'; import { init } from '../src/cli/commands/init.js';
import { Index } from '../src/core/index.js'; import { Index } from '../src/core/index.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
describe('taskService', () => { describe('taskService', () => {
let cwd: string; let cwd: string;
@ -39,12 +40,15 @@ describe('taskService', () => {
expect(claimed.assignedTo).toBe('codex'); expect(claimed.assignedTo).toBe('codex');
expect(claimed.claimedBy).toBe('codex'); expect(claimed.claimedBy).toBe('codex');
expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' }); expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' });
reviewTask(cwd, task.id);
const done = doneTask(cwd, task.id); const done = doneTask(cwd, task.id);
expect(done.status).toBe('done'); expect(done.status).toBe('done');
expect(done.claimedBy).toBe('codex');
}); });
it('transitions a task to review', () => { it('transitions a task to review', () => {
const task = createTask(cwd, { title: 'C', role: 'implementer' }); const task = createTask(cwd, { title: 'C', role: 'implementer' });
claimTask(cwd, task.id, 'codex');
const inReview = reviewTask(cwd, task.id); const inReview = reviewTask(cwd, task.id);
expect(inReview.status).toBe('review'); expect(inReview.status).toBe('review');
// Verify index is updated // Verify index is updated
@ -55,6 +59,7 @@ describe('taskService', () => {
it('records an explicit reviewer separately from the assignee', () => { it('records an explicit reviewer separately from the assignee', () => {
const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' }); const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, task.id, 'codex');
const inReview = reviewTask(cwd, task.id, 'claude'); const inReview = reviewTask(cwd, task.id, 'claude');
expect(inReview.status).toBe('review'); expect(inReview.status).toBe('review');
expect(inReview.assignedTo).toBe('codex'); expect(inReview.assignedTo).toBe('codex');
@ -68,6 +73,7 @@ describe('taskService', () => {
it('uses the preferred reviewer when reviewTask is called without one', () => { it('uses the preferred reviewer when reviewTask is called without one', () => {
init(cwd, { projectName: 'reviewer-test', yes: true }); init(cwd, { projectName: 'reviewer-test', yes: true });
const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' }); const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, task.id, 'codex');
const inReview = reviewTask(cwd, task.id); const inReview = reviewTask(cwd, task.id);
expect(inReview.reviewer).toBe('claude'); expect(inReview.reviewer).toBe('claude');
}); });
@ -81,10 +87,13 @@ describe('taskService', () => {
}); });
it('reopens a task (any status → open)', () => { it('reopens a task (any status → open)', () => {
const task = createTask(cwd, { title: 'E', role: 'implementer' }); const task = createTask(cwd, { title: 'E', role: 'implementer', assignedTo: 'kimi' });
cancelTask(cwd, task.id); claimTask(cwd, task.id, 'kimi');
reviewTask(cwd, task.id);
const reopened = reopenTask(cwd, task.id); const reopened = reopenTask(cwd, task.id);
expect(reopened.status).toBe('open'); expect(reopened.status).toBe('open');
expect(reopened.assignedTo).toBe('kimi');
expect(reopened.claimedBy).toBeUndefined();
const listed = listTasks(cwd, { status: 'open' }); const listed = listTasks(cwd, { status: 'open' });
expect(listed).toHaveLength(1); expect(listed).toHaveLength(1);
}); });
@ -122,4 +131,23 @@ describe('taskService', () => {
it('deleteTask is idempotent (deleting a missing task does not throw)', () => { it('deleteTask is idempotent (deleting a missing task does not throw)', () => {
expect(() => deleteTask(cwd, 'TSK-9999')).not.toThrow(); expect(() => deleteTask(cwd, 'TSK-9999')).not.toThrow();
}); });
it('dispatches a session-less agent explicitly', () => {
init(cwd, { projectName: 'dispatch-test', yes: true });
const config = loadConfig(cwd);
config.agents = { ...(config.agents ?? {}), backyard: { role: 'implementer', dispatch: 'architect' } };
saveConfig(cwd, config);
const task = createTask(cwd, { title: 'Backend', assignedTo: 'backyard' });
expect(dispatchTask(cwd, task.id, 'backyard')).toMatchObject({
status: 'in_progress', assignedTo: 'backyard', claimedBy: 'backyard',
});
});
it('records external work directly as a provenance-marked done task', () => {
init(cwd, { projectName: 'record-test', yes: true });
const task = recordExternalTask(cwd, { title: 'Already shipped', doneBy: 'codex' });
expect(task).toMatchObject({ status: 'done', origin: 'external', doneBy: 'codex', claimedBy: 'codex' });
expect(task.recordedAt).toBeTruthy();
expect(getTask(cwd, task.id).body).toContain('Nachgetragen');
});
}); });

198
tests/watchdog.test.ts Normal file
View File

@ -0,0 +1,198 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
import { createTask, claimTask, getTask, reviewTask } from '../src/core/services/taskService.js';
import { listMessages } from '../src/core/services/messageService.js';
import { readTaskLog, appendTaskLog } from '../src/core/services/taskLogService.js';
import { startWatchdog, resetWatchdog } from '../src/server/watchdog.js';
import { eventBus, type AgentHubEvent } from '../src/server/events.js';
import { enterLoop, leaveLoop, resetPresence } from '../src/core/services/presenceService.js';
/**
* Watchdog thresholds (TSK-0226). Fake timers drive both the scan interval and
* the task ages (Date is mocked, so createdAt/updatedAt advance with the clock).
* Thresholds are shrunk via the `watchdog` config section to keep the test fast.
*/
describe('watchdog', () => {
let cwd: string;
let stop: (() => void) | undefined;
let events: AgentHubEvent[];
const onChange = (e: AgentHubEvent) => events.push(e);
// Small thresholds: interval 1s, high-prio unclaimed 2s, default 5s, stale 4s.
const INTERVAL = 1_000;
const HIGH = 2_000;
const DEFAULT = 5_000;
const STALE = 4_000;
const REVIEW = 3_000;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-watchdog-'));
init(cwd, { projectName: 'watchdog-test', yes: true });
const config = loadConfig(cwd);
config.watchdog = {
enabled: true,
intervalMs: INTERVAL,
unclaimedHighMs: HIGH,
unclaimedDefaultMs: DEFAULT,
staleInProgressMs: STALE,
staleReviewMs: REVIEW,
};
saveConfig(cwd, config);
resetWatchdog();
resetPresence();
events = [];
eventBus.on('change', onChange);
vi.useFakeTimers();
});
afterEach(() => {
stop?.();
stop = undefined;
eventBus.off('change', onChange);
vi.useRealTimers();
rmSync(cwd, { recursive: true, force: true });
});
const remindersFor = (agent: string) =>
listMessages(cwd).filter((m) => m.to === agent && m.text.startsWith('Reminder:'));
it('re-notifies an unclaimed high-priority task: SSE re-emit + unread reminder + warn log', async () => {
createTask(cwd, { title: 'Urgent fix', priority: 'high', assignedTo: 'codex' });
stop = startWatchdog(cwd);
// Below the threshold: nothing happens.
await vi.advanceTimersByTimeAsync(INTERVAL);
expect(remindersFor('codex')).toHaveLength(0);
// Past the 2s high-priority threshold (next tick at t=2s fires the alert).
await vi.advanceTimersByTimeAsync(INTERVAL + 100);
// 1. Reminder message lands as UNREAD so the assignee's work loop wakes.
const reminders = remindersFor('codex');
expect(reminders).toHaveLength(1);
expect(reminders[0].status).toBe('unread');
expect(reminders[0].text).toBe('Reminder: TSK-0001 wartet auf dich');
expect(reminders[0].taskId).toBe('TSK-0001');
// 2. The task event is re-emitted on the bus (SSE fan-out).
expect(events.some((e) => e.type === 'task' && e.id === 'TSK-0001' && e.assignedTo === 'codex')).toBe(true);
// 3. Board-visible warn line on the task's live console.
const log = readTaskLog(cwd, 'TSK-0001');
expect(log.some((l) => l.level === 'warn' && l.text.includes('Watchdog'))).toBe(true);
});
it('does not spam: re-alerts only after the threshold elapses again', async () => {
createTask(cwd, { title: 'Urgent fix', priority: 'critical', assignedTo: 'codex' });
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(HIGH + 100); // first alert at t≈2s
expect(remindersFor('codex')).toHaveLength(1);
// One more interval tick — still inside the cooldown window.
await vi.advanceTimersByTimeAsync(INTERVAL);
expect(remindersFor('codex')).toHaveLength(1);
// Threshold elapsed again since the last alert → second reminder.
await vi.advanceTimersByTimeAsync(HIGH);
expect(remindersFor('codex')).toHaveLength(2);
});
it('uses the longer threshold for medium/low priority tasks', async () => {
createTask(cwd, { title: 'Routine', priority: 'medium', assignedTo: 'kimi' });
stop = startWatchdog(cwd);
// Past the high-priority threshold but below the default one: no alert.
await vi.advanceTimersByTimeAsync(HIGH + INTERVAL);
expect(remindersFor('kimi')).toHaveLength(0);
// Past the 5s default threshold.
await vi.advanceTimersByTimeAsync(DEFAULT);
expect(remindersFor('kimi')).toHaveLength(1);
});
it('alerts the architect when an assigned task targets an agent known to be out of the work loop', async () => {
createTask(cwd, { title: 'Unheard task', priority: 'high', assignedTo: 'codex' });
enterLoop('codex');
leaveLoop('codex', 'client timeout');
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(HIGH + 100);
const alerts = listMessages(cwd).filter(
(m) => m.to === 'claude' && m.text.includes('not in agenthub_work'),
);
expect(alerts).toHaveLength(1);
expect(alerts[0].taskId).toBe('TSK-0001');
});
it('routes architect-dispatched agents to the architect once and never re-notifies the agent', async () => {
const config = loadConfig(cwd);
config.agents = {
...(config.agents ?? {}),
claude: { role: 'architect', dispatch: 'loop' },
backyard: { role: 'implementer', dispatch: 'architect' },
};
saveConfig(cwd, config);
createTask(cwd, { title: 'Backend task', priority: 'high', assignedTo: 'backyard' });
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(HIGH + 100);
expect(remindersFor('backyard')).toHaveLength(0);
const notices = () => listMessages(cwd).filter((m) => m.to === 'claude' && m.text.includes('wird von dir gestartet'));
expect(notices()).toHaveLength(1);
await vi.advanceTimersByTimeAsync(HIGH * 2);
expect(notices()).toHaveLength(1);
});
it('alerts the architect about a silent in_progress task — without reassigning', async () => {
createTask(cwd, { title: 'WIP', priority: 'high', assignedTo: 'kimi' });
claimTask(cwd, 'TSK-0001', 'kimi');
stop = startWatchdog(cwd);
// No task-log lines at all; silent past the stale threshold.
await vi.advanceTimersByTimeAsync(STALE + INTERVAL);
// Architect (init default: claude) got the alert; the assignee got none.
const architectAlerts = listMessages(cwd).filter((m) => m.to === 'claude' && m.text.startsWith('Watchdog:'));
expect(architectAlerts).toHaveLength(1);
expect(architectAlerts[0].status).toBe('unread');
expect(architectAlerts[0].text).toContain('TSK-0001');
expect(remindersFor('kimi')).toHaveLength(0);
// Visibility only: the task still belongs to kimi (no auto-reassign).
const { task } = getTask(cwd, 'TSK-0001');
expect(task.status).toBe('in_progress');
expect(task.assignedTo).toBe('kimi');
expect(task.claimedBy).toBe('kimi');
});
it('a fresh task-log line keeps an in_progress task from being flagged stale', async () => {
createTask(cwd, { title: 'Chatty WIP', priority: 'high', assignedTo: 'kimi' });
claimTask(cwd, 'TSK-0001', 'kimi');
stop = startWatchdog(cwd);
// Log activity well inside the stale window, repeatedly: never flagged.
for (let i = 0; i < 4; i++) {
await vi.advanceTimersByTimeAsync(STALE - 2 * INTERVAL); // 2s < 4s stale window
appendTaskLog(cwd, 'TSK-0001', { text: `progress ${i}`, agent: 'kimi' });
}
expect(listMessages(cwd).filter((m) => m.text.startsWith('Watchdog:'))).toHaveLength(0);
});
it('alerts the architect when a review waits too long', async () => {
createTask(cwd, { title: 'Review me', assignedTo: 'kimi' });
claimTask(cwd, 'TSK-0001', 'kimi');
reviewTask(cwd, 'TSK-0001');
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(REVIEW + INTERVAL);
const alerts = listMessages(cwd).filter(
(m) => m.to === 'claude' && m.text.includes('waited in review'),
);
expect(alerts).toHaveLength(1);
expect(alerts[0].taskId).toBe('TSK-0001');
});
});

View File

@ -131,12 +131,14 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
await workDone; // resolves because the message woke the loop (not on timeout) await workDone; // resolves because the message woke the loop (not on timeout)
// The loop drained + marked the message read — proving it woke on the message, // The loop drained + surfaced the message (unread → delivered) — proving it
// not that it merely timed out (a timeout would leave it delivered/unread). // woke on the message, not that it merely timed out (a timeout would leave
// it unread). Since TSK-0224 the loop no longer auto-marks read: the message
// stays visible in the inbox until an explicit ack/read.
const inbox = (await fetch(`${server.url}/messages?agent=kimi`).then((r) => r.json())) as Array<{ text: string; status: string }>; const inbox = (await fetch(`${server.url}/messages?agent=kimi`).then((r) => r.json())) as Array<{ text: string; status: string }>;
const m = inbox.find((x) => x.text.includes('quick question')); const m = inbox.find((x) => x.text.includes('quick question'));
expect(m).toBeDefined(); expect(m).toBeDefined();
expect(m?.status).toBe('read'); expect(m?.status).toBe('delivered');
}, 7000); }, 7000);
it('wakes and re-claims when a submitted task is reopened', async () => { it('wakes and re-claims when a submitted task is reopened', async () => {