Author SHA1 Message Date
RichardandClaude Opus 5.5 f627918729 Lagebild, Stufe 0: Teamuebersicht in der WebUI (#711)
Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es,
auf einen Blick. Stufe 0 kommt ohne Einheiten aus; mit #683 werden die Agenten
zu aufklappbaren Containern, und die Ansicht faellt mit #688 zusammen.

Kern:
- AgentActivity: fuehrt die laufenden Laeufe (Beginn, Quelle, Aufgabe,
  Aufrufer) und die letzten Verbindungen (begrenzt auf 500), meldet Start,
  Ende und Verbindung als Ereignis. Vorher war nur ein Lauf ueber ChatAsync
  "laufend"; ein frischer Lauf (@new) war unsichtbar, und niemand hielt fest,
  wer wen gerufen hatte.
- Die Engine meldet beide Einstiege an; "arbeitet" beginnt hinter dem
  Agententor.

Anwendung:
- TeamOverview stellt den Lagebericht zusammen. Die Zustaende entscheidet der
  Code aus erfassten Tatsachen: laufender Lauf, offene Freigabe, Status des
  letzten Belegs, Laufzeit gegen den Median des Agenten (haengt ab dem
  Dreifachen, fruehestens nach 5 Minuten, ohne Verlauf nach 15). Rangfolge:
  haengt, wartet auf Freigabe, arbeitet; Problem und Budget nur, solange
  nichts Neues laeuft.

Web:
- GET /api/team, Ereignis "activity" (started, finished, link); das Ereignis
  "run" traegt jetzt die Agenten-Id.
- Ansicht #/team mit Cytoscape.js und ELK (bei Bedarf geladen): Layout aus
  der Struktur statt von Hand, Farbe und Symbol je Zustand, Kanten verblassen
  nach 30 s, Klick oeffnet eine Seitenleiste (laufende Laeufe, Freigaben,
  letzter Lauf im Wortlaut) mit "Freigaben oeffnen" vorne, danach Chat und
  Laeufe. Sprachpakete en/de.
- Ein Layout, das lief, solange die Flaeche noch 0 px breit war, wird neu
  gerechnet, sobald sie eine Breite hat (im Pruefgestell gefunden).

Geprueft im Browser mit einem Pruefgestell (API und Ereignisstrom durch
Attrappen ersetzt): sieben Agenten in allen Zustaenden, Seitenleiste,
Live-Kante und Verblassen.

Tests: AgentActivityTests (8), TeamOverviewTests (14). 1.283 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:34:46 +02:00
RichardandClaude Opus 5.5 d587dc3f20 gitattributes: eingecheckte Fremdbibliotheken ohne Zeilenende-Umschreibung
Mit text=auto haette Git die Dateien unter Windows beim Auschecken auf CRLF
umgeschrieben, und die SHA-256-Werte in vendor/README.md stimmten nicht mehr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:21:39 +02:00
RichardandClaude Opus 5.5 991a901cab WebUI: Cytoscape.js, ELK und cytoscape-elk eingecheckt (#711)
Fuer die Teamuebersicht (Lagebild, #711), auf Freigabe des Nutzers. Als Dateien
im wwwroot statt ueber ein CDN, weil die Zielmaschine womoeglich kein Internet
hat (WebUI-Leitfaden).

- cytoscape 3.34.3 (MIT), elkjs 0.9.3 (EPL-2.0), cytoscape-elk 2.3.0 (MIT)
- aus der npm-Registry, Tarballs gegen dist.integrity (SHA-512) geprueft
- elkjs bewusst 0.9.3: cytoscape-elk 2.3.0 verlangt ^0.9.3
- Lizenztexte daneben, vendor/README.md mit Version, Herkunft und SHA-256

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:21:25 +02:00
RichardandClaude Opus 5.5 0f49e04261 Test: wackelnde Wanduhr-Pruefung aus dem Parallelitaetstest entfernt (#551)
Freigegebene_Werkzeuge_laufen_tatsaechlich_gleichzeitig pruefte neben dem
Hoechststand gleichzeitiger Aufrufe auch eine Laufzeit unter 750 ms. Am
2026-09-24 schlug sie nach dem Merge von #716 mit 927 ms fehl, als die
Testprojekte parallel liefen; beim erneuten Lauf war sie gruen. Die Zeit misst
die Last der Maschine, nicht die Engine. Der Hoechststand bleibt als Nachweis.
Vier volle Durchlaeufe danach ohne Fehler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:16:56 +02:00
RichardandClaude Opus 5.5 fe0eb09d7c Merge feat/716-budgetende: Ausfall vermerken, Reservierung, Spielraum, Vorschau (#716)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:12:44 +02:00
RichardandClaude Opus 5.5 32383119da Budgetende, Stufe 4: den Engpass melden, bevor er eintritt (#716)
- TaskSchedule.OccurrenceTimesBetween liefert die Zeitpunkte der restlichen
  Laeufe; OccurrencesBetween zaehlt sie nur noch.
- BudgetHeadroom implementiert IBudgetOutlook: Die restlichen geplanten Laeufe
  des Tages werden in zeitlicher Reihenfolge durchgegangen, je Agent mit
  Grenze und fuer die Instanz, bis zum ersten Lauf, der das Budget verbraucht
  vorfaende. Das spiegelt die Pruefung beim Start eines Laufs. Spaetwerk
  zaehlt nicht mit, es laeuft ohnehin nur im Spielraum.
- Der Scanner schaut alle 30 Minuten voraus und nennt je Agent und Tag einmal,
  wann das Budget ausgeht und wie viele der geplanten Laeufe dann ausfallen,
  solange noch Zeit ist, nachzulegen oder zu entscheiden, was wartet.

Die Meldung steht vorerst im Protokoll (Warnung). Der Weg zum Menschen ueber
einen Kanal gehoert zu #646, die Anzeige ins Lagebild (#711).

Tests: 4 neu fuer die Vorschau. 1.261 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:12:44 +02:00
RichardandClaude Opus 5.5 9cf9a2cfc2 Budgetende, Stufe 3: Nachholen nur im Spielraum des Tages, keine Kappung bei 500 (#716)
- TaskSchedule.OccurrencesBetween zaehlt, wie oft eine Aufgabe bis zu einem
  Zeitpunkt noch laeuft, einschliesslich eines jetzt faelligen Laufs.
- BudgetHeadroom (ILateRunGate): Ein mitgenommener oder nachzuholender Lauf
  startet nur, wenn er in den Spielraum passt: Grenze minus Verbrauch minus
  Reservierung minus Bedarf der restlichen geplanten Laeufe bis Mitternacht
  minus 10 % Reserve. Das gilt je Agent und je Instanz, fuer Kosten und Tokens.
  Anderes Spaetwerk zaehlt nicht als Bedarf, sonst blockierten sich
  mitgenommene Aufgaben gegenseitig.
- Der Scanner fragt den Spielraum nur fuer Spaetwerk und protokolliert, wenn es
  warten muss. Die Engine gibt ihren BudgetGuard heraus, damit Scanner und
  Engine dieselben Reservierungen sehen.
- Ersetzt den pauschalen 25-%-Anteil aus dem Konzept (dort nachgezogen): Ein
  fester Anteil ist an vollen Tagen zu viel, an ruhigen zu wenig.

Drei weitere stille Kappungen bei 500 beseitigt (ListAllAsync, ungekappt):
Kostenprognose (Aufgaben ueber 500 fehlten), Waisenabgleich beim Import und
task_find aus #674, wo Dubletten unter aelteren Aufgaben mit niedriger
Prioritaet unsichtbar wurden.

Tests: BudgetHeadroomTests (9), Scanner-Pforte (1), Dublette hinter 500
Aufgaben (1). 1.257 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:10:40 +02:00
RichardandClaude Opus 5.5 8a83a67fb4 Budgetende, Stufe 2: laufende Laeufe reservieren ihr Budget (#716)
Der offene Rest aus #682: Jeder Lauf pruefte das Budget einzeln gegen den
bisher erfassten Verbrauch. Laufende Laeufe waren unsichtbar, gleichzeitig
startende passierten die Pruefung gemeinsam und ueberzogen gemeinsam.

- BudgetGuard.ReserveAsync prueft und reserviert unter einer Sperre. Die
  Reservierung ist der Mittelwert der letzten 20 Laeufe des Agenten (Laeufe
  ohne Verbrauch zaehlen nicht mit) und gilt, bis der Verbrauch des Laufs
  erfasst ist.
- Gegen die Grenze zaehlt: verbraucht + in laufenden Laeufen reserviert. Die
  eigene Schaetzung eines Laufs zaehlt bewusst nicht mit; ein einzelner Lauf
  wird weiter nur abgelehnt, wenn das Budget schon verbraucht ist. Sonst fiele
  eine kleine Aufgabe wegen eines grossen Durchschnitts aus.
- Gilt fuer Kosten- und Tokengrenze, je Agent und je Instanz.
- Die Ablehnungsmeldung nennt den reservierten Anteil, sonst wirkte "0,60 von
  1,00 USD verbraucht" als Ablehnungsgrund falsch.
- IUsageRepository.GetRecentForAgentAsync, mit leerer Vorgabe im Interface,
  damit bestehende Attrappen unberuehrt bleiben.

Tests: BudgetReservationTests (8 neu), darunter zehn gleichzeitige Starts, von
denen genau vier passieren. 1.246 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:06:07 +02:00
RichardandClaude Opus 5.5 5f3a85595b Budgetende, Stufe 1: Ausfall vermerken, Regel je Agent, mitnehmen statt anrennen (#716)
Bisher teilte sich eine Budgetablehnung den Ausgang NichtGelaufen mit dem
Herunterfahren. Einmalige Aufgaben wurden den Rest des Tages alle 30 Minuten
erneut abgelehnt (jedes Mal ein Beleg) und liefen nach Mitternacht still zu
Lasten des neuen Tages. Wiederkehrende verfielen still.

- TaskLauf.KeinBudget als eigener Ausgang; der Dispatcher bildet
  BudgetExceeded darauf ab.
- Vier Felder an der Aufgabe: BudgetMissCount, LastBudgetMissAt,
  DeferredUntil, CatchUpPending (Spalten werden fuer bestehende Datenbanken
  nachgeruestet).
- Einmalige Aufgaben werden mitgenommen: Der Termin bleibt erhalten, faellig
  ist die Aufgabe wieder ab der naechsten lokalen Mitternacht, nicht vorher.
- Wiederkehrende Aufgaben folgen der Regel des Agenten
  (budget.whenExhausted): expire vermerkt den Ausfall und laesst den Termin
  verfallen; catchUpOnce fasst alle verpassten Termine zu einem Nachholauf am
  naechsten Tag zusammen. Ein regulaer faelliger Termin deckt das Nachholen
  mit ab, es bleibt bei einem Lauf.
- Der neue Tag hat Vorfahrt: Mitgenommenes und Nachholaeufe kommen nach allem
  regulaer Faelligen, unabhaengig von der Prioritaet.
- Beim ersten Takt eines neuen Tages eine Zusammenfassung je Agent im Log.
- Sichtbar: Die Aufgabenkarte der WebUI zeigt Ausfaelle, Mitnahme und
  Nachholen; die Regel ist im Desktop je Agent einstellbar. Nebenbei sind
  "Next:"/"Last:" auf der Karte ins Sprachpaket gewandert.

Stufen 2 bis 4 (Reservierung, Spielraum statt pauschalem Anteil, Vorbeugen)
folgen.

Tests: BudgetMissTests (9 neu). 1.238 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:03:15 +02:00
RichardandClaude Opus 5.5 b53396a754 Organisation-Konzept: Einstellungen an der Einheit ohne Uebertrumpfen
Festlegung des Nutzers: Einheiten-Einstellungen sind entweder Sammelwerte, die in
die Agenten geschrieben werden (Vorgabe nur fuer neue Mitglieder, Warnung bei
Ausreissern wie Spezialisten), oder Regeln ohne Gegenstueck am Agenten
(Rechte-Obergrenze, beim Schreiben geprueft; Parallelitaet je Einheit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 11:55:19 +02:00
RichardandClaude Opus 5.5 4415b26e7d Organisation-Konzept: Fuellarbeit und Grossaufgaben (#717)
Zeitlich flexible Arbeit mit Frist statt Termin fuellt ungenutztes Tagesbudget
(Backfilling); ob Platz ist, entscheidet der Code aus Restbudget und Prognose.
Grossaufgaben werden geteilt oder bekommen ein vom Menschen freigegebenes
Sonderbudget als eigenen Topf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 11:24:28 +02:00
51 changed files with 10354 additions and 73 deletions
+3
View File
@@ -24,3 +24,6 @@
*.jpg binary
*.zip binary
*.dll binary
# Vendored third-party files stay byte for byte as published (checksums in vendor/README.md)
src/ClawdDotNet.Manager.Web/wwwroot/vendor/** -text
+82 -31
View File
@@ -1,6 +1,6 @@
# Organisation — 50 bis 100 Agenten geordnet zusammenarbeiten lassen
*Stand: 24. September 2026 (ergänzt um 7a, 9a, 10a, Einheiten ohne eigene Konfiguration, Budgetende). Konzept, noch nicht gebaut.*
*Stand: 24. September 2026 (ergänzt um 7a, 9a, 10a, Einstellungen an der Einheit, Budgetende, Füllarbeit). Konzept, noch nicht gebaut.*
> Herkunft: Richtungsentscheidung des Nutzers am 2026-09-21. Die bisherige Regel im
> [InstanceSetupGuide](InstanceSetupGuide.md) („mehr als 5 Agenten kritisch
@@ -123,37 +123,46 @@ Instanz „Onlinehandel"
| **Freigaben** | ein Mensch | die Leitung innerhalb ihrer Grenzen, der Mensch für alles darüber (Geld, Außenwirkung) |
| **Parallelität** | unbegrenzt | je Einheit und je Instanz eine Zahl gleichzeitiger Läufe, der Rest wartet |
### Eine Einheit hat keine eigene Konfiguration
### Einstellungen an der Einheit: nie zwei Werte, die sich übertrumpfen
*Festgelegt am 2026-09-24 durch den Nutzer.*
*Festgelegt am 2026-09-24 durch den Nutzer, in zwei Schritten.*
Was man „für die ganze Einheit" einstellt, wird **nicht an der Einheit gespeichert,
sondern gesammelt in jeden ihrer Agenten geschrieben**. Wer für das Marketing das
Tagesbudget je Agent von 5 auf 10 $ hebt, ändert zehn Agentenkonfigurationen auf einmal.
Eine Einheiten-Einstellung, die im Hintergrund gilt, gibt es nicht.
Einstellungen lassen sich an der Einheit speichern und bearbeiten. **Aber es gibt nie
zwei Werte für dieselbe Sache, von denen einer den anderen zur Laufzeit übertrumpft.**
Daraus folgen genau zwei Arten von Einheiten-Einstellungen:
Der Gewinn: **Was am Agenten steht, gilt.** Man muss nie eine Kette von Einheiten
hinaufrechnen, um zu wissen, welche Grenze ein Agent wirklich hat. Und ein Umzug ändert
nichts, was man nicht sieht.
**1. Sammelwerte, die in die Agenten geschrieben werden.** Alles, was es auch am Agenten
gibt: Tagesbudget, Modell, Verhalten beim Budgetende (#716). Wer für das Marketing das
Tagesbudget setzt, schreibt den Wert in jeden Agenten darunter. Die Einheit merkt sich den
Wert nur als **Vorgabe für Agenten, die neu hinzukommen**, nie als Grenze, die im
Hintergrund gilt. Danach steht am Agenten, was gilt, und nur dort.
Was die Sammeländerung braucht: eine Vorschau, welche Agenten sich wie ändern, einen
Audit-Eintrag je Agent und eine Entscheidung, was mit Agenten geschieht, die schon einen
abweichenden Wert haben. Vorschlag: Die Vorschau zeigt sie gesondert, und der Mensch
entscheidet, ob sie mit überschrieben werden.
- **Vorschau** vor dem Schreiben: welche Agenten sich wie ändern, Audit-Eintrag je Agent.
- **Warnung bei Ausreißern:** Weicht der bisherige Wert eines Agenten massiv von denen
seiner Kollegen ab, wird er in der Vorschau hervorgehoben und ist standardmäßig
**nicht** angehakt. Gemeint ist der Spezialist, der bewusst viele Tokens verbraucht und
sonst mit dem Wert seiner Kollegen kaputtkonfiguriert würde. Der Mensch entscheidet je
Ausreißer, ob er mit überschrieben wird.
- Die Vorschau bietet zwei Rechenwege: **ein Wert je Agent** („jeder bekommt 10 $") oder
**ein Gesamtbetrag, verteilt** („das Marketing bekommt 100 $"). Beim Verteilen
vorgeschlagen: im Verhältnis der bisherigen Werte, damit ein Spezialist seinen
größeren Anteil behält.
**Noch zu klären, weil es drei Stellen dieses Konzepts berührt:**
**2. Regeln, die es nur an der Einheit gibt.** Sie haben kein Gegenstück am Agenten und
können deshalb mit nichts konkurrieren:
- **Rechte.** Die Tabelle oben sagt „vererbt von der Einheit; was die Einheit nicht darf,
kann ein Mitglied nicht bekommen". Das ist eine Obergrenze an der Einheit, also genau
eine gespeicherte Einheiten-Konfiguration. Mit Sammeländerung statt Vererbung entfällt
diese Obergrenze: Einem einzelnen Agenten ließe sich später mehr geben als seinen
Kollegen. Offen ist, ob das hingenommen wird oder ob die Obergrenze die eine Ausnahme ist.
- **Parallelität.** Eine Zahl gleichzeitiger Läufe *je Einheit* lässt sich nicht auf
Agenten verteilen: Jeder Agent läuft ohnehin nur einmal gleichzeitig. Entweder sie
bleibt instanzweit (so ist #682 gebaut), oder sie ist eine zweite Ausnahme.
- **Grenzen der Leitung** (Abschnitt 12) standen bisher „an der Einheit, nicht an der
Person der Leitung". Nach dieser Festlegung stehen sie am Leitungsagenten. Beim
Wechsel der Leitung müssen sie dann mit umziehen.
- **Rechte-Obergrenze.** „Was die Einheit nicht darf, kann ein Mitglied nicht bekommen"
bleibt, weil sie eine Sicherheitsgrenze ist. Sie wird **beim Schreiben** geprüft, nicht
zur Laufzeit darübergelegt: Eine Rechteänderung am Agenten, die über die Obergrenze
hinausginge, wird abgewiesen. Senkt man die Obergrenze, zeigt die Vorschau, welchen
Agenten dadurch Rechte entzogen werden, und schreibt es in sie hinein. Damit gilt auch
hier: Was am Agenten steht, gilt.
- **Parallelität je Einheit** (#685). Ein Agent läuft ohnehin nur einmal gleichzeitig,
die Zahl gehört also nur der Einheit. Bis #685 gebaut ist, gilt sie instanzweit
(`maxParallelTaskRuns`, #682).
**Grenzen der Leitung** (Abschnitt 12) sind ein Sammelwert: Sie stehen am
Leitungsagenten. Beim Wechsel der Leitung zeigt die Vorschau, was mit umzieht.
### Beliebig tief, und beweglich
@@ -945,9 +954,14 @@ scheitert:
| `carry_over` | wird mitgenommen | einmalige Aufgaben: echte Arbeit, die nicht verschwinden darf |
Dazu gilt: **Der neue Tag hat Vorfahrt.** Nachholläufe kommen nach den regulär fälligen,
unterhalb jeder Priorität, und dürfen höchstens einen Anteil des neuen Tagesbudgets
verbrauchen (Vorgabe etwa 25 %). Ohne diese Regel schiebt ein einziger überzogener Tag
seinen Ausfall Tag für Tag vor sich her, statt ihn aufzulösen. Eine einmalige Aufgabe, die
unterhalb jeder Priorität, und sie nutzen nur den **Spielraum**, den der Rest des Tages
nicht braucht: Grenze minus Verbrauch, minus die Reservierung laufender Läufe, minus der
Bedarf der noch geplanten Läufe bis Mitternacht, minus 10 % Reserve. Ohne diese Regel
schiebt ein einziger überzogener Tag seinen Ausfall Tag für Tag vor sich her, statt ihn
aufzulösen. *Beim Bau am 2026-09-24 geändert:* Das Konzept sah hier zunächst einen
pauschalen Anteil von 25 % vor. Ein fester Anteil ist an einem vollen Tag zu viel und an
einem ruhigen unnötig wenig; der Spielraum folgt dem tatsächlichen Plan, und dieselbe
Rechnung trägt später die Füllarbeit. Eine einmalige Aufgabe, die
nach einigen Tagen immer noch nicht drankam, geht an den Menschen: Was nie läuft, darf
nicht unbemerkt liegen bleiben.
@@ -966,6 +980,42 @@ prüft jeder Lauf das Budget einzeln vor dem Start, und bis zu `maxParallelTaskR
können es gemeinsam überschreiten. Erst mit einer Reservierung vor dem Lauf ist der
Ausfall vorhersagbar statt eine Frage, welcher Lauf zuerst zugreift.
### Füllarbeit und Großaufgaben
*Ergänzt am 2026-09-24 auf Vorschlag des Nutzers. Item #717.*
Der vorige Abschnitt regelt, was liegen bleibt, wenn das Budget nicht reicht. Die
Kehrseite: An den meisten Tagen bleibt Budget übrig, und das verfällt.
**Füllarbeit** ist Arbeit, die umfangreich, aber nicht eilig ist. Ob sie heute, morgen
oder übermorgen läuft, ist gleich. Sie bekommt statt eines Termins eine **Frist
„spätestens bis"** und läuft, wenn Platz ist. Rechenzentren machen das seit Jahrzehnten
so und nennen es *Backfilling*. Ob Platz ist, entscheidet der Code:
- Keine regulär fällige Aufgabe des Agenten wartet.
- Das Restbudget des Tages, abzüglich der Prognose für die noch geplanten Läufe (#608) und
eines Sicherheitsaufschlags, reicht für die Schätzung der Füllarbeit.
- Bevorzugt spät am Tag, weil die Prognose dann am wenigsten Unsicherheit hat.
Rückt die Frist näher und es fand sich nie Platz, wird der Mensch gefragt: Budget
erhöhen oder Frist verschieben. Eine Füllarbeit überschreitet ihre Frist nie still.
**Großaufgaben** sind Aufgaben, deren Schätzung einen großen Teil des Tagesbudgets
übersteigt (Vorgabe etwa 50 %). Sie werden nicht einfach angefangen. Der Agent schlägt
einen von zwei Wegen vor:
1. **Teilen.** Unteraufgaben, jede ein eigener Lauf, verbunden über die Elternaufgabe
(Zielkette, Abschnitt 9a). Die Teile können dann Füllarbeit sein.
2. **Sonderbudget.** Ein Betrag für genau diese Aufgabe samt Unteraufgaben, vom Menschen
freigegeben. Es ist ein eigener Topf: Es hebt die Tagesgrenze des Agenten nicht an und
ist in der Kostenanalyse gesondert sichtbar. Damit ist es die einzige Ausnahme von
„Budgets bleiben am Agenten", gebunden an eine Aufgabe und nie still.
**Ehrlich schätzen.** Kosten lassen sich vorab nicht genau wissen, und Modelle schätzen
ihren eigenen Aufwand schlecht. Wo es Verlauf gibt, schätzt der Code aus dem Verlauf.
Sonst schätzt der Agent, und die Zahl ist als Schätzung gekennzeichnet. Nach dem ersten
Teillauf ersetzt der gemessene Wert die Schätzung.
### Was die Auswertung dafür braucht
- **Die Einheit als Dimension** in Filtern und Zeitverlauf der Verbrauchsansicht (#660) —
@@ -1295,8 +1345,8 @@ Gegenlesen am 2026-09-22 als zu grob aufgefallen:
innerhalb ihrer Grenzen liegt", ohne zu sagen, woraus eine Grenze besteht. Vorschlag:
Betrag je Vorgang, Betrag je Tag, erlaubte Werkzeugklassen, Außenwirkung ja/nein — und
alles davon an der Einheit, nicht an der Person der Leitung (Stufe 8). *Überholt am
2026-09-24: Einheiten speichern keine eigene Konfiguration (Abschnitt 3), die Grenzen
stehen damit am Leitungsagenten.*
2026-09-24: Grenzen sind ein Sammelwert und stehen am Leitungsagenten (Abschnitt 3,
„Einstellungen an der Einheit").*
- **Wie wird ein Beschluss wirksam?** „Bindend für alles unterhalb" ist ein Wort ohne
Maschinerie, solange niemand nachsieht. Vorschlag: Das Eskalationswerkzeug sucht von
sich aus, bevor es einen Vorgang anlegt — genau wie `task_find` vor `task_create`
@@ -1347,6 +1397,7 @@ nur nicht selbst an.
| #687 | Freigabe durch die Leitung innerhalb von Grenzen |
| #688 | Organigramm in Oberfläche und WebUI |
| #716 | Was liegen bleibt, wenn das Budget endet (Ausfall, Nachholen, Reservierung) |
| #717 | Füllarbeit und Großaufgaben: ungenutztes Budget füllen, teilen oder Sonderbudget |
| #711 | Lagebild (Teamübersicht): wer arbeitet, wer spricht mit wem, wo klemmt es |
| #712 | Fremde Laufzeiten: Claude Code, Codex, Befehl, HTTP |
| #713 | ClawdDotNet als MCP-Server für fremde Laufzeiten |
+21 -1
View File
@@ -3,6 +3,7 @@ using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Logging;
@@ -112,6 +113,9 @@ public sealed class AppHost : IAsyncDisposable
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents);
public TelegramClientManager? TelegramClient { get; private init; }
public OpenRouterStatusService? Status { get; private init; }
@@ -725,8 +729,24 @@ public sealed class AppHost : IAsyncDisposable
var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
var headroom = engine.BudgetGuard is { } guard
? new BudgetHeadroom(guard, () => instance.Agents, () => engine.InstanceBudget)
: null;
var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory,
maxParallelRuns: instance.MaxParallelTaskRuns);
maxParallelRuns: instance.MaxParallelTaskRuns,
budgetMissPolicy: task =>
{
// #716: The agent's rule. Bare '@new' names no agent — then the default applies.
var agentId = TaskAssignee.AgentId(task.Assignee);
return instance.Agents
.FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
?.Budget.MissPolicy ?? BudgetMissPolicy.Expire;
},
// #716: Late work waits for headroom the rest of the day does not need, and a
// day the budget will not last is named while there is still time to act.
lateRunGate: headroom,
budgetOutlook: headroom);
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
_ = Task.Run(async () =>
@@ -187,7 +187,8 @@ public sealed class TaskAdministration(
if (board is null)
return null;
var aufgaben = await board.ListAsync(new TaskQuery { Limit = 500 }, ct);
// #716: ungekappt - vorher fehlten ab 500 Aufgaben die übrigen in der Prognose.
var aufgaben = await board.ListAllAsync(ct);
var belege = new Dictionary<string, IReadOnlyList<RunReceipt>>(StringComparer.Ordinal);
if (audit is not null)
@@ -0,0 +1,170 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Staging;
namespace ClawdDotNet.App.Services;
/// <summary>What an agent is doing, as the team overview shows it (#711).</summary>
public enum TeamState
{
/// <summary>Nothing running, nothing open, the last run went fine (or there was none).</summary>
Idle,
/// <summary>At least one run in progress.</summary>
Working,
/// <summary>A run has taken much longer than this agent's runs usually take.</summary>
Stuck,
/// <summary>Tool calls of this agent wait for a human decision.</summary>
WaitingForApproval,
/// <summary>The last run failed, hit a limit or found the target busy.</summary>
Problem,
/// <summary>The last run was refused because the budget is used up.</summary>
BudgetExhausted
}
/// <summary>One agent in the team overview.</summary>
public sealed record TeamMember(
string AgentId,
string DisplayName,
string Model,
TeamState State,
IReadOnlyList<ActiveRun> Runs,
int PendingApprovals,
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs);
/// <summary>The whole picture at one moment: every agent, and who reached whom recently.</summary>
public sealed record TeamSnapshot(IReadOnlyList<TeamMember> Members, IReadOnlyList<AgentLink> RecentLinks, DateTime AtUtc);
/// <summary>
/// #711: The team overview — who is working, who reached whom, where something is stuck.
///
/// <para><b>Detecting is code, interpreting is model</b> (Organisation-Konzept, 10a). Every
/// state here comes from a fact the instance recorded: a run in progress, an open staged
/// call, the status of the last receipt. Nothing is judged by a model, so the picture is
/// the same for everyone who looks and costs nothing to draw.</para>
///
/// <para><b>Order of states.</b> When several apply, the one that needs someone first wins:
/// stuck, then waiting for approval, then working; a problem or an exhausted budget only
/// shows while nothing new is running — a new run is the attempt to get past it.</para>
/// </summary>
public sealed class TeamOverview(
AgentEngine? engine,
StagingService? staging,
IAuditRepository? audit,
IUsageRepository? usage,
Func<IReadOnlyList<AgentConfig>> agents,
TimeProvider? clock = null)
{
/// <summary>How far back the last receipt of each agent is looked for.</summary>
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
/// <summary>How long recent links stay in the snapshot — the overview fades them out.</summary>
public static readonly TimeSpan LinkWindow = TimeSpan.FromMinutes(10);
/// <summary>A run counts as stuck after this many times the agent's typical duration…</summary>
public const int StuckFactor = 3;
/// <summary>…but never before this — short runs vary a lot in relative terms.</summary>
public static readonly TimeSpan StuckFloor = TimeSpan.FromMinutes(5);
/// <summary>Without history, a run counts as stuck after this long.</summary>
public static readonly TimeSpan StuckWithoutHistory = TimeSpan.FromMinutes(15);
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
public async Task<TeamSnapshot> SnapshotAsync(CancellationToken ct)
{
var now = _clock.GetUtcNow().UtcDateTime;
var configured = agents();
var active = engine?.Activity.ActiveRuns() ?? [];
var links = engine?.Activity.RecentLinks(LinkWindow) ?? [];
var pending = staging is null ? [] : await staging.ListPendingAsync(ct);
var pendingByAgent = pending
.GroupBy(c => c.AgentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var lastReceipt = new Dictionary<string, RunReceipt>(StringComparer.OrdinalIgnoreCase);
if (audit is not null)
{
var receipts = await audit.ListReceiptsAsync(now - ReceiptWindow, now, 5_000, ct);
foreach (var receipt in receipts)
{
if (!lastReceipt.TryGetValue(receipt.AgentId, out var known) || receipt.OccurredAt > known.OccurredAt)
lastReceipt[receipt.AgentId] = receipt;
}
}
var members = new List<TeamMember>();
foreach (var agent in configured)
{
var runs = active
.Where(r => string.Equals(r.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.StartedAtUtc)
.ToList();
var approvals = pendingByAgent.GetValueOrDefault(agent.AgentId);
lastReceipt.TryGetValue(agent.AgentId, out var last);
var typical = await TypicalRunAsync(agent.AgentId, ct);
var state = Classify(runs, approvals, last?.Status, typical, now);
members.Add(new TeamMember(
agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals,
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds));
}
return new TeamSnapshot(members, links, now);
}
/// <summary>
/// The state from the facts. Pure, so the rules can be tested without an instance.
/// </summary>
public static TeamState Classify(
IReadOnlyList<ActiveRun> runs, int pendingApprovals, string? lastRunStatus, TimeSpan typicalRun, DateTime nowUtc)
{
if (runs.Count > 0)
{
var limit = typicalRun > TimeSpan.Zero
? TimeSpan.FromTicks(Math.Max(typicalRun.Ticks * StuckFactor, StuckFloor.Ticks))
: StuckWithoutHistory;
if (runs.Any(r => nowUtc - r.StartedAtUtc > limit))
return TeamState.Stuck;
}
if (pendingApprovals > 0)
return TeamState.WaitingForApproval;
if (runs.Count > 0)
return TeamState.Working;
return lastRunStatus switch
{
nameof(AgentRunStatus.BudgetExceeded) => TeamState.BudgetExhausted,
nameof(AgentRunStatus.Failed)
or nameof(AgentRunStatus.LoopLimitExceeded)
or nameof(AgentRunStatus.TargetBusy) => TeamState.Problem,
_ => TeamState.Idle
};
}
/// <summary>The agent's typical run duration — the median of its recent runs.</summary>
private async Task<TimeSpan> TypicalRunAsync(string agentId, CancellationToken ct)
{
if (usage is null)
return TimeSpan.Zero;
var recent = await usage.GetRecentForAgentAsync(agentId, 20, ct);
var durations = recent.Where(r => r.DurationMs > 0).Select(r => r.DurationMs).Order().ToList();
return durations.Count == 0 ? TimeSpan.Zero : TimeSpan.FromMilliseconds(durations[durations.Count / 2]);
}
}
@@ -125,6 +125,29 @@ public sealed class SqliteUsageRepository : IUsageRepository
return results;
}
public async Task<IReadOnlyList<RunUsage>> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT Id, AgentId, Model, PromptTokens, CompletionTokens, CachedTokens,
CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt
FROM RunUsage
WHERE AgentId = @agent COLLATE NOCASE
ORDER BY OccurredAt DESC
LIMIT @limit
""";
cmd.Parameters.AddWithValue("@agent", agentId);
cmd.Parameters.AddWithValue("@limit", Math.Clamp(limit, 1, 1000));
var results = new List<RunUsage>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(Read(reader));
return results;
}
public Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
@@ -53,6 +53,14 @@ public interface IUsageRepository
Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct);
/// <summary>
/// #716: The most recent runs of one agent — the basis for estimating what a run of
/// it will cost. Without an implementation there is no estimate, and a reservation
/// counts nothing.
/// </summary>
Task<IReadOnlyList<RunUsage>> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<RunUsage>>([]);
/// <summary>Entfernt Einträge älter als das angegebene Datum. Gibt die Anzahl zurück.</summary>
Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct);
}
@@ -0,0 +1,132 @@
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Activity;
/// <summary>A run that is in progress right now.</summary>
public sealed record ActiveRun(
string RunId,
string AgentId,
string Source,
string? TaskId,
string? CalledBy,
DateTime StartedAtUtc);
/// <summary>
/// One agent reaching another — for now a delegated run (<see cref="Kind"/> =
/// <c>delegation</c>). Meetings and escalations will add their own kinds.
/// </summary>
public sealed record AgentLink(string From, string To, string Kind, DateTime AtUtc);
/// <summary>
/// #711: Who is working right now, and who reached whom — the live side of the team overview.
///
/// <para><b>Why a component of its own.</b> The engine already tracked running chats, but
/// only those that went through <c>ChatAsync</c>; a fresh run (<c>@new</c>) was invisible to
/// <c>IsRunning</c>. And nothing recorded that agent A had called agent B — the delegation
/// chain knew it for the length of the call and then forgot. Here both are kept and
/// announced, independent of which engine path a run takes.</para>
///
/// <para><b>Memory only, and bounded.</b> This is the view of "now". What happened
/// yesterday is in the audit log; the time line (#711, later) replays it from there.</para>
/// </summary>
public sealed class AgentActivity
{
/// <summary>How many recent links are kept for a page that connects late.</summary>
public const int RecentLinkCapacity = 500;
private readonly Lock _lock = new();
private readonly Dictionary<string, ActiveRun> _active = new(StringComparer.Ordinal);
private readonly Queue<AgentLink> _recentLinks = new();
private readonly TimeProvider _clock;
public AgentActivity(TimeProvider? clock = null) => _clock = clock ?? TimeProvider.System;
public event Action<ActiveRun>? RunStarted;
/// <summary>A run ended — with the <c>AgentRunStatus</c> name, or <c>Aborted</c>.</summary>
public event Action<ActiveRun, string>? RunFinished;
public event Action<AgentLink>? LinkObserved;
/// <summary>
/// Announces a run. Dispose the handle when it ends; call <see cref="RunHandle.Complete"/>
/// first to report how. A delegated run (<paramref name="chain"/> deeper than its root)
/// also records the link from the caller.
/// </summary>
public RunHandle Begin(string runId, string agentId, string source, string? taskId, DelegationChain? chain)
{
var now = _clock.GetUtcNow().UtcDateTime;
var calledBy = chain is { Depth: > 0 } ? chain.Agents[^2] : null;
var run = new ActiveRun(runId, agentId, source, taskId, calledBy, now);
AgentLink? link = calledBy is null ? null : new AgentLink(calledBy, agentId, "delegation", now);
lock (_lock)
{
_active[runId] = run;
if (link is not null)
{
_recentLinks.Enqueue(link);
while (_recentLinks.Count > RecentLinkCapacity)
_recentLinks.Dequeue();
}
}
Raise(() => RunStarted?.Invoke(run));
if (link is not null)
Raise(() => LinkObserved?.Invoke(link));
return new RunHandle(this, run);
}
/// <summary>The runs in progress right now.</summary>
public IReadOnlyList<ActiveRun> ActiveRuns()
{
lock (_lock)
return [.. _active.Values];
}
/// <summary>Links observed within <paramref name="window"/>, oldest first.</summary>
public IReadOnlyList<AgentLink> RecentLinks(TimeSpan window)
{
var since = _clock.GetUtcNow().UtcDateTime - window;
lock (_lock)
return [.. _recentLinks.Where(l => l.AtUtc >= since)];
}
private void End(ActiveRun run, string status)
{
lock (_lock)
{
if (!_active.Remove(run.RunId))
return;
}
Raise(() => RunFinished?.Invoke(run, status));
}
/// <summary>A subscriber that throws must not break the run it is watching.</summary>
private static void Raise(Action raise)
{
try { raise(); }
catch { /* observers are best effort */ }
}
public sealed class RunHandle : IDisposable
{
private readonly AgentActivity _owner;
private readonly ActiveRun _run;
private string _status = "Aborted";
internal RunHandle(AgentActivity owner, ActiveRun run)
{
_owner = owner;
_run = run;
}
/// <summary>How the run ended. Without it, disposal reports <c>Aborted</c>.</summary>
public void Complete(string status) => _status = status;
public void Dispose() => _owner.End(_run, _status);
}
}
+146 -7
View File
@@ -13,6 +13,13 @@ public enum BudgetLimit
InstanceTokens
}
/// <summary>#716: What a run is expected to use — the agent's average over recent runs.</summary>
public readonly record struct RunEstimate(decimal CostUsd, long Tokens)
{
public static RunEstimate operator +(RunEstimate a, RunEstimate b) => new(a.CostUsd + b.CostUsd, a.Tokens + b.Tokens);
public static RunEstimate operator -(RunEstimate a, RunEstimate b) => new(a.CostUsd - b.CostUsd, a.Tokens - b.Tokens);
}
public sealed record BudgetStatus(bool IsAllowed, BudgetLimit Limit, string? Reason)
{
public static BudgetStatus Allowed { get; } = new(true, BudgetLimit.None, null);
@@ -34,6 +41,19 @@ public sealed class BudgetGuard
private readonly IUsageRepository _usage;
private readonly Func<DateTime> _now;
/// <summary>How many recent runs of an agent make up its per-run estimate (#716).</summary>
public const int EstimateSample = 20;
/// <summary>
/// #716: Check and reserve happen under this lock. Without it, two runs starting at
/// the same moment would both read the same "used" figure and both pass.
/// </summary>
private readonly SemaphoreSlim _reserveGate = new(1, 1);
private readonly Lock _reservedLock = new();
private readonly Dictionary<string, RunEstimate> _reservedByAgent = new(StringComparer.OrdinalIgnoreCase);
private RunEstimate _reservedInstance;
public BudgetGuard(IUsageRepository usage, Func<DateTime>? now = null)
{
_usage = usage;
@@ -43,8 +63,116 @@ public sealed class BudgetGuard
/// <summary>
/// Prüft, ob der Agent heute noch laufen darf.
/// </summary>
public async Task<BudgetStatus> CheckAsync(
public Task<BudgetStatus> CheckAsync(
AgentConfig agent, InstanceBudget instanceBudget, CancellationToken ct)
=> CheckCoreAsync(agent, instanceBudget, default, default, ct);
/// <summary>
/// #716: Checks like <see cref="CheckAsync"/> and, if the run may start, reserves its
/// estimated usage until the returned handle is disposed — i.e. until the run's usage
/// is recorded.
///
/// <para><b>What changes.</b> Before, each run was checked on its own against what was
/// already recorded. Runs in flight were invisible, so several starting together all
/// passed and exceeded the budget together (#682 capped how many; this closes it).
/// Now the runs in flight count with their estimate.</para>
///
/// <para><b>What does not change.</b> A single run is still refused only when the
/// budget is already used up — its own estimate is not added. Otherwise a small task
/// would be refused because the agent's average run is large.</para>
/// </summary>
public async Task<(BudgetStatus Status, IDisposable? Reservation)> ReserveAsync(
AgentConfig agent, InstanceBudget instanceBudget, CancellationToken ct)
{
if (!agent.Budget.HasAnyLimit && !instanceBudget.HasAnyLimit)
return (BudgetStatus.Allowed, null);
var estimate = await EstimateRunAsync(agent.AgentId, ct);
await _reserveGate.WaitAsync(ct);
try
{
RunEstimate agentReserved, instanceReserved;
lock (_reservedLock)
{
agentReserved = _reservedByAgent.GetValueOrDefault(agent.AgentId);
instanceReserved = _reservedInstance;
}
var status = await CheckCoreAsync(agent, instanceBudget, agentReserved, instanceReserved, ct);
if (!status.IsAllowed)
return (status, null);
lock (_reservedLock)
{
_reservedByAgent[agent.AgentId] = _reservedByAgent.GetValueOrDefault(agent.AgentId) + estimate;
_reservedInstance += estimate;
}
return (status, new Reservation(this, agent.AgentId, estimate));
}
finally
{
_reserveGate.Release();
}
}
/// <summary>Today's usage of an agent, or with an empty id of the whole instance.</summary>
public Task<DailyUsage> GetUsedTodayAsync(string agentId, CancellationToken ct)
=> _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agentId, ct);
/// <summary>What is currently reserved by runs in flight — for the agent, or with an
/// empty id for the whole instance.</summary>
public RunEstimate Reserved(string agentId)
{
lock (_reservedLock)
return agentId.Length == 0 ? _reservedInstance : _reservedByAgent.GetValueOrDefault(agentId);
}
/// <summary>
/// The average of the agent's recent runs. Runs without usage (refusals, aborted
/// before the first call) do not count — they would drag the estimate towards zero.
/// </summary>
public async Task<RunEstimate> EstimateRunAsync(string agentId, CancellationToken ct)
{
var recent = await _usage.GetRecentForAgentAsync(agentId, EstimateSample, ct);
var runs = recent.Where(r => r.TotalTokens > 0).ToList();
if (runs.Count == 0)
return default;
return new RunEstimate(
runs.Average(r => r.CostUsd),
(long)Math.Ceiling(runs.Average(r => (double)r.TotalTokens)));
}
private void Release(string agentId, RunEstimate estimate)
{
lock (_reservedLock)
{
var left = _reservedByAgent.GetValueOrDefault(agentId) - estimate;
if (left.CostUsd <= 0 && left.Tokens <= 0)
_reservedByAgent.Remove(agentId);
else
_reservedByAgent[agentId] = left;
_reservedInstance -= estimate;
}
}
private sealed class Reservation(BudgetGuard guard, string agentId, RunEstimate estimate) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
guard.Release(agentId, estimate);
}
}
private async Task<BudgetStatus> CheckCoreAsync(
AgentConfig agent, InstanceBudget instanceBudget,
RunEstimate agentReserved, RunEstimate instanceReserved, CancellationToken ct)
{
var today = DateOnly.FromDateTime(_now());
@@ -54,19 +182,21 @@ public sealed class BudgetGuard
{
var used = await _usage.GetDailyAsync(today, agent.AgentId, ct);
if (Exceeds(agentBudget.DailyCostUsd, used.CostUsd))
if (Exceeds(agentBudget.DailyCostUsd, used.CostUsd + agentReserved.CostUsd))
{
return new BudgetStatus(false, BudgetLimit.AgentCost,
$"Tagesbudget des Agenten erschöpft: {used.CostUsd:F4} von maximal " +
$"{agentBudget.DailyCostUsd:F2} USD verbraucht." +
ReservedHint(agentReserved.CostUsd) +
UnpricedHint(used));
}
if (Exceeds(agentBudget.DailyTokens, used.TotalTokens))
if (Exceeds(agentBudget.DailyTokens, used.TotalTokens + agentReserved.Tokens))
{
return new BudgetStatus(false, BudgetLimit.AgentTokens,
$"Token-Tageslimit des Agenten erschöpft: {used.TotalTokens:N0} von maximal " +
$"{agentBudget.DailyTokens:N0}.");
$"{agentBudget.DailyTokens:N0}." +
ReservedTokensHint(agentReserved.Tokens));
}
}
@@ -75,19 +205,21 @@ public sealed class BudgetGuard
{
var used = await _usage.GetDailyAsync(today, agentId: "", ct);
if (Exceeds(instanceBudget.DailyCostUsd, used.CostUsd))
if (Exceeds(instanceBudget.DailyCostUsd, used.CostUsd + instanceReserved.CostUsd))
{
return new BudgetStatus(false, BudgetLimit.InstanceCost,
$"Tagesbudget der Instanz erschöpft: {used.CostUsd:F4} von maximal " +
$"{instanceBudget.DailyCostUsd:F2} USD verbraucht." +
ReservedHint(instanceReserved.CostUsd) +
UnpricedHint(used));
}
if (Exceeds(instanceBudget.DailyTokens, used.TotalTokens))
if (Exceeds(instanceBudget.DailyTokens, used.TotalTokens + instanceReserved.Tokens))
{
return new BudgetStatus(false, BudgetLimit.InstanceTokens,
$"Token-Tageslimit der Instanz erschöpft: {used.TotalTokens:N0} von maximal " +
$"{instanceBudget.DailyTokens:N0}.");
$"{instanceBudget.DailyTokens:N0}." +
ReservedTokensHint(instanceReserved.Tokens));
}
}
@@ -109,6 +241,13 @@ public sealed class BudgetGuard
private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit;
/// <summary>#716: Without this, a refusal at "3.20 of 5.00 USD used" would look wrong.</summary>
private static string ReservedHint(decimal reserved)
=> reserved > 0 ? $" Weitere {reserved:F4} USD sind für laufende Läufe reserviert." : "";
private static string ReservedTokensHint(long reserved)
=> reserved > 0 ? $" Weitere {reserved:N0} Tokens sind für laufende Läufe reserviert." : "";
/// <summary>
/// Ohne Preisangaben ist die verbrauchte Summe unvollständig — sonst wirkte ein
/// niedriger Wert wie ein noch offener Spielraum.
@@ -0,0 +1,220 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Budget;
/// <summary>
/// #716: Decides whether a late run — carried-over work or a catch-up — may start now,
/// without taking budget the rest of the day still needs.
///
/// <para><b>The rule: the new day has right of way.</b> Headroom is the limit minus what
/// was used, minus what runs in flight have reserved, minus what the day's remaining
/// scheduled runs will need, minus a safety margin. A late run starts only if its
/// estimate fits into that. Otherwise one overdrawn day would push its shortfall into the
/// next, and the next, instead of resolving it.</para>
///
/// <para>This replaces the flat "at most 25 % of the new day" from the concept: a fixed
/// share is either too much on a busy day or needlessly little on a quiet one. The same
/// calculation is what fill work (#717) will run on.</para>
///
/// <para><b>Estimates, honestly.</b> A run is estimated from the agent's recent runs
/// (<see cref="BudgetGuard.EstimateRunAsync"/>). Without history the estimate is zero —
/// then only the margin keeps a reserve. That errs towards letting late work through;
/// the budget check at the start of the run still refuses once the limit is reached.</para>
/// </summary>
public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
{
/// <summary>Share of each limit kept free as a reserve for surprises.</summary>
public const decimal SafetyShare = 0.10m;
private readonly BudgetGuard _guard;
private readonly Func<IReadOnlyList<AgentConfig>> _agents;
private readonly Func<InstanceBudget> _instanceBudget;
private readonly TimeProvider _clock;
public BudgetHeadroom(
BudgetGuard guard,
Func<IReadOnlyList<AgentConfig>> agents,
Func<InstanceBudget> instanceBudget,
TimeProvider? clock = null)
{
_guard = guard;
_agents = agents;
_instanceBudget = instanceBudget;
_clock = clock ?? TimeProvider.System;
}
public async Task<bool> HasRoomAsync(
TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
{
var agents = _agents();
var agent = FindAgent(agents, lateTask);
var instanceBudget = _instanceBudget();
// Without a known agent there is nothing to calculate against; the budget check at
// the start of the run still applies.
if (agent is null || (!agent.Budget.HasAnyLimit && !instanceBudget.HasAnyLimit))
return true;
var endOfDay = StartOfNextLocalDay(nowUtc);
var estimates = new Dictionary<string, RunEstimate>(StringComparer.OrdinalIgnoreCase);
async Task<RunEstimate> EstimateFor(string agentId)
{
if (!estimates.TryGetValue(agentId, out var estimate))
estimates[agentId] = estimate = await _guard.EstimateRunAsync(agentId, ct);
return estimate;
}
var ownEstimate = await EstimateFor(agent.AgentId);
// Regular work of the rest of the day: everything scheduled except late work
// itself — that is what has to wait for headroom, not what headroom is kept for.
var regular = runnable.Where(t => t.Id != lateTask.Id && !IsLate(t)).ToList();
if (agent.Budget.HasAnyLimit)
{
var demand = default(RunEstimate);
foreach (var task in regular.Where(t => IsOf(t, agent.AgentId)))
demand += Times(ownEstimate, TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
var used = await _guard.GetUsedTodayAsync(agent.AgentId, ct);
var reserved = _guard.Reserved(agent.AgentId);
if (!Fits(agent.Budget.DailyCostUsd, used.CostUsd, reserved.CostUsd, demand.CostUsd, ownEstimate.CostUsd)
|| !Fits(agent.Budget.DailyTokens, used.TotalTokens, reserved.Tokens, demand.Tokens, ownEstimate.Tokens))
return false;
}
if (instanceBudget.HasAnyLimit)
{
var demand = default(RunEstimate);
foreach (var task in regular)
{
var agentId = TaskAssignee.AgentId(task.Assignee);
if (agentId.Length == 0)
continue;
demand += Times(await EstimateFor(agentId), TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
}
var used = await _guard.GetUsedTodayAsync("", ct);
var reserved = _guard.Reserved("");
if (!Fits(instanceBudget.DailyCostUsd, used.CostUsd, reserved.CostUsd, demand.CostUsd, ownEstimate.CostUsd)
|| !Fits(instanceBudget.DailyTokens, used.TotalTokens, reserved.Tokens, demand.Tokens, ownEstimate.Tokens))
return false;
}
return true;
}
/// <summary>
/// #716: Walks through the rest of today's schedule in time order and finds, per agent
/// with a limit and for the instance, the first run the budget would no longer cover.
/// Late work is left out: it only ever runs in headroom, so it cannot be what makes the
/// day's own runs fail.
/// </summary>
public async Task<IReadOnlyList<BudgetShortfall>> ShortfallsAsync(
IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
{
var agents = _agents();
var instanceBudget = _instanceBudget();
var endOfDay = StartOfNextLocalDay(nowUtc);
var result = new List<BudgetShortfall>();
// Every planned run until midnight, with whose it is.
var planned = new List<(DateTime At, string AgentId)>();
foreach (var task in runnable.Where(t => !IsLate(t)))
{
var agent = FindAgent(agents, task);
if (agent is null)
continue;
foreach (var at in TaskSchedule.OccurrenceTimesBetween(task, nowUtc, endOfDay))
planned.Add((at, agent.AgentId));
}
planned.Sort((a, b) => a.At.CompareTo(b.At));
var estimates = new Dictionary<string, RunEstimate>(StringComparer.OrdinalIgnoreCase);
foreach (var agentId in planned.Select(p => p.AgentId).Distinct(StringComparer.OrdinalIgnoreCase))
estimates[agentId] = await _guard.EstimateRunAsync(agentId, ct);
foreach (var agent in agents.Where(a => a.Budget.HasAnyLimit))
{
var runs = planned.Where(p => string.Equals(p.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase)).ToList();
var used = await _guard.GetUsedTodayAsync(agent.AgentId, ct);
var reserved = _guard.Reserved(agent.AgentId);
if (FirstRefused(runs, estimates, agent.Budget.DailyCostUsd, agent.Budget.DailyTokens, used, reserved) is { } index)
result.Add(new BudgetShortfall(agent.AgentId, runs[index].At, runs.Count - index, runs.Count));
}
if (instanceBudget.HasAnyLimit)
{
var used = await _guard.GetUsedTodayAsync("", ct);
var reserved = _guard.Reserved("");
if (FirstRefused(planned, estimates, instanceBudget.DailyCostUsd, instanceBudget.DailyTokens, used, reserved) is { } index)
result.Add(new BudgetShortfall("", planned[index].At, planned.Count - index, planned.Count));
}
return result;
}
/// <summary>
/// The index of the first run that would find the budget used up, or <c>null</c>.
/// Mirrors the check at the start of a run: a run is refused once usage has reached
/// the limit, not merely because its own estimate would overshoot.
/// </summary>
private static int? FirstRefused(
List<(DateTime At, string AgentId)> runs, Dictionary<string, RunEstimate> estimates,
decimal costLimit, long tokenLimit, Accounting.DailyUsage used, RunEstimate reserved)
{
var cost = used.CostUsd + reserved.CostUsd;
long tokens = used.TotalTokens + reserved.Tokens;
for (var i = 0; i < runs.Count; i++)
{
if ((costLimit > 0 && cost >= costLimit) || (tokenLimit > 0 && tokens >= tokenLimit))
return i;
var estimate = estimates.GetValueOrDefault(runs[i].AgentId);
cost += estimate.CostUsd;
tokens += estimate.Tokens;
}
return null;
}
/// <summary>Carried-over work and pending catch-ups are the late runs.</summary>
public static bool IsLate(TaskItem task) => task.IsCarriedOver || task.CatchUpPending;
/// <summary>0 or less means: no limit of this kind.</summary>
private static bool Fits(decimal limit, decimal used, decimal reserved, decimal demand, decimal estimate)
=> limit <= 0 || limit - used - reserved - demand - limit * SafetyShare >= estimate;
private static bool Fits(long limit, long used, long reserved, long demand, long estimate)
=> limit <= 0 || limit - used - reserved - demand - (long)(limit * SafetyShare) >= estimate;
private static RunEstimate Times(RunEstimate estimate, int runs)
=> new(estimate.CostUsd * runs, estimate.Tokens * runs);
private static bool IsOf(TaskItem task, string agentId)
=> string.Equals(TaskAssignee.AgentId(task.Assignee), agentId, StringComparison.OrdinalIgnoreCase);
private static AgentConfig? FindAgent(IReadOnlyList<AgentConfig> agents, TaskItem task)
{
var agentId = TaskAssignee.AgentId(task.Assignee);
return agentId.Length == 0
? (agents.Count == 1 ? agents[0] : null)
: agents.FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase));
}
private DateTime StartOfNextLocalDay(DateTime nowUtc)
{
var zone = _clock.LocalTimeZone;
var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc), zone);
var midnight = DateTime.SpecifyKind(local.Date.AddDays(1), DateTimeKind.Unspecified);
while (zone.IsInvalidTime(midnight))
midnight = midnight.AddMinutes(1);
return TimeZoneInfo.ConvertTimeToUtc(midnight, zone);
}
}
@@ -183,6 +183,21 @@ public sealed class AgentBudget
[JsonPropertyName("dailyTokens")]
public long DailyTokens { get; set; }
/// <summary>
/// #716: What happens to a run of a recurring task when the budget is exhausted —
/// <c>expire</c> (default) or <c>catchUpOnce</c>. One-time tasks are always carried
/// over to the next day. Kept as text so an unknown value falls back instead of
/// failing the whole configuration.
/// </summary>
[JsonPropertyName("whenExhausted")]
public string WhenExhausted { get; set; } = "expire";
[JsonIgnore]
public Tasks.BudgetMissPolicy MissPolicy
=> string.Equals(WhenExhausted?.Trim(), "catchUpOnce", StringComparison.OrdinalIgnoreCase)
? Tasks.BudgetMissPolicy.CatchUpOnce
: Tasks.BudgetMissPolicy.Expire;
[JsonIgnore]
public bool HasAnyLimit => DailyCostUsd > 0 || DailyTokens > 0;
}
+27 -8
View File
@@ -35,6 +35,15 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>Tagesgrenzen der Instanz. Wird vom Host gesetzt.</summary>
public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited;
/// <summary>
/// #716: The guard that checks and reserves every run — the scanner's headroom
/// calculation has to see the same reservations. <c>null</c> without usage recording.
/// </summary>
public BudgetGuard? BudgetGuard => _budgetGuard;
/// <summary>#711: Who is working right now and who reached whom — for the team overview.</summary>
public Activity.AgentActivity Activity { get; } = new();
/// <summary>
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
@@ -163,15 +172,20 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
return zu;
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
if (await CheckBudgetAsync(agentConfig, source ?? AuditSource.Direct, taskId, externalCt) is { } denied)
// #716: Die Reservierung gilt, bis der Verbrauch erfasst ist (Ende der Methode).
var (denied, reservation) = await CheckBudgetAsync(agentConfig, source ?? AuditSource.Direct, taskId, externalCt);
if (denied is not null)
return denied;
using var budgetReservation = reservation;
using var lauf = TrackRun();
var runId = Guid.NewGuid().ToString("N");
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
var recorder = CreateRecorder(agentConfig);
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
@@ -444,8 +458,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
if (RefuseWhileShuttingDown(agentConfig) is { } zu)
return zu;
if (await CheckBudgetAsync(agentConfig, source, taskId, externalCt) is { } denied)
var (denied, reservation) = await CheckBudgetAsync(agentConfig, source, taskId, externalCt);
if (denied is not null)
return denied;
using var budgetReservation = reservation;
using var lauf = TrackRun();
@@ -487,8 +503,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
try
{
// #711: "working" starts past the gate — waiting for it is not working.
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
var recorder = CreateRecorder(agentConfig);
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
@@ -1638,17 +1657,17 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>
/// Prüft das Tagesbudget. Gibt ein Ergebnis zurück, wenn der Lauf nicht stattfinden
/// darf — sonst null.
/// darf — sonst null und, bei gesetzter Grenze, die Reservierung des Laufs (#716).
/// </summary>
private async Task<AgentRunResult?> CheckBudgetAsync(
private async Task<(AgentRunResult? Denied, IDisposable? Reservation)> CheckBudgetAsync(
AgentConfig agentConfig, string? source, string? taskId, CancellationToken ct)
{
if (_budgetGuard is null)
return null;
return (null, null);
var status = await _budgetGuard.CheckAsync(agentConfig, InstanceBudget, ct);
var (status, reservation) = await _budgetGuard.ReserveAsync(agentConfig, InstanceBudget, ct);
if (status.IsAllowed)
return null;
return (null, reservation);
// #608: Ist ein Budget erschoepft, lehnt es jeden weiteren Lauf des Tages ab. Einmal je
// Agent, Grenze und Tag als Warnung genuegt - danach steht jede Ablehnung als Beleg da.
@@ -1676,7 +1695,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
await RecordReceiptAsync(Guid.NewGuid().ToString("N"), agentConfig, result, source, taskId);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
return (result, null);
}
/// <summary>Welche Budgetgrenze je Agent zuletzt an welchem Tag als Warnung gemeldet wurde.</summary>
@@ -339,6 +339,12 @@ public sealed class SqliteStorage
// #554: Paart den "angefangen"-Eintrag mit seinem Abschluss.
SpalteErgaenzen(connection, "AuditLog", "CallId", "TEXT NOT NULL DEFAULT ''");
// #716: What happened when the budget refused a run.
SpalteErgaenzen(connection, "Tasks", "BudgetMissCount", "INTEGER NOT NULL DEFAULT 0");
SpalteErgaenzen(connection, "Tasks", "LastBudgetMissAt", "TEXT NULL");
SpalteErgaenzen(connection, "Tasks", "DeferredUntil", "TEXT NULL");
SpalteErgaenzen(connection, "Tasks", "CatchUpPending", "INTEGER NOT NULL DEFAULT 0");
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
using var index = connection.CreateCommand();
index.CommandText = """
@@ -98,7 +98,8 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
return result.Status switch
{
AgentRunStatus.Completed => TaskLauf.Erledigt,
AgentRunStatus.BudgetExceeded or AgentRunStatus.ShuttingDown => TaskLauf.NichtGelaufen,
AgentRunStatus.BudgetExceeded => TaskLauf.KeinBudget,
AgentRunStatus.ShuttingDown => TaskLauf.NichtGelaufen,
_ => TaskLauf.Gescheitert
};
}
@@ -29,6 +29,13 @@ public interface ITaskRepository
/// </summary>
Task<IReadOnlyList<TaskItem>> ListRunnableAsync(CancellationToken ct);
/// <summary>
/// Every task, without a cap — for the few callers that must see the whole board: the
/// cost forecast, duplicate detection, the import's orphan sweep. Before #716 they used
/// <see cref="ListAsync"/> with its limit of 500 and silently saw only part of it.
/// </summary>
Task<IReadOnlyList<TaskItem>> ListAllAsync(bool includeArchived, CancellationToken ct);
/// <summary>
/// Beansprucht einen fälligen Termin atomar. Genau ein gleichzeitiger Aufruf gewinnt;
/// jeder weitere erhält <c>false</c>. Setzt den Marker (<paramref name="occurrenceKey"/>)
@@ -65,6 +72,28 @@ public interface ITaskRepository
/// <summary>Ändert den Status direkt (Tool-Aktionen, Auto-Dispatch, Eskalation).</summary>
Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct);
/// <summary>
/// #716: The budget refused a run of a one-time task. Releases the claim, restores the
/// occurrence (the task is still due) and defers it to <paramref name="deferredUntil"/>.
/// </summary>
Task<bool> CarryOverAsync(
string id, string claimToken, string? previousOccurrence, DateTime deferredUntil,
DateTime now, CancellationToken ct);
/// <summary>
/// #716: The budget refused a run of a recurring task. Releases the claim back to
/// <c>todo</c>; the occurrence stays consumed. With <paramref name="catchUpUntil"/> a
/// single catch-up run is scheduled for then — an already pending one is kept.
/// </summary>
Task<bool> RecordBudgetMissAsync(
string id, string claimToken, DateTime? catchUpUntil, DateTime now, CancellationToken ct);
/// <summary>
/// #716: A run took place — clears the miss counter, a pending catch-up and the
/// deferral. <see cref="TaskItem.LastBudgetMissAt"/> stays as history.
/// </summary>
Task<bool> ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct);
/// <summary>
/// Reconciliation beim Start: verwaiste Claims (älter als <paramref name="leaseCutoff"/>)
/// lösen und die betroffenen Aufgaben von <see cref="TaskItemStatus.InProgress"/> zurück
@@ -193,6 +193,68 @@ public sealed class SqliteTaskRepository : ITaskRepository
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> CarryOverAsync(
string id, string claimToken, string? previousOccurrence, DateTime deferredUntil,
DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE Tasks
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL,
LastOccurrence = @occ, UpdatedAt = @now,
BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now,
DeferredUntil = @deferred
WHERE Id = @id AND ClaimToken = @token
""";
cmd.Parameters.AddWithValue("@occ", (object?)previousOccurrence ?? DBNull.Value);
cmd.Parameters.AddWithValue("@now", Format(now));
cmd.Parameters.AddWithValue("@deferred", Format(deferredUntil));
cmd.Parameters.AddWithValue("@id", id);
cmd.Parameters.AddWithValue("@token", claimToken);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> RecordBudgetMissAsync(
string id, string claimToken, DateTime? catchUpUntil, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
// A pending catch-up keeps its date: several misses still mean one catch-up run.
cmd.CommandText = """
UPDATE Tasks
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now,
BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now,
CatchUpPending = CASE WHEN @catchUp = 1 THEN 1 ELSE CatchUpPending END,
DeferredUntil = CASE WHEN @catchUp = 1 AND CatchUpPending = 0 THEN @deferred
ELSE DeferredUntil END
WHERE Id = @id AND ClaimToken = @token
""";
cmd.Parameters.AddWithValue("@now", Format(now));
cmd.Parameters.AddWithValue("@catchUp", catchUpUntil is null ? 0 : 1);
cmd.Parameters.AddWithValue("@deferred", catchUpUntil is { } until ? Format(until) : DBNull.Value);
cmd.Parameters.AddWithValue("@id", id);
cmd.Parameters.AddWithValue("@token", claimToken);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE Tasks
SET BudgetMissCount = 0, CatchUpPending = 0, DeferredUntil = NULL, UpdatedAt = @now
WHERE Id = @id
""";
cmd.Parameters.AddWithValue("@now", Format(now));
cmd.Parameters.AddWithValue("@id", id);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<int> ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
@@ -208,6 +270,22 @@ public sealed class SqliteTaskRepository : ITaskRepository
return await cmd.ExecuteNonQueryAsync(ct);
}, ct);
public async Task<IReadOnlyList<TaskItem>> ListAllAsync(bool includeArchived, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + Columns + " FROM Tasks"
+ (includeArchived ? "" : " WHERE Status <> 'archived'")
+ " ORDER BY Priority DESC, UpdatedAt DESC";
var results = new List<TaskItem>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(Read(reader));
return results;
}
public async Task<IReadOnlyList<TaskItem>> ListRunnableAsync(CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
@@ -252,7 +330,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
private const string Columns =
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt";
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending";
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
{
@@ -318,7 +397,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
ClaimToken = r.IsDBNull(18) ? null : r.GetString(18),
ClaimedAt = r.IsDBNull(19) ? null : Parse(r.GetString(19)),
CreatedAt = Parse(r.GetString(20)),
UpdatedAt = Parse(r.GetString(21))
UpdatedAt = Parse(r.GetString(21)),
BudgetMissCount = r.GetInt32(22),
LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)),
DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)),
CatchUpPending = r.GetInt32(25) != 0
};
}
+39
View File
@@ -205,6 +205,32 @@ public sealed record TaskItem
public DateTime CreatedAt { get; init; }
public DateTime UpdatedAt { get; init; }
// ─── Budget misses (#716) ───
/// <summary>
/// How many times in a row a run of this task was refused because the budget was
/// exhausted. Reset by the next run that actually takes place.
/// </summary>
public int BudgetMissCount { get; init; }
/// <summary>When the last budget refusal happened (UTC). Kept after a reset, as history.</summary>
public DateTime? LastBudgetMissAt { get; init; }
/// <summary>
/// Not before this point (UTC). Set after a budget refusal to the start of the next
/// local day: retrying within the same day only produces further refusals.
/// </summary>
public DateTime? DeferredUntil { get; init; }
/// <summary>
/// A recurring task under <see cref="BudgetMissPolicy.CatchUpOnce"/> missed at least
/// one occurrence; all of them are caught up by a single run.
/// </summary>
public bool CatchUpPending { get; init; }
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
/// <summary>
/// Wiederkehrend? Solche Tasks kehren nach dem Feuern auf <c>todo</c> zurück statt auf
/// <c>done</c> — sonst würde ein Cron-Task nur ein einziges Mal laufen. Ein
@@ -215,6 +241,19 @@ public sealed record TaskItem
|| When is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every };
}
/// <summary>
/// #716: What happens to a run of a recurring task that the budget refused. One-time
/// tasks are always carried over — they are real work that must not disappear.
/// </summary>
public enum BudgetMissPolicy
{
/// <summary>The occurrence lapses and is recorded; the next regular one comes anyway.</summary>
Expire,
/// <summary>All missed occurrences are caught up by one run on the next day.</summary>
CatchUpOnce
}
/// <summary>Suchkriterien für <see cref="ITaskRepository.ListAsync"/>.</summary>
public sealed record TaskQuery
{
+246 -8
View File
@@ -12,10 +12,17 @@ public enum TaskLauf
Gescheitert,
/// <summary>
/// Der Lauf fand gar nicht statt — Budget erschöpft, Instanz im Herunterfahren (#643).
/// Der Lauf fand gar nicht statt — Instanz im Herunterfahren (#643).
/// Eine einmalige Aufgabe bleibt dann fällig, statt als gelaufen liegenzubleiben.
/// </summary>
NichtGelaufen
NichtGelaufen,
/// <summary>
/// #716: The run did not take place because the budget is exhausted. Unlike a
/// shutdown, retrying the same day is pointless — the scanner defers to the next day
/// and applies the agent's <see cref="BudgetMissPolicy"/>.
/// </summary>
KeinBudget
}
/// <summary>
@@ -37,6 +44,30 @@ public interface IMarketCalendar
bool IsOpen(DateTime nowUtc);
}
/// <summary>
/// #716: Decides whether a late run — carried-over work or a catch-up — may start now.
/// Without one, late runs only wait for the regular ones of the same tick.
/// </summary>
public interface ILateRunGate
{
Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct);
}
/// <summary>
/// #716: The day ahead — where the budget will not reach the end of today's schedule.
/// </summary>
public interface IBudgetOutlook
{
Task<IReadOnlyList<BudgetShortfall>> ShortfallsAsync(IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct);
}
/// <summary>
/// #716: An agent whose budget runs out before today's schedule does — from when on, and
/// how many planned runs will be refused. <see cref="AgentId"/> is empty for the instance
/// limit.
/// </summary>
public sealed record BudgetShortfall(string AgentId, DateTime ExhaustsAtUtc, int RunsRefused, int RunsPlanned);
/// <summary>Bis C1 kommt: der Markt gilt als immer offen.</summary>
public sealed class AlwaysOpenMarketCalendar : IMarketCalendar
{
@@ -98,6 +129,26 @@ public sealed class TaskScanner : IAsyncDisposable
/// <summary>How many due tasks had to wait at the last tick — logged only on change.</summary>
private int _lastWaiting;
/// <summary>#716: What the budget refused, per agent, for the day summary.</summary>
private readonly Func<TaskItem, BudgetMissPolicy> _missPolicy;
private readonly Dictionary<string, (int Misses, int CarriedOver, int CatchUps)> _missesToday = new(StringComparer.OrdinalIgnoreCase);
private DateOnly? _missDay;
private readonly ILateRunGate? _lateRunGate;
private readonly IBudgetOutlook? _outlook;
/// <summary>How often the day ahead is looked at — a forecast, not a watchdog.</summary>
public static readonly TimeSpan OutlookInterval = TimeSpan.FromMinutes(30);
private DateTime _lastOutlookAt = DateTime.MinValue;
/// <summary>Agents already warned about today, with the day — once per agent and day.</summary>
private readonly Dictionary<string, DateOnly> _shortfallWarned = new(StringComparer.OrdinalIgnoreCase);
/// <summary>How many late runs waited for budget headroom at the last tick.</summary>
private int _lastWaitingForHeadroom;
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
@@ -109,7 +160,10 @@ public sealed class TaskScanner : IAsyncDisposable
TimeProvider? clock = null,
TimeSpan? tick = null,
TimeSpan? lease = null,
int maxParallelRuns = DefaultMaxParallelRuns)
int maxParallelRuns = DefaultMaxParallelRuns,
Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null,
ILateRunGate? lateRunGate = null,
IBudgetOutlook? budgetOutlook = null)
{
_repo = repo;
_dispatcher = dispatcher;
@@ -119,6 +173,9 @@ public sealed class TaskScanner : IAsyncDisposable
_tick = tick ?? TimeSpan.FromSeconds(60);
_lease = lease ?? TimeSpan.FromMinutes(15);
_maxParallelRuns = Math.Max(1, maxParallelRuns);
_missPolicy = budgetMissPolicy ?? (_ => BudgetMissPolicy.Expire);
_lateRunGate = lateRunGate;
_outlook = budgetOutlook;
}
/// <summary>How many scheduled runs may be in flight at once (#682).</summary>
@@ -226,7 +283,10 @@ public sealed class TaskScanner : IAsyncDisposable
// #682: ungekappt. Vorher ListAsync mit Limit 500 — darüber fielen Termine lautlos aus.
var candidates = await _repo.ListRunnableAsync(ct);
var due = new List<(TaskItem Task, string Occurrence)>();
SummarizeMissesIfNewDay(now);
await WarnAboutShortfallsAsync(candidates, now, ct);
var due = new List<(TaskItem Task, string Occurrence, bool Late)>();
foreach (var task in candidates)
{
if (task.Status != TaskItemStatus.Todo)
@@ -269,26 +329,42 @@ public sealed class TaskScanner : IAsyncDisposable
if (_zurueckgestellt.TryGetValue(task.Id, out var ab) && now < ab)
continue;
var occ = TaskSchedule.DueOccurrence(task, now);
if (occ is null)
// #716: Refused for lack of budget — not before the next day. For a recurring
// task this only holds back the catch-up; its regular occurrences keep coming.
var deferred = task.DeferredUntil is { } until && now < until;
if (deferred && !task.IsRecurring)
continue;
if (task.OnlyWhenMarketOpen && !_market.IsOpen(now))
continue;
due.Add((task, occ));
var occ = TaskSchedule.DueOccurrence(task, now);
if (occ is not null)
{
// A regular occurrence also covers a pending catch-up: one run, not two.
due.Add((task, occ, Late: task.IsCarriedOver));
continue;
}
if (task.CatchUpPending && !deferred)
due.Add((task, Iso(now), Late: true));
}
// #716: The new day has right of way — carried-over and catch-up runs come after
// everything regularly due. Within each group: priority, then the oldest occurrence.
// Occurrence keys are ISO-8601 UTC ("O"), so ordinal order is time order.
due.Sort((a, b) =>
{
if (a.Late != b.Late)
return a.Late ? 1 : -1;
var byPriority = b.Task.Priority.CompareTo(a.Task.Priority);
return byPriority != 0 ? byPriority : string.CompareOrdinal(a.Occurrence, b.Occurrence);
});
var launched = new List<Task>();
var waiting = 0;
foreach (var (task, occ) in due)
var waitingForHeadroom = 0;
foreach (var (task, occ, late) in due)
{
// OpenCount also counts runs started by hand (RunTaskNowAsync) — they take a
// slot, even though they did not wait for one.
@@ -298,6 +374,14 @@ public sealed class TaskScanner : IAsyncDisposable
continue;
}
// #716: Late work only uses what the rest of the day does not need.
if (late && _lateRunGate is not null
&& !await _lateRunGate.HasRoomAsync(task, candidates, now, ct))
{
waitingForHeadroom++;
continue;
}
var token = Guid.NewGuid().ToString("N");
if (await _repo.TryClaimAsync(task.Id, occ, token, now, leaseCutoff, ct))
launched.Add(Track(RunLoggedAsync(task, token, ct)));
@@ -316,6 +400,16 @@ public sealed class TaskScanner : IAsyncDisposable
_lastWaiting = waiting;
}
if (waitingForHeadroom != _lastWaitingForHeadroom)
{
if (waitingForHeadroom > 0)
_logger.LogInformation(
"{Waiting} carried-over or catch-up run(s) waiting for budget headroom — "
+ "the rest of today's schedule comes first",
waitingForHeadroom);
_lastWaitingForHeadroom = waitingForHeadroom;
}
return launched;
}
@@ -413,6 +507,12 @@ public sealed class TaskScanner : IAsyncDisposable
var now = _clock.GetUtcNow().UtcDateTime;
var ok = lauf == TaskLauf.Erledigt;
if (lauf == TaskLauf.KeinBudget)
{
await HandleBudgetMissAsync(task, token, now, ct);
return;
}
// #643: Fand der Lauf einer einmaligen Aufgabe gar nicht statt, gilt ihr Termin als
// nicht verbraucht. Vorher blieb sie mit gesetztem Marker für immer auf todo liegen.
if (lauf == TaskLauf.NichtGelaufen && !task.IsRecurring)
@@ -443,10 +543,148 @@ public sealed class TaskScanner : IAsyncDisposable
await _repo.CompleteClaimAsync(task.Id, token, final, now, ct);
// #716: A run took place — earlier budget misses are settled.
if (lauf != TaskLauf.NichtGelaufen
&& (task.BudgetMissCount > 0 || task.CatchUpPending || task.DeferredUntil is not null))
await _repo.ClearBudgetMissAsync(task.Id, now, ct);
if (ok && final == TaskItemStatus.Done)
await UnblockDependentsAsync(task.Id, now, ct);
}
/// <summary>
/// #716: The budget refused the run. A one-time task is carried over to the next day
/// with its occurrence intact; a recurring one follows the agent's policy — the
/// occurrence lapses, and under <see cref="BudgetMissPolicy.CatchUpOnce"/> one
/// catch-up run is scheduled for the next day.
/// </summary>
private async Task HandleBudgetMissAsync(TaskItem task, string token, DateTime now, CancellationToken ct)
{
var nextDay = StartOfNextLocalDay(now);
var agent = TaskAssignee.AgentId(task.Assignee);
var carried = false;
var catchUp = false;
if (!task.IsRecurring)
{
carried = await _repo.CarryOverAsync(task.Id, token, task.LastOccurrence, nextDay, now, ct);
}
else
{
catchUp = _missPolicy(task) == BudgetMissPolicy.CatchUpOnce;
await _repo.RecordBudgetMissAsync(task.Id, token, catchUp ? nextDay : null, now, ct);
}
lock (_missesToday)
{
var entry = _missesToday.GetValueOrDefault(agent);
_missesToday[agent] = (entry.Misses + 1,
entry.CarriedOver + (carried ? 1 : 0),
entry.CatchUps + (catchUp && !task.CatchUpPending ? 1 : 0));
}
_logger.LogInformation(
"Task {TaskId} refused for lack of budget — {Outcome}",
task.Id,
carried ? $"carried over, due again from {nextDay:yyyy-MM-dd HH:mm} UTC"
: catchUp ? "occurrence lapsed, one catch-up run scheduled for the next day"
: "occurrence lapsed (policy: expire)");
}
/// <summary>
/// #716: Prevent rather than catch up. Every half hour the day ahead is checked; an
/// agent whose budget will not last until midnight is named once a day — with the time
/// it runs out and how many planned runs that costs — while there is still time to
/// raise the budget or decide what can go.
/// </summary>
private async Task WarnAboutShortfallsAsync(IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
{
if (_outlook is null || nowUtc - _lastOutlookAt < OutlookInterval)
return;
_lastOutlookAt = nowUtc;
IReadOnlyList<BudgetShortfall> shortfalls;
try
{
shortfalls = await _outlook.ShortfallsAsync(runnable, nowUtc, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Budget outlook failed — no forecast this time");
return;
}
var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, _clock.LocalTimeZone));
foreach (var shortfall in shortfalls)
{
if (_shortfallWarned.TryGetValue(shortfall.AgentId, out var warnedOn) && warnedOn == today)
continue;
_shortfallWarned[shortfall.AgentId] = today;
var local = TimeZoneInfo.ConvertTimeFromUtc(shortfall.ExhaustsAtUtc, _clock.LocalTimeZone);
_logger.LogWarning(
"Budget forecast: the daily budget of {Who} will run out around {Time:HH:mm} — "
+ "{Refused} of {Planned} runs planned for the rest of today would be refused. "
+ "Raise the budget or decide what can wait.",
shortfall.AgentId.Length == 0 ? "the instance" : $"agent '{shortfall.AgentId}'",
local, shortfall.RunsRefused, shortfall.RunsPlanned);
}
}
/// <summary>
/// #716: On the first tick of a new local day, one line per agent about what the
/// budget refused the day before. The details are on the tasks themselves.
/// </summary>
private void SummarizeMissesIfNewDay(DateTime nowUtc)
{
var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, _clock.LocalTimeZone));
List<KeyValuePair<string, (int Misses, int CarriedOver, int CatchUps)>> summary;
DateOnly? day;
lock (_missesToday)
{
if (_missDay is null)
{
_missDay = today;
return;
}
if (_missDay == today)
return;
day = _missDay;
summary = [.. _missesToday];
_missesToday.Clear();
_missDay = today;
}
foreach (var (agent, counts) in summary)
{
_logger.LogWarning(
"Budget summary {Day:yyyy-MM-dd} for agent {AgentId}: {Misses} run(s) refused for lack of "
+ "budget, {Carried} task(s) carried over, {CatchUps} catch-up run(s) scheduled",
day, agent.Length == 0 ? "-" : agent, counts.Misses, counts.CarriedOver, counts.CatchUps);
}
}
/// <summary>
/// Local midnight after <paramref name="nowUtc"/>, in UTC. The budget day follows the
/// machine's local date, like <see cref="Budget.BudgetGuard"/>.
/// </summary>
private DateTime StartOfNextLocalDay(DateTime nowUtc)
{
var zone = _clock.LocalTimeZone;
var local = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, zone);
var midnight = DateTime.SpecifyKind(local.Date.AddDays(1), DateTimeKind.Unspecified);
// A midnight skipped by a DST change does not exist; take the next valid minute.
while (zone.IsInvalidTime(midnight))
midnight = midnight.AddMinutes(1);
return TimeZoneInfo.ConvertTimeToUtc(midnight, zone);
}
private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O");
/// <summary>
/// Auto-Dispatch: Wird eine Aufgabe fertig, werden die auf sie wartenden Aufgaben
/// freigegeben, sobald <b>alle</b> ihre Blocker erledigt sind.
@@ -201,6 +201,79 @@ public static class TaskSchedule
}
}
/// <summary>
/// #716: How many runs the task will still trigger from <paramref name="fromUtc"/> up to
/// (excluding) <paramref name="toUtc"/> — including one that is due right now. The
/// basis for "how much budget does the rest of the day still need".
/// </summary>
public static int OccurrencesBetween(TaskItem task, DateTime fromUtc, DateTime toUtc, int cap = 1_440)
=> OccurrenceTimesBetween(task, fromUtc, toUtc, cap).Count;
/// <summary>
/// #716: When the task will run from <paramref name="fromUtc"/> up to (excluding)
/// <paramref name="toUtc"/>. A run due right now is listed at <paramref name="fromUtc"/>.
/// </summary>
public static IReadOnlyList<DateTime> OccurrenceTimesBetween(TaskItem task, DateTime fromUtc, DateTime toUtc, int cap = 1_440)
{
fromUtc = DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc);
toUtc = DateTime.SpecifyKind(toUtc, DateTimeKind.Utc);
var times = new List<DateTime>();
if (toUtc <= fromUtc)
return times;
var dueNow = DueOccurrence(task, fromUtc) is not null;
if (dueNow)
times.Add(fromUtc);
if (task.When is not { } when)
return times; // one-time without a schedule: due until it ran
switch (when.Kind)
{
case TaskWhenKind.At:
{
if (!dueNow && task.LastOccurrence is null
&& TryResolveInstant(when.Value, when.TimeZone, out var at) && at < toUtc)
times.Add(at);
return times;
}
case TaskWhenKind.Every:
{
if (!TryParseInterval(when.Value, out var interval) || interval <= TimeSpan.Zero)
return times;
var next = dueNow
? fromUtc + interval
: NextOccurrenceUtc(task, fromUtc) ?? toUtc;
while (times.Count < cap && next < toUtc)
{
times.Add(next);
next += interval;
}
return times;
}
case TaskWhenKind.Cron:
{
if (!TryPrepareCron(when, out var cron, out var tz))
return times;
var cursor = fromUtc;
while (times.Count < cap && NextCronUtc(cron, tz, cursor) is { } next && next < toUtc)
{
if (next > fromUtc)
times.Add(next);
cursor = next;
}
return times;
}
default:
return times;
}
}
private static bool TryPrepareCron(TaskWhen when, out CronExpression cron, out TimeZoneInfo tz)
{
cron = null!;
@@ -31,6 +31,10 @@ public sealed class TaskboardService
public Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
=> _repo.ListAsync(query, ct);
/// <summary>The whole board, without the cap of <see cref="ListAsync"/>.</summary>
public Task<IReadOnlyList<TaskItem>> ListAllAsync(CancellationToken ct, bool includeArchived = false)
=> _repo.ListAllAsync(includeArchived, ct);
// ─── Anlegen ───
/// <summary>
@@ -104,9 +108,9 @@ public sealed class TaskboardService
if (task is not null) { seen.Add(task.Id); imported++; }
}
// Verwaiste Zeilen archivieren. Der Deckel von 500 ist für den Start unkritisch;
// wächst das Board darüber hinaus, gehört ohnehin das Aufräumen (siehe Konzept) her.
var known = await _repo.ListAsync(new TaskQuery { IncludeArchived = true, Limit = 500 }, ct);
// Verwaiste Zeilen archivieren. Ungekappt (#716): Vorher sah dieser Abgleich nur die
// ersten 500 Zeilen, und darüber blieben verwaiste Zeilen still stehen.
var known = await _repo.ListAllAsync(includeArchived: true, ct);
foreach (var task in known)
if (task.Status != TaskItemStatus.Archived && !seen.Contains(task.Id))
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Archived, DateTime.UtcNow, ct);
@@ -1,3 +1,4 @@
using ClawdDotNet.Core.Tasks;
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
@@ -77,6 +78,13 @@ public sealed partial class AgentItemViewModel : ViewModelBase
[ObservableProperty]
private long _dailyTokens;
/// <summary>#716: What happens to a recurring task's run when the budget is exhausted.</summary>
[ObservableProperty]
private string _whenExhausted = "expire";
/// <summary>The values <see cref="AgentBudget.WhenExhausted"/> understands.</summary>
public IReadOnlyList<string> WhenExhaustedOptions { get; } = ["expire", "catchUpOnce"];
public ObservableCollection<ToolItemViewModel> AvailableTools { get; } = [];
/// <summary>
@@ -101,6 +109,7 @@ public sealed partial class AgentItemViewModel : ViewModelBase
_timeoutSeconds = config.LoopGuard.TimeoutSeconds;
_dailyCostUsd = config.Budget.DailyCostUsd;
_dailyTokens = config.Budget.DailyTokens;
_whenExhausted = config.Budget.MissPolicy == BudgetMissPolicy.CatchUpOnce ? "catchUpOnce" : "expire";
foreach (var tool in KnownTools)
{
@@ -137,6 +146,7 @@ public sealed partial class AgentItemViewModel : ViewModelBase
Config.LoopGuard.TimeoutSeconds = TimeoutSeconds;
Config.Budget.DailyCostUsd = DailyCostUsd;
Config.Budget.DailyTokens = DailyTokens;
Config.Budget.WhenExhausted = WhenExhausted;
}
}
@@ -208,6 +208,15 @@
<TextBox Text="{Binding DailyTokens}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.agents.whenExhausted]}" />
<ComboBox HorizontalAlignment="Stretch"
ItemsSource="{Binding WhenExhaustedOptions}"
SelectedItem="{Binding WhenExhausted}" />
<TextBlock Classes="caption" TextWrapping="Wrap"
Text="{Binding L[desktop.agents.whenExhaustedHint]}" />
</StackPanel>
</StackPanel>
</StackPanel>
+2
View File
@@ -64,6 +64,8 @@
"desktop.agents.maxToolResultChars": "Max. Tool-Ergebnis (Zeichen)",
"desktop.agents.dailyCost": "Tagesbudget ($ USD)",
"desktop.agents.dailyTokens": "Tagesbudget (Tokens)",
"desktop.agents.whenExhausted": "Wenn das Budget erschöpft ist (wiederkehrende Aufgaben)",
"desktop.agents.whenExhaustedHint": "expire: Der verpasste Lauf verfällt und wird an der Aufgabe vermerkt. catchUpOnce: Alle verpassten Läufe werden am nächsten Tag durch einen Lauf nachgeholt, nach der regulären Arbeit des Tages. Einmalige Aufgaben werden immer auf den nächsten Tag mitgenommen.",
"desktop.agents.allowedToolsFor": "ZUGELASSENE WERKZEUGE FÜR {0}",
"desktop.agents.thTool": "WERKZEUG",
"desktop.agents.thDescription": "BESCHREIBUNG",
+2
View File
@@ -64,6 +64,8 @@
"desktop.agents.maxToolResultChars": "Max Tool Result (chars)",
"desktop.agents.dailyCost": "Daily Budget ($ USD)",
"desktop.agents.dailyTokens": "Daily Budget (Tokens)",
"desktop.agents.whenExhausted": "When the budget is exhausted (recurring tasks)",
"desktop.agents.whenExhaustedHint": "expire: the missed run lapses and is recorded on the task. catchUpOnce: all missed runs are caught up by one run the next day, after the day's regular work. One-time tasks are always carried over to the next day.",
"desktop.agents.allowedToolsFor": "ALLOWED TOOLS FOR {0}",
"desktop.agents.thTool": "TOOL",
"desktop.agents.thDescription": "DESCRIPTION",
@@ -1721,3 +1721,77 @@ div.klein,
td.klein {
font-size: 12px;
}
/* ─── Team overview (#711) ─── */
.team-ansicht {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
.team-zusammenfassung {
flex-wrap: wrap;
min-height: 24px;
}
.team-flaeche {
display: flex;
gap: 12px;
flex: 1;
min-height: 420px;
}
.team-graph {
flex: 1;
min-width: 0;
min-height: 420px;
background: var(--surface-subtil);
border: 1px solid var(--divider);
}
.team-seitenleiste {
width: 320px;
flex-shrink: 0;
padding: 14px;
background: var(--surface);
border: 1px solid var(--divider);
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
}
.team-seitenleiste-kopf {
justify-content: space-between;
}
.team-seitenleiste h3 {
margin: 8px 0 0;
}
.team-ergebnis {
white-space: pre-wrap;
word-break: break-word;
max-height: 160px;
overflow-y: auto;
font-family: var(--schrift-mono);
}
.team-aktionen {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.team-legende {
flex-wrap: wrap;
gap: 16px;
}
@media (max-width: 800px) {
.team-flaeche { flex-direction: column; }
.team-seitenleiste { width: auto; }
}
@@ -35,10 +35,12 @@ import { renderUsageRuns } from "./views/usage-runs.js";
import { renderUsageTimeline } from "./views/usage-timeline.js";
import { renderRunDetail } from "./views/run-detail.js";
import { renderPromptHistory } from "./views/prompt-history.js";
import { renderTeam, leaveTeam } from "./views/team.js";
// ─── 1. Routen registrieren ───
function verlasseAnsicht() {
leaveTeam();
trenneLogStream();
trenneAgentenChatsStream();
stoppeInstanzenPolling();
@@ -50,6 +52,7 @@ registriereRoute("/chat/:agentId", (p) => { verlasseAnsicht(); return rendereCha
registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); });
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
@@ -67,6 +67,28 @@
"nav.chat": "Chat",
"nav.agents": "Agenten",
"nav.tasks": "Aufgaben",
"nav.team": "Teamübersicht",
"team.title": "Teamübersicht",
"team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.",
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
"team.empty": "Diese Instanz hat keine Agenten.",
"team.legendLink": "hat erreicht (verblasst nach 30 s)",
"team.state.Idle": "bereit",
"team.state.Working": "arbeitet",
"team.state.Stuck": "hängt",
"team.state.WaitingForApproval": "wartet auf Freigabe",
"team.state.Problem": "Problem",
"team.state.BudgetExhausted": "Budget erschöpft",
"team.running": "Läuft gerade",
"team.since": "seit {time}",
"team.task": "Aufgabe {id}",
"team.calledBy": "gerufen von {agent}",
"team.stuckHint": "Läufe dieses Agenten dauern sonst etwa {minutes} Min.",
"team.approvals": "{n} Werkzeugaufruf/-aufrufe warten auf Freigabe",
"team.lastRun": "Letzter Lauf",
"team.openApprovals": "Freigaben öffnen",
"team.openChat": "Chat öffnen",
"team.openRuns": "Läufe anzeigen",
"nav.services": "Dienste",
"nav.group.analytics": "Analyse",
"nav.usage": "Tokennutzung",
@@ -192,6 +214,12 @@
"tasks.saved": "Aufgabe gespeichert.",
"tasks.deleted": "Aufgabe gelöscht.",
"tasks.runTriggered": "Lauf ausgelöst.",
"tasks.nextRun": "Nächster",
"tasks.lastRun": "Letzter",
"tasks.budgetMissed": "Budget erschöpft: {n} Lauf/Läufe abgelehnt",
"tasks.budgetMissedLast": "zuletzt {time}",
"tasks.budgetCarriedOver": "mitgenommen, wieder fällig ab {time}",
"tasks.budgetCatchUp": "ein Nachholauf ab {time}",
"services.title": "Dienste",
"services.newService": "Neuer Dienst",
@@ -67,6 +67,28 @@
"nav.chat": "Chat",
"nav.agents": "Agents",
"nav.tasks": "Tasks",
"nav.team": "Team overview",
"team.title": "Team overview",
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.",
"team.noInstance": "No instance selected. Choose an active instance in the header.",
"team.empty": "No agents in this instance.",
"team.legendLink": "reached (fades after 30 s)",
"team.state.Idle": "idle",
"team.state.Working": "working",
"team.state.Stuck": "stuck",
"team.state.WaitingForApproval": "waiting for approval",
"team.state.Problem": "problem",
"team.state.BudgetExhausted": "budget exhausted",
"team.running": "Running now",
"team.since": "since {time}",
"team.task": "task {id}",
"team.calledBy": "called by {agent}",
"team.stuckHint": "Runs of this agent usually take about {minutes} min.",
"team.approvals": "{n} tool call(s) waiting for approval",
"team.lastRun": "Last run",
"team.openApprovals": "Open approvals",
"team.openChat": "Open chat",
"team.openRuns": "Show runs",
"nav.services": "Services",
"nav.group.analytics": "Analytics",
"nav.usage": "Token Usage",
@@ -192,6 +214,12 @@
"tasks.saved": "Task saved.",
"tasks.deleted": "Task deleted.",
"tasks.runTriggered": "Run triggered.",
"tasks.nextRun": "Next",
"tasks.lastRun": "Last",
"tasks.budgetMissed": "Budget exhausted: {n} run(s) refused",
"tasks.budgetMissedLast": "last at {time}",
"tasks.budgetCarriedOver": "carried over, due again from {time}",
"tasks.budgetCatchUp": "one catch-up run from {time}",
"services.title": "Services",
"services.newService": "New Service",
@@ -55,6 +55,12 @@
</svg>
<span class="nav-label" data-i18n="nav.agents">Agents</span>
</a>
<a href="#/team" class="nav-link" title="Team overview" data-i18n-title="nav.team">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 3h6v6H3z M15 3h6v6h-6z M9 15h6v6H9z M6 9v3h12V9 M12 12v3"/>
</svg>
<span class="nav-label" data-i18n="nav.team">Team overview</span>
</a>
<a href="#/aufgaben" class="nav-link" title="Tasks" data-i18n-title="nav.tasks">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
@@ -93,7 +93,7 @@ export function verbindeStrom() {
meldeStatus(false);
};
const bekannteEreignisse = ["chat", "step", "run", "instance", "status"];
const bekannteEreignisse = ["chat", "step", "run", "instance", "status", "activity"];
for (const art of bekannteEreignisse) {
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
}
+23
View File
@@ -0,0 +1,23 @@
# Vendored libraries
Third-party files the WebUI ships as-is. They live here instead of being loaded from a CDN
because the target machine may have no internet access (WebUI-Leitfaden). Do not edit them;
to update, replace the file and this table together.
Downloaded 2026-09-24 from the npm registry. Each tarball was checked against the
`dist.integrity` (SHA-512) the registry publishes before extracting.
| Package | Version | File | License | SHA-256 of the file |
|---|---|---|---|---|
| [cytoscape](https://www.npmjs.com/package/cytoscape) | 3.34.3 | `cytoscape/cytoscape.min.js` | MIT | `5f3b5b529546d5af1fc5628590af033b74511a5b6f789f5f4682845863228b91` |
| [elkjs](https://www.npmjs.com/package/elkjs) | 0.9.3 | `elkjs/elk.bundled.js` | EPL-2.0 | `b0745abd7f23cd91690a1587e377edbe19fd7233c783300290936720546216d4` |
| [cytoscape-elk](https://www.npmjs.com/package/cytoscape-elk) | 2.3.0 | `cytoscape-elk/cytoscape-elk.js` | MIT | `25acbb9dea9b0dc6fef0e79b697547ee56e579f0e1c22ae4353131e72c7e140a` |
**Why elkjs 0.9.3 and not the newest.** cytoscape-elk 2.3.0 declares `elkjs ^0.9.3`. A newer
elkjs would be untested with the adapter.
**How they are loaded.** All three are UMD bundles and, loaded as classic scripts in this
order, register the globals `cytoscape`, `ELK` and `cytoscapeElk`. The team overview (#711)
loads them on demand, so pages that do not draw a graph do not pay for ~2 MB of script.
The license texts sit next to each file.
@@ -0,0 +1,21 @@
Copyright (c) 2016-2018, 2020, The Cytoscape Consortium.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,356 @@
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory(require("elkjs/lib/elk.bundled.js"));
else if(typeof define === 'function' && define.amd)
define(["elkjs"], factory);
else if(typeof exports === 'object')
exports["cytoscapeElk"] = factory(require("elkjs/lib/elk.bundled.js"));
else
root["cytoscapeElk"] = factory(root["ELK"]);
})(this, function(__WEBPACK_EXTERNAL_MODULE__632__) {
return /******/ (function() { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ 632:
/***/ (function(module) {
module.exports = __WEBPACK_EXTERNAL_MODULE__632__;
/***/ })
/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/************************************************************************/
/******/ /* webpack/runtime/compat get default export */
/******/ !function() {
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function() { return module['default']; } :
/******/ function() { return module; };
/******/ __webpack_require__.d(getter, { a: getter });
/******/ return getter;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/define property getters */
/******/ !function() {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = function(exports, definition) {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ !function() {
/******/ __webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }
/******/ }();
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
!function() {
// EXPORTS
__webpack_require__.d(__webpack_exports__, {
"default": function() { return /* binding */ src; }
});
// EXTERNAL MODULE: external {"commonjs":"elkjs/lib/elk.bundled.js","commonjs2":"elkjs/lib/elk.bundled.js","amd":"elkjs","root":"ELK"}
var elk_bundled_js_amd_elkjs_root_ELK_ = __webpack_require__(632);
var elk_bundled_js_amd_elkjs_root_ELK_default = /*#__PURE__*/__webpack_require__.n(elk_bundled_js_amd_elkjs_root_ELK_);
;// CONCATENATED MODULE: ./src/assign.js
// Simple, internal Object.assign() polyfill for options objects etc.
function assign_assign(tgt) {
for (var _len = arguments.length, srcs = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
srcs[_key - 1] = arguments[_key];
}
srcs.forEach(function (src) {
Object.keys(src).forEach(function (k) {
return tgt[k] = src[k];
});
});
return tgt;
}
/* harmony default export */ var src_assign = (Object.assign != null ? Object.assign.bind(Object) : assign_assign);
;// CONCATENATED MODULE: ./src/defaults.js
var defaults = {
nodeDimensionsIncludeLabels: false,
// Boolean which changes whether label dimensions are included when calculating node dimensions
fit: true,
// Whether to fit
padding: 20,
// Padding on fit
animate: false,
// Whether to transition the node positions
animateFilter: function animateFilter() {
return true;
},
// Whether to animate specific nodes when animation is on; non-animated nodes immediately go to their final positions
animationDuration: 500,
// Duration of animation in ms if enabled
animationEasing: undefined,
// Easing of animation if enabled
transform: function transform(node, pos) {
return pos;
},
// A function that applies a transform to the final node position
ready: undefined,
// Callback on layoutready
stop: undefined,
// Callback on layoutstop
nodeLayoutOptions: undefined,
// Special options for only the nodes
elk: {
// Options to pass directly to ELK `layoutOptions`. The subsequent identifier has to be used as property key in quotes.
// E.g. for 'org.eclipse.elk.direction' use:
// 'elk.direction'
// Primary/mandatory, the elk algorithm to use
// one of 'box', 'disco', 'force', 'layered', 'mrtree', 'radial', 'random', 'stress'
// (see https://www.eclipse.org/elk/reference/algorithms.html)
algorithm: undefined
},
priority: function priority() {
return null;
} // Edges with a non-nil value are skipped when geedy edge cycle breaking is enabled
};
/* harmony default export */ var src_defaults = (defaults);
;// CONCATENATED MODULE: ./src/layout.js
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
var elkOverrides = {};
var getPos = function getPos(ele, options) {
var dims = ele.layoutDimensions(options);
var parent = ele.parent();
var k = ele.scratch('elk');
var p = {
x: k.x,
y: k.y
};
while (parent.nonempty()) {
var kp = parent.scratch('elk');
p.x += kp.x;
p.y += kp.y;
parent = parent.parent();
} // elk considers a node position to be its top-left corner, while cy is the centre
p.x += dims.w / 2;
p.y += dims.h / 2;
return p;
};
var makeNode = function makeNode(node, options) {
var k = {
_cyEle: node,
id: node.id()
}; // Apply nodeLayoutOptions per user-specified function
// e.g. nodeLayoutOptions => n.scratch('layoutOptions')
if (options.nodeLayoutOptions) {
k.layoutOptions = options.nodeLayoutOptions(node);
}
if (!node.isParent()) {
var dims = node.layoutDimensions(options);
var p = node.position(); // the elk position is the top-left corner, cy is the centre
k.x = p.x - dims.w / 2;
k.y = p.y - dims.h / 2;
k.width = dims.w;
k.height = dims.h;
}
node.scratch('elk', k);
return k;
};
var makeEdge = function makeEdge(edge
/*, options*/
) {
var k = {
_cyEle: edge,
id: edge.id(),
source: edge.data('source'),
target: edge.data('target')
};
edge.scratch('elk', k);
return k;
};
var makeGraph = function makeGraph(nodes, edges, options) {
var elkNodes = [];
var elkEdges = [];
var elkEleLookup = {};
var graph = {
id: 'root',
children: [],
edges: []
}; // map all nodes
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
var k = makeNode(n, options);
elkNodes.push(k);
elkEleLookup[n.id()] = k;
} // map all edges
for (var _i = 0; _i < edges.length; _i++) {
var e = edges[_i];
var _k = makeEdge(e, options);
elkEdges.push(_k);
elkEleLookup[e.id()] = _k;
} // make hierarchy
for (var _i2 = 0; _i2 < elkNodes.length; _i2++) {
var _k2 = elkNodes[_i2];
var _n = _k2._cyEle;
if (!_n.isChild()) {
graph.children.push(_k2);
} else {
var parent = _n.parent();
var parentK = elkEleLookup[parent.id()];
var children = parentK.children = parentK.children || [];
children.push(_k2);
}
}
for (var _i3 = 0; _i3 < elkEdges.length; _i3++) {
var _k3 = elkEdges[_i3]; // put all edges in the top level for now
// TODO does this cause issues in certain edgecases?
/*let e = k._cyEle;
let parentSrc = e.source().parent();
let parentTgt = e.target().parent();
if ( false && parentSrc.nonempty() && parentTgt.nonempty() && parentSrc.same( parentTgt ) ){
let kp = elkEleLookup[ parentSrc.id() ];
kp.edges = kp.edges || [];
kp.edges.push( k );
} else {*/
graph.edges.push(_k3); //}
}
return graph;
};
var Layout = /*#__PURE__*/function () {
function Layout(options) {
_classCallCheck(this, Layout);
var elkOptions = options.elk;
var cy = options.cy;
this.options = src_assign({}, src_defaults, options);
this.options.elk = src_assign({
aspectRatio: cy.width() / cy.height()
}, src_defaults.elk, elkOptions, elkOverrides);
}
_createClass(Layout, [{
key: "run",
value: function run() {
var layout = this;
var options = this.options;
var eles = options.eles;
var nodes = eles.nodes();
var edges = eles.edges();
var elk = new (elk_bundled_js_amd_elkjs_root_ELK_default())();
var graph = makeGraph(nodes, edges, options);
graph['layoutOptions'] = options.elk;
elk.layout(graph).then(function () {
nodes.filter(function (n) {
return !n.isParent();
}).layoutPositions(layout, options, function (n) {
return getPos(n, options);
});
});
return this;
}
}, {
key: "stop",
value: function stop() {
return this; // chaining
}
}, {
key: "destroy",
value: function destroy() {
return this; // chaining
}
}]);
return Layout;
}();
/* harmony default export */ var layout = (Layout);
;// CONCATENATED MODULE: ./src/index.js
// registers the extension on a cytoscape lib ref
var register = function register(cytoscape) {
if (!cytoscape) {
return;
} // can't register if cytoscape unspecified
cytoscape('layout', 'elk', layout); // register with cytoscape.js
};
if (typeof cytoscape !== 'undefined') {
// expose to global cytoscape (i.e. window.cytoscape)
// eslint-disable-next-line no-undef
register(cytoscape);
}
/* harmony default export */ var src = (register);
}();
__webpack_exports__ = __webpack_exports__["default"];
/******/ return __webpack_exports__;
/******/ })()
;
});
@@ -0,0 +1,19 @@
Copyright (c) 2016-2026, The Cytoscape Consortium.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,264 @@
# Eclipse Public License - v 2.0
THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
PUBLIC LICENSE (“AGREEMENT”). ANY USE, REPRODUCTION OR DISTRIBUTION OF
THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
## 1. DEFINITIONS
“Contribution” means:
- a\) in the case of the initial Contributor, the initial content
Distributed under this Agreement, and
- b\) in the case of each subsequent Contributor:
- i\) changes to the Program, and
- ii\) additions to the Program;
where such changes and/or additions to the Program originate from
and are Distributed by that particular Contributor. A Contribution
“originates” from a Contributor if it was added to the Program by
such Contributor itself or anyone acting on such Contributor's
behalf. Contributions do not include changes or additions to the
Program that are not Modified Works.
“Contributor” means any person or entity that Distributes the Program.
“Licensed Patents” mean patent claims licensable by a Contributor which
are necessarily infringed by the use or sale of its Contribution alone
or when combined with the Program.
“Program” means the Contributions Distributed in accordance with this
Agreement.
“Recipient” means anyone who receives the Program under this Agreement
or any Secondary License (as applicable), including Contributors.
“Derivative Works” shall mean any work, whether in Source Code or other
form, that is based on (or derived from) the Program and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship.
“Modified Works” shall mean any work in Source Code or other form that
results from an addition to, deletion from, or modification of the
contents of the Program, including, for purposes of clarity any new file
in Source Code form that contains any contents of the Program. Modified
Works shall not include works that contain only declarations,
interfaces, types, classes, structures, or files of the Program solely
in each case in order to link to, bind by name, or subclass the Program
or Modified Works thereof.
“Distribute” means the acts of a) distributing or b) making available in
any manner that enables the transfer of a copy.
“Source Code” means the form of a Program preferred for making
modifications, including but not limited to software source code,
documentation source, and configuration files.
“Secondary License” means either the GNU General Public License, Version
2.0, or any later versions of that license, including any exceptions or
additional permissions as identified by the initial Contributor.
## 2. GRANT OF RIGHTS
- a\) Subject to the terms of this Agreement, each Contributor hereby
grants Recipient a non-exclusive, worldwide, royalty-free copyright
license to reproduce, prepare Derivative Works of, publicly display,
publicly perform, Distribute and sublicense the Contribution of such
Contributor, if any, and such Derivative Works.
- b\) Subject to the terms of this Agreement, each Contributor hereby
grants Recipient a non-exclusive, worldwide, royalty-free patent license
under Licensed Patents to make, use, sell, offer to sell, import and
otherwise transfer the Contribution of such Contributor, if any, in
Source Code or other form. This patent license shall apply to the
combination of the Contribution and the Program if, at the time the
Contribution is added by the Contributor, such addition of the
Contribution causes such combination to be covered by the
Licensed Patents. The patent license shall not apply to any other
combinations which include the Contribution. No hardware per se is
licensed hereunder.
- c\) Recipient understands that although each Contributor grants the
licenses to its Contributions set forth herein, no assurances are
provided by any Contributor that the Program does not infringe the
patent or other intellectual property rights of any other entity. Each
Contributor disclaims any liability to Recipient for claims brought by
any other entity based on infringement of intellectual property rights
or otherwise. As a condition to exercising the rights and licenses
granted hereunder, each Recipient hereby assumes sole responsibility to
secure any other intellectual property rights needed, if any. For
example, if a third party patent license is required to allow Recipient
to Distribute the Program, it is Recipient's responsibility to acquire
that license before distributing the Program.
- d\) Each Contributor represents that to its knowledge it has sufficient
copyright rights in its Contribution, if any, to grant the copyright
license set forth in this Agreement.
- e\) Notwithstanding the terms of any Secondary License, no Contributor
makes additional grants to any Recipient (other than those set forth in
this Agreement) as a result of such Recipient's receipt of the Program
under the terms of a Secondary License (if permitted under the terms of
Section 3).
## 3. REQUIREMENTS
3.1 If a Contributor Distributes the Program in any form, then:
- a\) the Program must also be made available as Source Code, in accordance
with section 3.2, and the Contributor must accompany the Program with a
statement that the Source Code for the Program is available under this
Agreement, and informs Recipients how to obtain it in a reasonable
manner on or through a medium customarily used for software exchange;
and
- b\) the Contributor may Distribute the Program under a license different
than this Agreement, provided that such license:
- i\) effectively disclaims on behalf of all other Contributors all
warranties and conditions, express and implied, including warranties or
conditions of title and non-infringement, and implied warranties or
conditions of merchantability and fitness for a particular purpose;
- ii\) effectively excludes on behalf of all other Contributors all
liability for damages, including direct, indirect, special, incidental
and consequential damages, such as lost profits;
- iii\) does not attempt to limit or alter the recipients' rights in the
Source Code under section 3.2; and
- iv\) requires any subsequent distribution of the Program by any party to
be under a license that satisfies the requirements of this section 3.
3.2 When the Program is Distributed as Source Code:
- a\) it must be made available under this Agreement, or if the Program (i)
is combined with other material in a separate file or files made
available under a Secondary License, and (ii) the initial Contributor
attached to the Source Code the notice described in Exhibit A of this
Agreement, then the Program may be made available under the terms of
such Secondary Licenses, and
- b\) a copy of this Agreement must be included with each copy of
the Program.
3.3 Contributors may not remove or alter any copyright, patent,
trademark, attribution notices, disclaimers of warranty, or limitations
of liability (‘notices’) contained within the Program from any copy of
the Program which they Distribute, provided that Contributors may add
their own appropriate notices.
## 4. COMMERCIAL DISTRIBUTION
Commercial distributors of software may accept certain responsibilities
with respect to end users, business partners and the like. While this
license is intended to facilitate the commercial use of the Program, the
Contributor who includes the Program in a commercial product offering
should do so in a manner which does not create potential liability for
other Contributors. Therefore, if a Contributor includes the Program in
a commercial product offering, such Contributor (“Commercial
Contributor”) hereby agrees to defend and indemnify every other
Contributor (“Indemnified Contributor”) against any losses, damages and
costs (collectively “Losses”) arising from claims, lawsuits and other
legal actions brought by a third party against the Indemnified
Contributor to the extent caused by the acts or omissions of such
Commercial Contributor in connection with its distribution of the
Program in a commercial product offering. The obligations in this
section do not apply to any claims or Losses relating to any actual or
alleged intellectual property infringement. In order to qualify, an
Indemnified Contributor must: a) promptly notify the Commercial
Contributor in writing of such claim, and b) allow the Commercial
Contributor to control, and cooperate with the Commercial Contributor
in, the defense and any related settlement negotiations. The Indemnified
Contributor may participate in any such claim at its own expense.
For example, a Contributor might include the Program in a commercial
product offering, Product X. That Contributor is then a Commercial
Contributor. If that Commercial Contributor then makes performance
claims, or offers warranties related to Product X, those performance
claims and warranties are such Commercial Contributor's responsibility
alone. Under this section, the Commercial Contributor would have to
defend claims against the other Contributors related to those
performance claims and warranties, and if a court requires any other
Contributor to pay any damages as a result, the Commercial Contributor
must pay those damages.
## 5. NO WARRANTY {#warranty}
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN “AS IS”
BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
PURPOSE. Each Recipient is solely responsible for determining the
appropriateness of using and distributing the Program and assumes all
risks associated with its exercise of rights under this Agreement,
including but not limited to the risks and costs of program errors,
compliance with applicable laws, damage to or loss of data, programs or
equipment, and unavailability or interruption of operations.
## 6. DISCLAIMER OF LIABILITY {#disclaimer}
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
## 7. GENERAL
If any provision of this Agreement is invalid or unenforceable under
applicable law, it shall not affect the validity or enforceability of
the remainder of the terms of this Agreement, and without further action
by the parties hereto, such provision shall be reformed to the minimum
extent necessary to make such provision valid and enforceable.
If Recipient institutes patent litigation against any entity (including
a cross-claim or counterclaim in a lawsuit) alleging that the Program
itself (excluding combinations of the Program with other software or
hardware) infringes such Recipient's patent(s), then such Recipient's
rights granted under Section 2(b) shall terminate as of the date such
litigation is filed.
All Recipient's rights under this Agreement shall terminate if it fails
to comply with any of the material terms or conditions of this Agreement
and does not cure such failure in a reasonable period of time after
becoming aware of such noncompliance. If all Recipient's rights under
this Agreement terminate, Recipient agrees to cease use and distribution
of the Program as soon as reasonably practicable. However, Recipient's
obligations under this Agreement and any licenses granted by Recipient
relating to the Program shall continue and survive.
Everyone is permitted to copy and distribute copies of this Agreement,
but in order to avoid inconsistency the Agreement is copyrighted and may
only be modified in the following manner. The Agreement Steward reserves
the right to publish new versions (including revisions) of this
Agreement from time to time. No one other than the Agreement Steward has
the right to modify this Agreement. The Eclipse Foundation is the
initial Agreement Steward. The Eclipse Foundation may assign the
responsibility to serve as the Agreement Steward to a suitable separate
entity. Each new version of the Agreement will be given a distinguishing
version number. The Program (including Contributions) may always be
Distributed subject to the version of the Agreement under which it was
received. In addition, after a new version of the Agreement is
published, Contributor may elect to Distribute the Program (including
its Contributions) under the new version.
Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
receives no rights or licenses to the intellectual property of any
Contributor under this Agreement, whether expressly, by implication,
estoppel or otherwise. All rights in the Program not expressly granted
under this Agreement are reserved. Nothing in this Agreement is intended
to be enforceable by any entity that is not a Contributor or Recipient.
No third-party beneficiary rights are created under this Agreement.
## Exhibit A – Form of Secondary Licenses Notice {#exhibit-a}
“This Source Code may also be made available under the following
Secondary Licenses when the conditions for such availability set forth
in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
version(s), and exceptions or additional permissions here}.”
> Simply including a copy of this Agreement, including this Exhibit A is
> not sufficient to license the Source Code under Secondary Licenses.
>
> If it is not possible or desirable to put the notice in a particular
> file, then You may include the notice in a location (such as a LICENSE
> file in a relevant directory) where a recipient would be likely to
> look for such a notice.
>
> You may add additional accurate notices of copyright ownership.
File diff suppressed because one or more lines are too long
@@ -163,11 +163,11 @@ function erstelleAufgabenKarte(task) {
zeilen.push(
erstelleElement("p", {
klasse: "gedaempft text-klein",
text: `Next: ${task.nextRunUtc ? formatiereDatumZeit(task.nextRunUtc) : "—"}`
text: `${t("tasks.nextRun")}: ${task.nextRunUtc ? formatiereDatumZeit(task.nextRunUtc) : "—"}`
})
);
const letzter = erstelleElement("p", { klasse: "gedaempft text-klein" }, "Last: ");
const letzter = erstelleElement("p", { klasse: "gedaempft text-klein" }, `${t("tasks.lastRun")}: `);
if (task.lastRun) {
letzter.append(
`${formatiereDatumZeit(task.lastRun.occurredAt)} `,
@@ -184,6 +184,19 @@ function erstelleAufgabenKarte(task) {
zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${task.hinweis}` }));
}
// #716: What the budget refused, and what happens next.
const ausfall = task.budgetMiss;
if (ausfall) {
const teile = [t("tasks.budgetMissed", { n: ausfall.count })];
if (ausfall.lastAt) teile.push(t("tasks.budgetMissedLast", { time: formatiereDatumZeit(ausfall.lastAt) }));
if (ausfall.carriedOver && ausfall.deferredUntil) {
teile.push(t("tasks.budgetCarriedOver", { time: formatiereDatumZeit(ausfall.deferredUntil) }));
} else if (ausfall.catchUpPending) {
teile.push(t("tasks.budgetCatchUp", { time: ausfall.deferredUntil ? formatiereDatumZeit(ausfall.deferredUntil) : "—" }));
}
zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${teile.join(" · ")}` }));
}
const aktionen = erstelleElement("div", {
klasse: "gruppe-horizontal",
stil: "margin-top: 8px; justify-content: flex-end;"
@@ -0,0 +1,429 @@
// ClawdDotNet — Team overview (#711)
//
// Who is working, who reached whom in the last seconds, and where something is stuck — at a
// glance, like looking across an office. Stage 0: the agents of the active instance and the
// live links between them. With units (#683) they become collapsible containers.
//
// Three rules from the concept (Organisation-Konzept, 10a):
// - The layout comes from the structure, not from the hand: no free node editor.
// - The state is decided by the instance (code), the page only draws it.
// - A click opens a side panel, not the chat — most problems are solved by an approval.
import { api, holeAktiveInstanz } from "../modules/api.js";
import { erstelleElement, erstellePlakette, formatiereDatumZeit } from "../modules/dom.js";
import { abonnieren } from "../modules/events.js";
import { t } from "../modules/i18n.js";
// Loaded on demand: pages without a graph do not pay for ~2 MB of script.
const LIBRARIES = [
"vendor/cytoscape/cytoscape.min.js",
"vendor/elkjs/elk.bundled.js",
"vendor/cytoscape-elk/cytoscape-elk.js"
];
/** How long a link stays visible after it was observed; it fades out over this time. */
const LINK_VISIBLE_MS = 30_000;
/** How often the picture is refreshed without an event — for "stuck" and approvals. */
const REFRESH_MS = 30_000;
// Every state has a colour AND a symbol: colour alone is not enough.
const STATES = {
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
Working: { symbol: "▶", tone: "erfolg", colour: "--accent" },
Stuck: { symbol: "⧗", tone: "gefahr", colour: "--gefahr" },
WaitingForApproval: { symbol: "✋", tone: "warnung", colour: "--warnung" },
Problem: { symbol: "⚠", tone: "gefahr", colour: "--gefahr" },
BudgetExhausted: { symbol: "$", tone: "warnung", colour: "--warnung" }
};
let librariesLoaded = null;
let cy = null;
let snapshot = null;
let selectedAgent = null;
let panel = null;
let summary = null;
let unsubscribe = null;
let refreshTimer = null;
let fadeTimer = null;
let refreshPending = null;
let resizeObserver = null;
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
let layoutWidth = 0;
// ─── Loading ───
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.onload = resolve;
script.onerror = () => reject(new Error(`Could not load ${src}`));
document.head.append(script);
});
}
function loadLibraries() {
librariesLoaded ??= (async () => {
// In order: cytoscape-elk registers against the globals of the two before it.
for (const src of LIBRARIES) await loadScript(src);
window.cytoscape.use(window.cytoscapeElk);
})();
return librariesLoaded;
}
function token(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#888";
}
// ─── View ───
export async function renderTeam() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
leaveTeam();
const layout = erstelleElement("div", { klasse: "team-ansicht" });
layout.append(
erstelleElement("h1", { text: t("team.title") }),
erstelleElement("p", { klasse: "gedaempft", text: t("team.subtitle") })
);
summary = erstelleElement("div", { klasse: "team-zusammenfassung gruppe-horizontal" });
const canvas = erstelleElement("div", { klasse: "team-graph", id: "team-graph" });
panel = erstelleElement("aside", { klasse: "team-seitenleiste" });
panel.hidden = true;
layout.append(summary, erstelleElement("div", { klasse: "team-flaeche" }, canvas, panel), buildLegend());
container.replaceChildren(layout);
if (!holeAktiveInstanz()) {
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("team.noInstance") }));
return;
}
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
try {
await loadLibraries();
snapshot = await api("/team");
} catch (err) {
canvas.replaceChildren(erstelleElement("div", { klasse: "fehler-zentriert", text: err.message }));
return;
}
canvas.replaceChildren();
drawGraph(canvas);
unsubscribe = abonnieren("activity", onActivity);
refreshTimer = setInterval(refresh, REFRESH_MS);
fadeTimer = setInterval(fadeLinks, 1_000);
}
/** Called when the route changes: stop listening, stop timers, free the graph. */
export function leaveTeam() {
unsubscribe?.();
unsubscribe = null;
clearInterval(refreshTimer);
clearInterval(fadeTimer);
clearTimeout(refreshPending);
refreshTimer = fadeTimer = refreshPending = null;
resizeObserver?.disconnect();
resizeObserver = null;
cy?.destroy();
cy = null;
selectedAgent = null;
}
// ─── Graph ───
function drawGraph(canvas) {
cy = window.cytoscape({
container: canvas,
elements: [],
wheelSensitivity: 0.2,
minZoom: 0.3,
maxZoom: 2.5,
// Nodes are not dragged: the layout comes from the structure, not the hand.
autoungrabify: true,
style: [
{
selector: "node",
style: {
shape: "rectangle",
width: 190,
height: 58,
"background-color": token("--surface"),
"border-width": 2,
"border-color": "data(colour)",
label: "data(label)",
"text-wrap": "wrap",
"text-max-width": 176,
"text-valign": "center",
"text-halign": "center",
"font-family": "Barlow, sans-serif",
"font-size": 13,
color: token("--text")
}
},
{ selector: "node.aktiv", style: { "border-width": 4 } },
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
{
selector: "edge",
style: {
width: 3,
"line-color": token("--accent"),
"target-arrow-color": token("--accent"),
"target-arrow-shape": "triangle",
"curve-style": "bezier",
opacity: "data(opacity)"
}
}
]
});
cy.on("tap", "node", (event) => openPanel(event.target.id()));
cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
applySnapshot(true);
for (const link of snapshot.links ?? []) addLink(link);
observeSize(canvas);
}
/**
* The area can still be 0 px wide when the graph is first drawn — the page is being laid out
* around it. ELK then packs everything into one column, and nothing can be fitted. So: lay
* out again once the area has a width, and fit again whenever its size changes (the side
* panel opening, the window resizing).
*/
function observeSize(canvas) {
resizeObserver = new ResizeObserver(() => {
if (!cy) return;
cy.resize();
if (layoutWidth === 0 && canvas.clientWidth > 0) runLayout();
else cy.fit(undefined, 30);
});
resizeObserver.observe(canvas);
}
function runLayout() {
const area = cy.container();
layoutWidth = area.clientWidth;
const layout = cy.layout({
name: "elk",
fit: false,
// Stage 0 has no structure yet, so the agents are packed evenly into the shape of
// the area. rectpacking keeps their order, so a box does not jump around when a
// state changes. With units (#683) this becomes a layered layout of nested containers.
elk: {
algorithm: "rectpacking",
"elk.spacing.nodeNode": 40,
"elk.aspectRatio": area.clientWidth > 0 && area.clientHeight > 0
? Math.max(0.5, area.clientWidth / area.clientHeight)
: 2
}
});
// ELK computes asynchronously; fit once the positions are there.
layout.one("layoutstop", () => cy?.fit(undefined, 30));
layout.run();
}
function nodeLabel(member) {
const state = STATES[member.state] ?? STATES.Idle;
const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : "";
return `${state.symbol} ${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
}
function applySnapshot(relayout) {
if (!cy || !snapshot) return;
const present = new Set();
for (const member of snapshot.members) {
present.add(member.agentId);
const state = STATES[member.state] ?? STATES.Idle;
const data = { id: member.agentId, label: nodeLabel(member), colour: token(state.colour) };
const node = cy.getElementById(member.agentId);
if (node.nonempty()) {
node.data(data);
} else {
cy.add({ group: "nodes", data });
relayout = true;
}
cy.getElementById(member.agentId).toggleClass("aktiv", member.runs.length > 0);
}
cy.nodes().filter((n) => !present.has(n.id())).remove();
if (relayout) runLayout();
renderSummary();
if (selectedAgent) openPanel(selectedAgent);
}
// ─── Live ───
function forActiveInstance(data) {
// The manager merges the streams of all instances and stamps each event.
return !data?.instanz || data.instanz === holeAktiveInstanz();
}
function onActivity(data) {
if (!cy || !forActiveInstance(data)) return;
if (data.kind === "link" && data.link) addLink(data.link);
// Start and end change states; ask the instance instead of guessing here.
clearTimeout(refreshPending);
refreshPending = setTimeout(refresh, 400);
}
async function refresh() {
if (!cy) return;
try {
snapshot = await api("/team");
applySnapshot(false);
} catch {
// The next event or tick tries again; a failed refresh keeps the last picture.
}
}
function addLink(link) {
if (!cy) return;
const age = Date.now() - new Date(link.atUtc).getTime();
if (age > LINK_VISIBLE_MS) return;
if (cy.getElementById(link.from).empty() || cy.getElementById(link.to).empty()) return;
// One edge per pair; a new contact renews it.
const id = `link:${link.from}->${link.to}`;
const existing = cy.getElementById(id);
const data = { id, source: link.from, target: link.to, seenAt: new Date(link.atUtc).getTime(), opacity: 1 };
if (existing.nonempty()) existing.data(data);
else cy.add({ group: "edges", data });
}
function fadeLinks() {
if (!cy) return;
const now = Date.now();
cy.edges().forEach((edge) => {
const age = now - edge.data("seenAt");
if (age > LINK_VISIBLE_MS) edge.remove();
else edge.data("opacity", Math.max(0.15, 1 - age / LINK_VISIBLE_MS));
});
}
// ─── Summary and legend ───
function renderSummary() {
if (!summary || !snapshot) return;
const counts = {};
for (const member of snapshot.members) counts[member.state] = (counts[member.state] ?? 0) + 1;
const parts = Object.keys(STATES)
.filter((state) => counts[state])
.map((state) => erstellePlakette(`${STATES[state].symbol} ${counts[state]} ${t(`team.state.${state}`)}`, STATES[state].tone));
summary.replaceChildren(
...(parts.length ? parts : [erstelleElement("span", { klasse: "gedaempft", text: t("team.empty") })])
);
}
function buildLegend() {
return erstelleElement(
"div",
{ klasse: "team-legende gruppe-horizontal gedaempft text-klein" },
...Object.entries(STATES).map(([state, s]) =>
erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })),
erstelleElement("span", { text: `→ ${t("team.legendLink")}` })
);
}
// ─── Side panel ───
function openPanel(agentId) {
const member = snapshot?.members.find((m) => m.agentId === agentId);
if (!member || !panel) return;
selectedAgent = agentId;
cy?.getElementById(agentId).select();
const state = STATES[member.state] ?? STATES.Idle;
const rows = [
erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
erstelleElement("h2", { text: member.displayName || member.agentId }),
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: "×",
title: t("common.close"),
onclick: closePanel
})),
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
];
// Top layer: what is happening now.
if (member.runs.length) {
rows.push(erstelleElement("h3", { text: t("team.running") }));
for (const run of member.runs) {
const parts = [
t("team.since", { time: formatiereDatumZeit(run.startedAtUtc) }),
run.source,
run.taskId ? t("team.task", { id: run.taskId }) : null,
run.calledBy ? t("team.calledBy", { agent: run.calledBy }) : null
].filter(Boolean);
rows.push(erstelleElement("p", { klasse: "text-klein", text: parts.join(" · ") }));
}
if (member.state === "Stuck" && member.typicalRunMs > 0) {
rows.push(erstelleElement("p", {
klasse: "text-klein",
text: `⚠ ${t("team.stuckHint", { minutes: Math.max(1, Math.round(member.typicalRunMs / 60_000)) })}`
}));
}
}
if (member.pendingApprovals > 0) {
rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: member.pendingApprovals })}` }));
}
// Middle layer: how the last run ended.
rows.push(erstelleElement("h3", { text: t("team.lastRun") }));
if (member.lastRunStatus) {
rows.push(erstelleElement("p", { klasse: "text-klein" },
erstellePlakette(member.lastRunStatus, member.lastRunStatus === "Completed" ? "erfolg" : "warnung"),
` ${formatiereDatumZeit(member.lastRunAtUtc)}`));
if (member.lastRunResult) {
rows.push(erstelleElement("p", { klasse: "text-klein team-ergebnis", text: member.lastRunResult }));
}
} else {
rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" }));
}
// The actions — the approval first, it is what most problems need.
const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" });
if (member.pendingApprovals > 0) {
actions.append(link(`#/staging`, t("team.openApprovals"), "primaer"));
}
actions.append(
link(`#/chat/${encodeURIComponent(member.agentId)}`, t("team.openChat")),
// Bottom layer: every run with its raw transcript.
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
);
rows.push(actions);
panel.replaceChildren(...rows);
panel.hidden = false;
}
function link(href, text, style = "flach") {
return erstelleElement("button", {
type: "button",
klasse: `${style} klein`,
text,
onclick: () => { window.location.hash = href; }
});
}
function closePanel() {
selectedAgent = null;
cy?.elements().unselect();
if (panel) panel.hidden = true;
}
@@ -210,7 +210,9 @@ public sealed class TaskboardTool : IAgentTool
private static async Task<List<(TaskItem Task, TaskTitleMatch Match)>> SucheAehnlicheAsync(
TaskboardService board, string title, string? exceptId, CancellationToken ct)
{
var alle = await board.ListAsync(new TaskQuery { Limit = 500 }, ct);
// Uncapped (#716): with a limit of 500, a duplicate among older, low-priority tasks
// stayed invisible once the board grew beyond that.
var alle = await board.ListAllAsync(ct);
return TaskSimilarity.FindOpen(alle, title, exceptId);
}
+33 -1
View File
@@ -116,7 +116,39 @@ public sealed record TaskDto(
TaskWhenDto? When = null,
DateTime? NextRunUtc = null,
TaskLastRunDto? LastRun = null,
string? Hinweis = null);
string? Hinweis = null,
TaskBudgetMissDto? BudgetMiss = null);
/// <summary>
/// #716: Runs of this task the budget refused — only present while something is open
/// (misses since the last run that took place, a pending catch-up or a deferral).
/// </summary>
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
/// <summary>#711: The team overview — every agent and who reached whom recently.</summary>
public sealed record TeamSnapshotDto(IReadOnlyList<TeamMemberDto> Members, IReadOnlyList<AgentLinkDto> Links, DateTime AtUtc);
/// <summary>
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
/// WaitingForApproval, Problem, BudgetExhausted — decided by the instance, not the page.
/// </summary>
public sealed record TeamMemberDto(
string AgentId,
string DisplayName,
string Model,
string State,
IReadOnlyList<ActiveRunDto> Runs,
int PendingApprovals,
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs);
/// <summary>#711: A run in progress.</summary>
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
/// <summary>#711: One agent reaching another.</summary>
public sealed record AgentLinkDto(string From, string To, string Kind, DateTime AtUtc);
/// <summary>Der letzte Lauf einer Aufgabe (#594) - aus dem Beleg, samt Kosten.</summary>
public sealed record TaskLastRunDto(DateTime OccurredAt, string Status, decimal CostUsd, bool CostIsKnown, string ResultRef);
+19
View File
@@ -69,6 +69,21 @@ internal static class Mapping
public static ChatEntryDto ToDto(this ChatEntry entry)
=> new(entry.Role, entry.Content, entry.Timestamp, entry.Source);
public static TeamSnapshotDto ToDto(this App.Services.TeamSnapshot snapshot)
=> new(
snapshot.Members.Select(m => new TeamMemberDto(
m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
m.Runs.Select(ToDto).ToList(),
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs)).ToList(),
snapshot.RecentLinks.Select(ToDto).ToList(),
snapshot.AtUtc);
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
public static AgentLinkDto ToDto(this Core.Activity.AgentLink link)
=> new(link.From, link.To, link.Kind, link.AtUtc);
public static TaskDto ToDto(this TaskOverview overview)
=> overview.Task.ToDto() with
{
@@ -129,6 +144,10 @@ internal static class Mapping
task.UpdatedAt,
task.When is { } when
? new TaskWhenDto(when.Kind.ToString().ToLowerInvariant(), when.Value, when.TimeZone)
: null,
BudgetMiss: task.BudgetMissCount > 0 || task.CatchUpPending || task.DeferredUntil is not null
? new TaskBudgetMissDto(task.BudgetMissCount, task.LastBudgetMissAt, task.DeferredUntil,
task.CatchUpPending, task.IsCarriedOver)
: null);
/// <summary>
@@ -31,6 +31,7 @@ internal static class ApiEndpoints
api.MapUsage();
api.MapAnalytics();
api.MapLogs();
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
app.MapEvents();
app.MapControl();
@@ -71,12 +71,23 @@ internal static class EventEndpoints
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
=> Publish("run", new
{
agentId = result.AgentId,
model,
status = result.Status.ToString(),
steps = result.StepCount,
tokens = result.TokensUsed
});
// #711: the team overview — who starts, who stops, who reached whom.
void OnRunStarted(Core.Activity.ActiveRun run)
=> Publish("activity", new { kind = "started", run = run.ToDto() });
void OnRunFinished(Core.Activity.ActiveRun run, string status)
=> Publish("activity", new { kind = "finished", run = run.ToDto(), status });
void OnLink(Core.Activity.AgentLink link)
=> Publish("activity", new { kind = "link", link = link.ToDto() });
void OnInstanceChanged(InstanceChange change)
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
@@ -88,6 +99,9 @@ internal static class EventEndpoints
engine.OnChatEntryAdded += OnChat;
engine.OnStepCompleted += OnStep;
engine.OnRunCompleted += OnRunCompleted;
engine.Activity.RunStarted += OnRunStarted;
engine.Activity.RunFinished += OnRunFinished;
engine.Activity.LinkObserved += OnLink;
}
host.InstanceChanged += OnInstanceChanged;
@@ -106,6 +120,9 @@ internal static class EventEndpoints
e.OnChatEntryAdded -= OnChat;
e.OnStepCompleted -= OnStep;
e.OnRunCompleted -= OnRunCompleted;
e.Activity.RunStarted -= OnRunStarted;
e.Activity.RunFinished -= OnRunFinished;
e.Activity.LinkObserved -= OnLink;
}
host.InstanceChanged -= OnInstanceChanged;
@@ -0,0 +1,82 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Activity;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #711: The rules that decide what the team overview shows for an agent. Every state comes
/// from a recorded fact, never from a model's judgement.
/// </summary>
public sealed class TeamOverviewTests
{
private static readonly DateTime Now = new(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc);
private static ActiveRun RunningFor(TimeSpan duration)
=> new("r", "worker", "task", null, null, Now - duration);
[Fact]
public void Nothing_running_and_a_good_last_run_is_idle()
=> TeamOverview.Classify([], 0, "Completed", TimeSpan.FromMinutes(1), Now).ShouldBe(TeamState.Idle);
[Fact]
public void An_agent_without_any_run_yet_is_idle()
=> TeamOverview.Classify([], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
[Fact]
public void A_run_in_progress_is_working()
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, null, TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.Working);
[Fact]
public void A_run_far_beyond_the_usual_duration_is_stuck()
// Usually 2 minutes: three times that is 6, running for 7.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(7))], 0, null, TimeSpan.FromMinutes(2), Now)
.ShouldBe(TeamState.Stuck);
[Fact]
public void Short_runs_get_a_floor_before_they_count_as_stuck()
// Usually 20 seconds: three times is one minute, but the floor is five.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(3))], 0, null, TimeSpan.FromSeconds(20), Now)
.ShouldBe(TeamState.Working);
[Fact]
public void Without_history_a_run_is_stuck_only_after_a_long_while()
{
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(10))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Working);
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(16))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Stuck);
}
[Fact]
public void An_open_approval_outranks_working()
// What the human can do something about comes first.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 2, null, TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.WaitingForApproval);
[Fact]
public void Stuck_outranks_an_open_approval()
=> TeamOverview.Classify([RunningFor(TimeSpan.FromHours(1))], 2, null, TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.Stuck);
[Theory]
[InlineData("Failed")]
[InlineData("LoopLimitExceeded")]
[InlineData("TargetBusy")]
public void A_failed_last_run_is_a_problem(string status)
=> TeamOverview.Classify([], 0, status, TimeSpan.Zero, Now).ShouldBe(TeamState.Problem);
[Fact]
public void A_refused_last_run_shows_the_exhausted_budget()
=> TeamOverview.Classify([], 0, "BudgetExceeded", TimeSpan.Zero, Now).ShouldBe(TeamState.BudgetExhausted);
[Fact]
public void A_new_run_hides_the_old_problem()
// A new run is the attempt to get past it; the problem shows again if it fails.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, "Failed", TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.Working);
[Fact]
public void A_cancelled_run_is_not_a_problem()
// Cancelled means a human stopped it — that was a decision, not a fault.
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
}
@@ -0,0 +1,143 @@
using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Tools;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Activity;
/// <summary>
/// #711: Who is working right now and who reached whom — the live side of the team overview.
/// </summary>
public sealed class AgentActivityTests
{
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero));
[Fact]
public void A_run_is_active_until_its_handle_is_disposed()
{
var activity = new AgentActivity(_clock);
using (var run = activity.Begin("r1", "worker", "task", "t-1", null))
{
var active = activity.ActiveRuns().ShouldHaveSingleItem();
active.AgentId.ShouldBe("worker");
active.TaskId.ShouldBe("t-1");
active.CalledBy.ShouldBeNull();
run.Complete("Completed");
}
activity.ActiveRuns().ShouldBeEmpty();
}
[Fact]
public void Start_and_end_are_announced_with_the_status()
{
var activity = new AgentActivity(_clock);
var log = new List<string>();
activity.RunStarted += r => log.Add($"start {r.AgentId}");
activity.RunFinished += (r, status) => log.Add($"end {r.AgentId} {status}");
using (var run = activity.Begin("r1", "worker", "chat", null, null))
run.Complete("LoopLimitExceeded");
using (activity.Begin("r2", "worker", "chat", null, null)) { }
log.ShouldBe(["start worker", "end worker LoopLimitExceeded", "start worker", "end worker Aborted"]);
}
[Fact]
public void A_delegated_run_records_who_called_whom()
{
var activity = new AgentActivity(_clock);
AgentLink? seen = null;
activity.LinkObserved += l => seen = l;
var chain = DelegationChain.Root("lead").Append("buyer");
using (activity.Begin("r1", "buyer", "agent_comm", null, chain)) { }
seen.ShouldNotBeNull();
seen.From.ShouldBe("lead");
seen.To.ShouldBe("buyer");
seen.Kind.ShouldBe("delegation");
activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().From.ShouldBe("lead");
}
[Fact]
public void Old_links_fall_out_of_the_recent_window()
{
var activity = new AgentActivity(_clock);
using (activity.Begin("r1", "buyer", "agent_comm", null, DelegationChain.Root("lead").Append("buyer"))) { }
_clock.Advance(TimeSpan.FromMinutes(11));
activity.RecentLinks(TimeSpan.FromMinutes(10)).ShouldBeEmpty();
}
[Fact]
public void The_memory_of_links_is_bounded()
{
var activity = new AgentActivity(_clock);
var chain = DelegationChain.Root("lead").Append("buyer");
for (var i = 0; i < AgentActivity.RecentLinkCapacity + 50; i++)
using (activity.Begin($"r{i}", "buyer", "agent_comm", null, chain)) { }
activity.RecentLinks(TimeSpan.FromDays(1)).Count.ShouldBe(AgentActivity.RecentLinkCapacity);
}
[Fact]
public void A_subscriber_that_throws_does_not_break_the_run()
{
var activity = new AgentActivity(_clock);
activity.RunStarted += _ => throw new InvalidOperationException("broken page");
activity.RunFinished += (_, _) => throw new InvalidOperationException("broken page");
Should.NotThrow(() =>
{
using var run = activity.Begin("r1", "worker", "chat", null, null);
run.Complete("Completed");
});
activity.ActiveRuns().ShouldBeEmpty();
}
// ─── through the engine ───
[Fact]
public async Task The_engine_announces_a_delegated_run_with_its_caller()
{
var fixture = new EngineFixture();
fixture.AddAgent("lead");
fixture.AddAgent("buyer");
fixture.Client.RespondsWithText("Erledigt.");
var started = new List<ActiveRun>();
var finished = new List<string>();
fixture.Engine.Activity.RunStarted += started.Add;
fixture.Engine.Activity.RunFinished += (_, status) => finished.Add(status);
var result = await fixture.Engine.SendMessageAsync("lead", "buyer", "Bitte pruefen", "inst", null, default);
result.Delivered.ShouldBeTrue(result.Error);
var run = started.ShouldHaveSingleItem();
run.AgentId.ShouldBe("buyer");
run.CalledBy.ShouldBe("lead");
finished.ShouldBe(["Completed"]);
fixture.Engine.Activity.ActiveRuns().ShouldBeEmpty();
fixture.Engine.Activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().To.ShouldBe("buyer");
}
[Fact]
public async Task A_fresh_run_is_visible_too()
{
// Before #711 only runs through ChatAsync counted as "running"; RunAsync (@new) did not.
var fixture = new EngineFixture();
var agent = fixture.AddAgent("worker");
fixture.Client.RespondsWithText("Fertig.");
var started = new List<ActiveRun>();
fixture.Engine.Activity.RunStarted += started.Add;
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
started.ShouldHaveSingleItem().AgentId.ShouldBe("worker");
}
}
@@ -0,0 +1,214 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Budget;
/// <summary>
/// #716, stage 3: late work — carried-over tasks and catch-ups — only uses budget the rest
/// of the day does not need. The new day has right of way.
/// </summary>
public sealed class BudgetHeadroomTests
{
// 2027-03-10 14:00 UTC; the test clock's local zone is UTC, so the day ends at 24:00 UTC.
private static readonly DateTime Now = new(2027, 3, 10, 14, 0, 0, DateTimeKind.Utc);
private static readonly DateTime CreatedEarlier = new(2027, 3, 1, 0, 0, 0, DateTimeKind.Utc);
private static AgentConfig Agent(decimal dailyCost = 1.00m)
=> new() { AgentId = "worker", Budget = new AgentBudget { DailyCostUsd = dailyCost } };
/// <summary>Runs averaging 0.05 USD.</summary>
private static List<RunUsage> History(decimal cost = 0.05m)
=> [.. Enumerable.Range(0, 5).Select(_ => new RunUsage { AgentId = "worker", PromptTokens = 1_000, CostUsd = cost, CostIsKnown = true })];
private static TaskItem Cron(string id, string expression, string? lastOccurrence = null) => new()
{
Id = id, Title = id, Assignee = "@worker", CreatedAt = CreatedEarlier, LastOccurrence = lastOccurrence,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = expression, TimeZone = "UTC" }
};
private static TaskItem Carried(string id) => new()
{
Id = id, Title = id, Assignee = "@worker", CreatedAt = CreatedEarlier, BudgetMissCount = 1
};
private static BudgetHeadroom Headroom(decimal used, AgentConfig agent, InstanceBudget? instance = null, decimal perRun = 0.05m)
=> new(new BudgetGuard(new FakeUsage(used, History(perRun))),
() => [agent], () => instance ?? InstanceBudget.Unlimited, new UtcClock(Now));
// ═══════════════════ counting the rest of the day ═══════════════════
[Fact]
public void Hourly_from_14_30_until_midnight_is_nine_more_runs()
{
// 15:00 … 23:00. The 14:00 run is done (marker), so nothing is due right now.
var task = Cron("t", "0 * * * *", lastOccurrence: "2027-03-10T14:00:00.0000000Z");
TaskSchedule.OccurrencesBetween(task, Now.AddMinutes(30), Now.Date.AddDays(1)).ShouldBe(9);
}
[Fact]
public void A_run_due_right_now_counts_as_well()
{
// Last run 13:00, so the 14:00 run is due now; then 15:00 … 23:00.
var task = Cron("t", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
TaskSchedule.OccurrencesBetween(task, Now.AddMinutes(1), Now.Date.AddDays(1)).ShouldBe(10);
}
[Fact]
public void A_one_time_task_counts_once_until_it_ran()
{
var open = new TaskItem { Id = "t", CreatedAt = CreatedEarlier };
var done = open with { LastOccurrence = "2027-03-10T09:00:00.0000000Z" };
TaskSchedule.OccurrencesBetween(open, Now, Now.AddHours(1)).ShouldBe(1);
TaskSchedule.OccurrencesBetween(done, Now, Now.AddHours(1)).ShouldBe(0);
}
[Fact]
public void An_interval_counts_its_remaining_ticks()
{
var task = new TaskItem
{
Id = "t", CreatedAt = CreatedEarlier, LastOccurrence = "2027-03-10T13:45:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" }
};
// 14:15, 14:45, 15:15, 15:45 before 16:00.
TaskSchedule.OccurrencesBetween(task, Now, Now.AddHours(2)).ShouldBe(4);
}
// ═══════════════════ headroom ═══════════════════
[Fact]
public async Task Late_work_fits_when_the_rest_of_the_day_leaves_room()
{
// Limit 1.00, used 0.30, margin 0.10. Rest of the day: 10 hourly runs à 0.05 = 0.50.
// Left: 1.00 - 0.30 - 0.50 - 0.10 = 0.10 ≥ 0.05 for the late run.
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var late = Carried("t-late");
var room = await Headroom(used: 0.30m, Agent()).HasRoomAsync(late, [hourly, late], Now, default);
room.ShouldBeTrue();
}
[Fact]
public async Task Late_work_waits_when_the_rest_of_the_day_needs_the_budget()
{
// Same, but 0.40 used: 1.00 - 0.40 - 0.50 - 0.10 = 0.00 < 0.05.
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var late = Carried("t-late");
var room = await Headroom(used: 0.40m, Agent()).HasRoomAsync(late, [hourly, late], Now, default);
room.ShouldBeFalse("the day's own schedule has right of way");
}
[Fact]
public async Task Other_late_work_does_not_count_as_the_days_demand()
{
// Ten carried-over tasks do not reserve budget against each other — they are what
// waits for headroom, not what headroom is kept for.
var late = Carried("t-late");
var others = Enumerable.Range(0, 10).Select(i => Carried($"t-other-{i}")).ToList();
var room = await Headroom(used: 0.50m, Agent()).HasRoomAsync(late, [late, .. others], Now, default);
room.ShouldBeTrue();
}
[Fact]
public async Task The_instance_limit_counts_the_schedules_of_all_agents()
{
var agent = Agent(dailyCost: 0); // no limit of its own
var instance = new InstanceBudget { DailyCostUsd = 1.00m };
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var late = Carried("t-late");
(await Headroom(used: 0.30m, agent, instance).HasRoomAsync(late, [hourly, late], Now, default)).ShouldBeTrue();
(await Headroom(used: 0.40m, agent, instance).HasRoomAsync(late, [hourly, late], Now, default)).ShouldBeFalse();
}
[Fact]
public async Task Without_any_limit_there_is_always_room()
{
var late = Carried("t-late");
(await Headroom(used: 99m, Agent(dailyCost: 0)).HasRoomAsync(late, [late], Now, default)).ShouldBeTrue();
}
// ═══════════════════ the day ahead ═══════════════════
[Fact]
public async Task The_outlook_names_the_hour_the_budget_runs_out()
{
// Limit 1.00, used 0.60, runs at 14:00 (due now), 15:00 … 23:00 à 0.05. Before the
// 22:00 run 0.60 + 8 × 0.05 = 1.00 is used — from there on everything is refused.
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var shortfalls = await Headroom(used: 0.60m, Agent()).ShortfallsAsync([hourly], Now, default);
var shortfall = shortfalls.ShouldHaveSingleItem();
shortfall.AgentId.ShouldBe("worker");
shortfall.ExhaustsAtUtc.ShouldBe(new DateTime(2027, 3, 10, 22, 0, 0, DateTimeKind.Utc));
shortfall.RunsRefused.ShouldBe(2);
shortfall.RunsPlanned.ShouldBe(10);
}
[Fact]
public async Task A_day_that_fits_has_no_shortfall()
{
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
(await Headroom(used: 0.30m, Agent()).ShortfallsAsync([hourly], Now, default)).ShouldBeEmpty();
}
[Fact]
public async Task Late_work_does_not_make_the_day_fall_short()
{
// Fifty carried-over tasks only ever run in headroom — they cannot be the reason the
// day's own runs are refused.
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var late = Enumerable.Range(0, 50).Select(i => Carried($"t-late-{i}")).ToList();
(await Headroom(used: 0.30m, Agent()).ShortfallsAsync([hourly, .. late], Now, default)).ShouldBeEmpty();
}
[Fact]
public async Task The_instance_limit_gets_its_own_forecast()
{
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var instance = new InstanceBudget { DailyCostUsd = 1.00m };
var shortfalls = await Headroom(used: 0.60m, Agent(dailyCost: 0), instance).ShortfallsAsync([hourly], Now, default);
shortfalls.ShouldHaveSingleItem().AgentId.ShouldBe("", "empty means: the instance");
}
// ═══════════════════ test doubles ═══════════════════
private sealed class UtcClock(DateTime now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(now, TimeSpan.Zero);
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
private sealed class FakeUsage(decimal usedCost, IReadOnlyList<RunUsage> recent) : IUsageRepository
{
public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
public Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
=> Task.FromResult(new DailyUsage(date, 0, 0, usedCost, true, 0));
public Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct) => Task.FromResult(recent);
public Task<IReadOnlyList<RunUsage>> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct)
=> Task.FromResult(recent);
public Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0);
}
}
@@ -0,0 +1,173 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Budget;
/// <summary>
/// #716 (the rest of #682): Runs in flight count against the budget with their estimate.
///
/// Before, every run was checked on its own against what was already recorded, so runs
/// starting together all saw the same figure and all passed. #682 capped how many could
/// start at once; the reservation closes the gap.
/// </summary>
public sealed class BudgetReservationTests
{
private static AgentConfig Agent(decimal dailyCost = 1.00m, long dailyTokens = 0)
=> new() { AgentId = "worker", Budget = new AgentBudget { DailyCostUsd = dailyCost, DailyTokens = dailyTokens } };
/// <summary>Recent runs averaging 0.30 USD and 3,000 tokens.</summary>
private static List<RunUsage> History(decimal cost = 0.30m, int tokens = 3_000)
=> [.. Enumerable.Range(0, 5).Select(_ => new RunUsage
{
AgentId = "worker", PromptTokens = tokens, CostUsd = cost, CostIsKnown = true, Status = "Completed"
})];
[Fact]
public async Task Runs_in_flight_count_against_the_budget()
{
var guard = new BudgetGuard(new FakeUsage(usedCost: 0.60m, History()));
var (first, a) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
var (second, b) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
var (third, c) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
first.IsAllowed.ShouldBeTrue("0.60 used, nothing in flight");
second.IsAllowed.ShouldBeTrue("0.60 used + 0.30 in flight");
third.IsAllowed.ShouldBeFalse("0.60 used + 0.60 in flight — before #716 this one passed too");
third.Reason.ShouldNotBeNull().ShouldContain("reserviert");
c.ShouldBeNull();
a!.Dispose();
b!.Dispose();
}
[Fact]
public async Task A_finished_run_frees_its_reservation()
{
var guard = new BudgetGuard(new FakeUsage(usedCost: 0.60m, History()));
var (_, a) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
var (_, b) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
a!.Dispose();
a.Dispose(); // twice is harmless
var (again, c) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
again.IsAllowed.ShouldBeTrue();
b!.Dispose();
c!.Dispose();
guard.Reserved("worker").ShouldBe(default(RunEstimate));
guard.Reserved("").ShouldBe(default(RunEstimate));
}
[Fact]
public async Task A_single_run_is_not_refused_because_of_its_own_estimate()
{
// 0.95 of 1.00 used, an average run costs 0.30: a small task may still fit, and
// refusing it on the average would be guessing.
var guard = new BudgetGuard(new FakeUsage(usedCost: 0.95m, History()));
var (status, reservation) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeTrue();
reservation!.Dispose();
}
[Fact]
public async Task Parallel_starts_cannot_all_pass_together()
{
var guard = new BudgetGuard(new FakeUsage(usedCost: 0m, History()));
var results = await Task.WhenAll(Enumerable.Range(0, 10)
.Select(_ => guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default)));
// 0.00, 0.30, 0.60, 0.90 in flight pass; from 1.20 on the budget is spoken for.
results.Count(r => r.Status.IsAllowed).ShouldBe(4);
foreach (var (_, reservation) in results)
reservation?.Dispose();
}
[Fact]
public async Task The_token_limit_counts_reservations_too()
{
var guard = new BudgetGuard(new FakeUsage(usedCost: 0m, History(cost: 0m, tokens: 4_000)));
var agent = Agent(dailyCost: 0, dailyTokens: 10_000);
var (a, ra) = await guard.ReserveAsync(agent, InstanceBudget.Unlimited, default);
var (b, rb) = await guard.ReserveAsync(agent, InstanceBudget.Unlimited, default);
var (c, _) = await guard.ReserveAsync(agent, InstanceBudget.Unlimited, default);
var (d, _) = await guard.ReserveAsync(agent, InstanceBudget.Unlimited, default);
a.IsAllowed.ShouldBeTrue();
b.IsAllowed.ShouldBeTrue();
c.IsAllowed.ShouldBeTrue("8,000 of 10,000 reserved");
d.IsAllowed.ShouldBeFalse("12,000 reserved");
d.Limit.ShouldBe(BudgetLimit.AgentTokens);
ra!.Dispose();
rb!.Dispose();
}
[Fact]
public async Task The_instance_limit_sees_the_runs_of_all_agents()
{
var guard = new BudgetGuard(new FakeUsage(usedCost: 0m, History()));
var instance = new InstanceBudget { DailyCostUsd = 0.50m };
var unlimitedAgent = Agent(dailyCost: 0);
var (a, _) = await guard.ReserveAsync(unlimitedAgent, instance, default);
var (b, _) = await guard.ReserveAsync(unlimitedAgent, instance, default);
var (c, _) = await guard.ReserveAsync(unlimitedAgent, instance, default);
a.IsAllowed.ShouldBeTrue();
b.IsAllowed.ShouldBeTrue("0.30 in flight");
c.IsAllowed.ShouldBeFalse("0.60 in flight over an instance limit of 0.50");
c.Limit.ShouldBe(BudgetLimit.InstanceCost);
}
[Fact]
public async Task Without_any_limit_nothing_is_reserved()
{
var guard = new BudgetGuard(new FakeUsage(usedCost: 0m, History()));
var (status, reservation) = await guard.ReserveAsync(Agent(dailyCost: 0), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeTrue();
reservation.ShouldBeNull();
}
[Fact]
public async Task Runs_without_usage_do_not_drag_the_estimate_down()
{
var history = History();
history.Add(new RunUsage { AgentId = "worker", Status = "BudgetExceeded" }); // 0 tokens
var guard = new BudgetGuard(new FakeUsage(usedCost: 0m, history));
var estimate = await guard.EstimateRunAsync("worker", default);
estimate.CostUsd.ShouldBe(0.30m);
estimate.Tokens.ShouldBe(3_000);
}
private sealed class FakeUsage(decimal usedCost, IReadOnlyList<RunUsage> recent) : IUsageRepository
{
public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
public async Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
{
await Task.Yield(); // like the database: the caller does not get the answer synchronously
return new DailyUsage(date, 0, 0, usedCost, true, 0);
}
public Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct)
=> Task.FromResult(recent);
public Task<IReadOnlyList<RunUsage>> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct)
=> Task.FromResult(recent);
public Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0);
}
}
@@ -276,17 +276,16 @@ public class HarnessHaertungTests
("ParallelTool", "{}", "c3"))
.RespondsWithText("Fertig.");
var sw = Stopwatch.StartNew();
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
sw.Stop();
// Der belastbare Nachweis: Aufrufe haben sich überlappt.
//
// Hier stand zusätzlich eine Wanduhr-Prüfung (unter 750 ms, seriell wären es 900).
// Sie schlug am 2026-09-24 mit 927 ms fehl, als die Testprojekte parallel liefen —
// die Zeit misst die Last der Maschine, nicht das Verhalten der Engine. Der
// Höchststand gleichzeitiger Aufrufe beweist dasselbe, ohne zu wackeln.
Assert.True(tool.MaxConcurrent > 1,
$"Erwartet: überlappende Aufrufe. Höchststand war {tool.MaxConcurrent}.");
// Die Laufzeit als zweite, schwächere Bestätigung — seriell wären es 900 ms.
Assert.True(sw.ElapsedMilliseconds < 750,
$"Erwartet: deutlich unter 900 ms. Gemessen: {sw.ElapsedMilliseconds} ms.");
}
[Fact]
@@ -0,0 +1,293 @@
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// #716: What the scanner does with a run the budget refused.
///
/// Before, a one-time task was retried every 30 minutes for the rest of the day — each
/// attempt another refusal and another receipt — and then ran after midnight at the
/// expense of the new day, without anyone having decided that. A recurring task's
/// occurrence lapsed silently.
/// </summary>
public sealed class BudgetMissTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
// 2027: a cron schedule never looks back before the task's creation date.
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero));
public BudgetMissTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(_storage);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private TaskScanner Scanner(ITaskDispatcher dispatcher, BudgetMissPolicy policy = BudgetMissPolicy.Expire)
=> new(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock, budgetMissPolicy: _ => policy);
private Task<TaskItem> SeedOneTime(string id, int priority = 3)
=> _repo.UpsertAsync(new TaskItem { Id = id, Title = id, Assignee = "@worker", Priority = priority }, default);
private Task<TaskItem> SeedHourly(string id)
=> _repo.UpsertAsync(new TaskItem
{
Id = id, Title = id, Assignee = "@worker",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 * * * *", TimeZone = "UTC" }
}, default);
private async Task<TaskItem> Get(string id) => (await _repo.GetAsync(id, default))!;
/// <summary>The next local midnight, in UTC — the budget day follows the local date.</summary>
private DateTime NextLocalMidnightUtc()
{
var zone = _clock.LocalTimeZone;
var local = TimeZoneInfo.ConvertTimeFromUtc(_clock.GetUtcNow().UtcDateTime, zone);
return TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local.Date.AddDays(1), DateTimeKind.Unspecified), zone);
}
private void MoveTo(DateTime utc) => _clock.SetUtcNow(new DateTimeOffset(utc, TimeSpan.Zero));
// ═══════════════════ one-time tasks: carried over ═══════════════════
[Fact]
public async Task A_refused_one_time_task_is_carried_over_to_the_next_day()
{
await SeedOneTime("t-report");
var dispatcher = new BudgetDispatcher { HasBudget = false };
var scanner = Scanner(dispatcher);
var midnight = NextLocalMidnightUtc();
await scanner.ScanOnceAsync(default);
var task = await Get("t-report");
task.Status.ShouldBe(TaskItemStatus.Todo, "it is still work to be done");
task.BudgetMissCount.ShouldBe(1);
task.LastBudgetMissAt.ShouldNotBeNull();
task.DeferredUntil.ShouldBe(midnight);
task.IsCarriedOver.ShouldBeTrue();
}
[Fact]
public async Task A_carried_over_task_is_not_retried_the_same_day()
{
await SeedOneTime("t-report");
var dispatcher = new BudgetDispatcher { HasBudget = false };
var scanner = Scanner(dispatcher);
await scanner.ScanOnceAsync(default);
// Before #716: again after 30 minutes, and again, until midnight.
MoveTo(NextLocalMidnightUtc().AddMinutes(-1));
(await scanner.ScanOnceAsync(default)).ShouldBe(0);
dispatcher.Calls.ShouldBe(1, "one refusal, not one every half hour");
}
[Fact]
public async Task On_the_next_day_it_runs_and_the_miss_is_settled()
{
await SeedOneTime("t-report");
var dispatcher = new BudgetDispatcher { HasBudget = false };
var scanner = Scanner(dispatcher);
await scanner.ScanOnceAsync(default);
MoveTo(NextLocalMidnightUtc().AddMinutes(1));
dispatcher.HasBudget = true;
(await scanner.ScanOnceAsync(default)).ShouldBe(1);
var task = await Get("t-report");
task.Status.ShouldBe(TaskItemStatus.Done);
task.BudgetMissCount.ShouldBe(0);
task.DeferredUntil.ShouldBeNull();
task.LastBudgetMissAt.ShouldNotBeNull("kept as history");
}
[Fact]
public async Task The_new_day_has_right_of_way_over_carried_over_work()
{
await SeedOneTime("t-yesterday", priority: 5);
var dispatcher = new BudgetDispatcher { HasBudget = false };
var scanner = Scanner(dispatcher);
await scanner.ScanOnceAsync(default);
MoveTo(NextLocalMidnightUtc().AddMinutes(1));
await SeedOneTime("t-today", priority: 1);
dispatcher.HasBudget = true;
dispatcher.Started.Clear();
await scanner.ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-today", "t-yesterday"],
"carried-over work comes after the day's own, even with a higher priority");
}
// ═══════════════════ recurring tasks: the agent's policy ═══════════════════
[Fact]
public async Task Under_expire_a_refused_occurrence_lapses_and_is_recorded()
{
await SeedHourly("t-hourly");
var dispatcher = new BudgetDispatcher { HasBudget = false };
var scanner = Scanner(dispatcher, BudgetMissPolicy.Expire);
await scanner.ScanOnceAsync(default);
var task = await Get("t-hourly");
task.Status.ShouldBe(TaskItemStatus.Todo);
task.BudgetMissCount.ShouldBe(1);
task.CatchUpPending.ShouldBeFalse();
// Nothing is caught up the next day beyond the regular occurrences.
MoveTo(NextLocalMidnightUtc().AddMinutes(30));
dispatcher.HasBudget = true;
dispatcher.Started.Clear();
(await scanner.ScanOnceAsync(default)).ShouldBe(1, "only the regular occurrence of this hour");
}
[Fact]
public async Task Under_catch_up_once_several_misses_leave_one_pending_catch_up()
{
await SeedHourly("t-hourly");
var dispatcher = new BudgetDispatcher { HasBudget = false };
var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce);
var midnight = NextLocalMidnightUtc();
// Three hours in a row refused.
for (var hour = 0; hour < 3; hour++)
{
await scanner.ScanOnceAsync(default);
_clock.Advance(TimeSpan.FromHours(1));
}
var task = await Get("t-hourly");
task.BudgetMissCount.ShouldBe(3);
task.CatchUpPending.ShouldBeTrue();
task.DeferredUntil.ShouldBe(midnight, "set by the first miss, not pushed on by the later ones");
}
[Fact]
public async Task The_catch_up_runs_exactly_once_on_the_next_day()
{
// A daily report at 22:30: its next regular occurrence is 24 hours away, so here
// the catch-up is the only run — the case catchUpOnce exists for.
MoveTo(new DateTime(2027, 3, 10, 22, 30, 0, DateTimeKind.Utc));
await _repo.UpsertAsync(new TaskItem
{
Id = "t-daily", Title = "t-daily", Assignee = "@worker",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "30 22 * * *", TimeZone = "UTC" }
}, default);
var dispatcher = new BudgetDispatcher { HasBudget = false };
var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce);
(await scanner.ScanOnceAsync(default)).ShouldBe(1, "the regular 22:30 run, refused");
MoveTo(NextLocalMidnightUtc().AddMinutes(1));
dispatcher.HasBudget = true;
dispatcher.Started.Clear();
(await scanner.ScanOnceAsync(default)).ShouldBe(1, "the catch-up");
_clock.Advance(TimeSpan.FromMinutes(1));
(await scanner.ScanOnceAsync(default)).ShouldBe(0, "once, not on every tick");
dispatcher.Started.ShouldBe(["t-daily"]);
var task = await Get("t-daily");
task.CatchUpPending.ShouldBeFalse();
task.BudgetMissCount.ShouldBe(0);
task.Status.ShouldBe(TaskItemStatus.Todo, "still recurring");
}
[Fact]
public async Task A_pending_catch_up_does_not_run_on_the_same_day()
{
await SeedHourly("t-hourly");
var dispatcher = new BudgetDispatcher { HasBudget = false };
var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce);
await scanner.ScanOnceAsync(default);
// Budget raised by the human in the evening: the regular occurrence runs, and it
// covers the pending catch-up — one run, not two.
dispatcher.HasBudget = true;
dispatcher.Started.Clear();
_clock.Advance(TimeSpan.FromHours(1));
(await scanner.ScanOnceAsync(default)).ShouldBe(1);
(await Get("t-hourly")).CatchUpPending.ShouldBeFalse();
}
[Fact]
public async Task A_shutdown_is_not_a_budget_miss()
{
await SeedOneTime("t-report");
var dispatcher = new BudgetDispatcher { Result = TaskLauf.NichtGelaufen };
await Scanner(dispatcher).ScanOnceAsync(default);
var task = await Get("t-report");
task.BudgetMissCount.ShouldBe(0);
task.DeferredUntil.ShouldBeNull("a shutdown keeps the old 30-minute retry");
}
[Fact]
public async Task Late_work_waits_while_the_headroom_gate_says_no()
{
await SeedOneTime("t-yesterday");
var dispatcher = new BudgetDispatcher { HasBudget = false };
var gate = new FixedGate { Room = false };
var scanner = new TaskScanner(_repo, dispatcher, NullLoggerFactory.Instance,
clock: _clock, lateRunGate: gate);
await scanner.ScanOnceAsync(default);
MoveTo(NextLocalMidnightUtc().AddMinutes(1));
await SeedOneTime("t-today");
dispatcher.HasBudget = true;
dispatcher.Started.Clear();
await scanner.ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-today"], "regular work never asks the gate");
gate.Asked.ShouldBe(["t-yesterday"]);
gate.Room = true;
await scanner.ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-today", "t-yesterday"]);
}
private sealed class FixedGate : ILateRunGate
{
public bool Room { get; set; }
public List<string> Asked { get; } = [];
public Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
{
Asked.Add(lateTask.Id);
return Task.FromResult(Room);
}
}
// ═══════════════════ test double ═══════════════════
private sealed class BudgetDispatcher : ITaskDispatcher
{
public bool HasBudget { get; set; } = true;
public TaskLauf? Result { get; set; }
public int Calls { get; private set; }
public List<string> Started { get; } = [];
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
{
Calls++;
Started.Add(task.Id);
return Task.FromResult(Result ?? (HasBudget ? TaskLauf.Erledigt : TaskLauf.KeinBudget));
}
}
}
@@ -143,6 +143,24 @@ public sealed class DublettenTests : IDisposable
(await _board.ListAsync(new TaskQuery { Limit = 50 }, default)).Count.ShouldBe(2);
}
[Fact]
public async Task Die_Dublette_wird_auch_hinter_500_anderen_Aufgaben_gefunden()
{
// #716: Die Suche las ListAsync mit Limit 500, sortiert nach Prioritaet. Eine alte
// Aufgabe mit niedriger Prioritaet fiel damit aus dem Blick, sobald das Brett wuchs.
var alt = await _board.CreateAsync(new TaskItem
{
Title = "Betriebsluecke: Eur Kurse nicht aktuell", Assignee = "@human", Priority = 1
}, default);
for (var i = 0; i < 510; i++)
await _repo.UpsertAsync(new TaskItem { Id = $"t-fill-{i}", Title = $"Anderes Thema {i}", Priority = 5 }, default);
var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Kurse EUR nicht aktuell","assignee":"@human"}""");
result.Success.ShouldBeFalse("die alte Aufgabe meint dasselbe");
result.ErrorMessage.ShouldNotBeNull().ShouldContain(alt.Id);
}
[Fact]
public async Task Eine_erledigte_Aufgabe_blockiert_den_naechsten_Befund_nicht()
{