Author SHA1 Message Date
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
RichardandClaude Opus 5.5 30a5aae825 Merge docs/budgetende-einheiten-konfig
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:53:32 +02:00
RichardandClaude Opus 5.5 66c6d8e73b Organisation-Konzept: Budgetende (#716), Einheiten ohne eigene Konfiguration
- 8a: Was liegen bleibt, wenn das Budget endet. Ist-Zustand nachgesehen
  (einmalige Aufgaben werden still nachgeholt, wiederkehrende verfallen still),
  Vorschlag expire / catch_up_once / carry_over je Agent, der neue Tag hat
  Vorfahrt, Vermerk an der Aufgabe und als Tageszusammenfassung, Vorbeugen
  ueber die Prognose aus #608, Budgetreservierung als Rest aus #682.
- 3: Festlegung des Nutzers, dass Einheiten keine eigene Konfiguration
  speichern; Einstellungen fuer eine Einheit werden gesammelt in die Agenten
  geschrieben. Drei Kollisionen als offen markiert: Rechte-Obergrenze,
  Parallelitaet je Einheit, Grenzen der Leitung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:53:32 +02:00
RichardandClaude Opus 5.5 f99fd0cf02 Merge fix/682-scanner-deckel: Laeufe deckeln, keine stille Kappung (#682)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:24:51 +02:00
RichardandClaude Opus 5.5 8df5d004e4 Scanner: Laeufe deckeln, stille Kappung bei 500 beseitigen (#682)
Drei Fehler im Taktgeber des Taskboards, zwei gemeldet, einer beim Bau gefunden:

1. Unbegrenzte Parallelitaet. Jede faellige Aufgabe startete sofort. Bei
   Cron-Terminen auf runden Zeiten hiess das: alle Agenten gleichzeitig, und
   alle Laeufe passierten die Budgetpruefung gemeinsam, weil sie je Lauf vor
   dem Start laeuft. Neu: maxParallelTaskRuns je Instanz (Vorgabe 4). Der Rest
   wartet, sortiert nach Prioritaet und dann nach Faelligkeit.

2. Stille Kappung. Die Kandidaten kamen ueber ListAsync mit Limit 500; darueber
   fielen Termine lautlos aus dem Takt. Neu: ListRunnableAsync liefert alle
   Aufgaben auf todo, ungekappt. Muessen Aufgaben warten, steht das im Log,
   allerdings nur, wenn sich die Zahl aendert.

3. Der Takt wartete auf jeden Lauf, den er startete. Ein zehnminuetiger Lauf
   hielt damit jeden anderen Termin zehn Minuten an, und mit einem Deckel
   haetten die freien Plaetze leer gestanden. Neu: Die Schleife startet und
   wartet nicht; DisposeAsync wartet stattdessen auf die laufenden Laeufe.

Beansprucht wird nur, was sofort starten kann. Alles andere bleibt auf todo,
sonst liefe beim Warten der Lease ab und die Aufgabe wuerde ein zweites Mal
beansprucht. Ein Cron-Termin geht dadurch nicht verloren, weil DueOccurrence
bis zum letzten Lauf zuruecksucht. Ein von Hand gestarteter Lauf wartet auf
keinen Platz, belegt aber einen.

Bleibt offen: Bis zu maxParallelTaskRuns Laeufe koennen die Budgetpruefung
weiterhin gemeinsam passieren. Die Luecke ist damit begrenzt, aber nicht
geschlossen; dafuer braeuchte es eine Reservierung vor dem Lauf.

Tests: ScannerCapacityTests (9 neu). 1.229 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:24:51 +02:00
RichardandClaude Opus 5.5 3ba9ebc565 Merge docs/lagebild-laufzeiten-paperclip: Lagebild, fremde Laufzeiten, Paperclip-Uebernahmen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:02:49 +02:00
RichardandClaude Opus 5.5 ac63e58141 Organisation-Konzept: Lagebild (#711), fremde Laufzeiten (#712, #713), Paperclip (#714, #715)
- 7a Fremde Laufzeiten: Claude Code u. a. als Laufzeit der
  Entwicklungsabteilung; gilt als weniger vertrauenswuerdig (Worktree/
  Sandbox, Freigabelisten statt Rechte-Umgehung, Ergebnis ist ein Vorschlag,
  nach aussen nur ueber den ClawdDotNet-MCP-Server). Vorbehalte Kontingent
  und Nutzungsbedingungen als offene Entscheidung.
- 9a Von Paperclip gelernt: Zielkette mit Pruefung ruhender Aufgabenbaeume,
  Rueckmeldung nach Vertrauensstufe (fremder Text steigt nicht in der
  Hierarchie auf), Courier Pattern; bewusst nicht uebernommen: Rechte-
  Umgehung als Voreinstellung, firmenweite Sichtbarkeit.
- 10a Das Lagebild: Teamuebersicht mit Layout aus der Struktur,
  aufklappbaren Einheiten, Problemzustaenden aus dem Code und Seitenleiste
  vor dem Chat; Stufe 0 ohne Einheiten.
- Reihenfolge, offene Entscheidungen und Itemliste nachgezogen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:02:49 +02:00
RichardandClaude Opus 5.5 f4189e0e57 Merge feat/i18n-englisch: Englisch als Hauptsprache mit Sprachpaketen (#661)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:43:51 +02:00
33 changed files with 2543 additions and 50 deletions
+384 -5
View File
@@ -1,6 +1,6 @@
# Organisation — 50 bis 100 Agenten geordnet zusammenarbeiten lassen
*Stand: 21. September 2026. 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,6 +123,47 @@ 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 |
### Einstellungen an der Einheit: nie zwei Werte, die sich übertrumpfen
*Festgelegt am 2026-09-24 durch den Nutzer, in zwei Schritten.*
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:
**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.
- **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.
**2. Regeln, die es nur an der Einheit gibt.** Sie haben kein Gegenstück am Agenten und
können deshalb mit nichts konkurrieren:
- **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
Eine Einheit enthält Einheiten, und die wieder Einheiten — **ohne feste Grenze**.
@@ -609,6 +650,76 @@ Und quer dazu: Jedes Werkzeug bringt seine Eignungsproben mit
das angenehm; bei fünfzig ist es die einzige Art, den Überblick zu behalten, welches
Modell womit umgehen kann.
---
## 7a — Fremde Laufzeiten
*Ergänzt am 2026-09-24, angeregt durch Paperclip. Items #712, #713.*
Der vorige Abschnitt legt fest, dass nur die Entwicklungsabteilung Code hervorbringt.
Daraus folgt eine Frage, die bisher offen war: **Womit programmiert sie?** Ein
nachgebautes Schreib-und-Ausführ-Werkzeug von uns wird nie so gut wie Claude Code, das
auf nichts anderes ausgelegt ist. Und mit einem Abo kann es billiger sein als Tokens über
OpenRouter.
Also bekommt jeder Agent eine **Laufzeit**:
| Laufzeit | Was läuft | Wofür |
|---|---|---|
| `native` | unsere Engine mit OpenRouter, wie heute | Voreinstellung, alles außer Programmieren |
| `claude-code` | Claude Code im Kopflos-Modus | die Entwicklungsabteilung |
| `codex` | OpenAI Codex CLI | dasselbe, anderer Anbieter |
| `command` | ein Befehl, dessen Ausgabe das Ergebnis ist | Sonderfälle |
| `http` | ein Aufruf an einen fremden Dienst | Agenten, die woanders laufen |
**Was die Instanz behält:** Organisation, Taskboard, Budget, Audit, Freigaben. **Was die
fremde Laufzeit übernimmt:** die Arbeit innerhalb eines einzelnen Laufs. Paperclip trennt
das vollständig: *„control plane, not execution plane"*. Wir tun das nicht, und das ist
Absicht. Unsere Sicherheit liegt in der Ausführung (StagingGate, Werkzeugrechte,
UntrustedContent). Eine fremde Laufzeit ist deshalb die Ausnahme für einen bestimmten
Zweck, nicht das Modell für alles.
### Eine fremde Laufzeit gilt als weniger vertrauenswürdig
Was innerhalb von Claude Code geschieht, sieht keine unserer Prüfstellen. Daraus folgen
vier Regeln:
1. **Eigener Arbeitsplatz.** Ein Worktree oder eine Sandbox (#654), nie der
Arbeitsbereich der Instanz selbst.
2. **Freigabelisten statt Rechte-Umgehung.** `--allowedTools` und `--max-turns`, nie
`--dangerously-skip-permissions`. Bei Paperclip ist die Umgehung die Voreinstellung;
bei uns ist sie ausgeschlossen.
3. **Das Ergebnis ist ein Vorschlag.** Ein Diff oder ein fertiges Skript zur Abnahme, nie
ein direkter Einsatz. Ob es eingesetzt wird, entscheidet die Lieferkette aus
Abschnitt 7 (#695). Dass es Claude Code geschrieben hat, erspart keine Prüfung.
4. **Nach außen nur über uns.** Mit Taskboard, Leitung und Ablage spricht die fremde
Laufzeit über einen **MCP-Server, den die Instanz je Lauf bereitstellt** (#713). Jeder
Aufruf dort durchläuft dieselbe Kette wie ein nativer Werkzeugaufruf. Der Server
bietet nur an, was der Agent laut Konfiguration hat, und ist damit kein zweiter Weg
an den Rechten vorbei. Das Token gilt einen Lauf lang und wird nie in der
Befehlszeile übergeben.
### Kosten, die nichts kosten
Mit einem Abo kostet ein Lauf 0 $ und verbraucht trotzdem etwas: Kontingent. Die
Token-Zahlen aus `--output-format stream-json` gehen deshalb wie jeder andere Lauf in
RunUsage. In der Kostenanalyse (Abschnitt 8a) erscheinen sie als Kontingentverbrauch, nicht
als „kostenlos". Sonst wird genau die Abteilung unsichtbar, die am meisten Tokens
verbraucht.
### Zwei Vorbehalte vor dem Bau
- **Das Kontingent teilt der Mensch mit sich selbst.** Ein Pro-Plan, auf dem die
Entwicklungsabteilung läuft, ist derselbe, mit dem der Mensch arbeitet. Entweder ein
eigener Account oder eine Vorfahrtsregel, die die Abteilung anhält, bevor der Mensch
an die Grenze stößt.
- **Die Nutzungsbedingungen sind nicht geprüft.** Ob der automatisierte Dauerbetrieb
eines Verbraucher-Abos durch ein Agentensystem gedeckt ist, ist offen. Deshalb werden
beide Anmeldewege gebaut, Abo und API-Schlüssel, und die Entscheidung bleibt eine
Frage der Konfiguration statt eine des Umbaus.
---
## 8 — Kontrolle: eine Leitung delegiert nicht nur
Modelle machen Fehler. Das ist keine Randbedingung, sondern die Arbeitsgrundlage — vier
@@ -818,6 +929,93 @@ muss. Also:
Erkennen ist auch hier Code, nicht Modell: Ein Mittelwert und ein Schwellwert sind eine
Abfrage. Ob die Abweichung ein Problem ist, deutet die Aufsicht (#680).
### Was liegen bleibt, wenn das Budget endet
*Ergänzt am 2026-09-24 auf Hinweis des Nutzers. Item #716.*
Um 22:30 ist das Tagesbudget aufgebraucht, und für den Rest des Tages stehen noch 100
Läufe an. Lässt man sie verfallen, fehlt Arbeit. Holt man sie nach, fressen sie das
Budget des nächsten Tages.
**Heute gibt es dafür schon ein Verhalten. Nur hat es niemand entschieden.** Der Ausfall
wird vermerkt (seit #608 als RunReceipt mit `BudgetExceeded`), aber nicht an der Aufgabe
und nicht zusammengefasst. Einmalige Aufgaben werden alle 30 Minuten erneut versucht und
laufen nach Mitternacht still zu Lasten des neuen Tages. Wiederkehrende verfallen still,
weil ihr Termin schon beim Beanspruchen verbraucht ist. Beides kann richtig sein, aber
nicht, ohne dass es jemand festlegt. Die Tagesgrenze ist die lokale Mitternacht.
Der Vorschlag ist eine Einstellung je Agent, was mit einem Lauf geschieht, der am Budget
scheitert:
| Regel | Was geschieht | Voreinstellung für |
|---|---|---|
| `expire` | verfällt, wird an der Aufgabe vermerkt | wiederkehrende Aufgaben: Der nächste Termin kommt ohnehin, ein verpasster Stundenbericht ist vom nächsten überholt |
| `catch_up_once` | alle verfallenen Termine werden zu **einem** Nachholauf zusammengefasst | — (sinnvoll für den Tagesbericht, dessen nächster Termin erst in 24 Stunden käme) |
| `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 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.
**Vermerkt wird zweimal:** an der Aufgabe („ausgefallen: Budget erschöpft, 22:30" bzw.
„nachgeholt am …") und in einer Tageszusammenfassung („Budget Marketing um 22:30
erschöpft, 100 Läufe ausgefallen, 12 zum Nachholen vorgemerkt"). Die Zusammenfassung
geht in die Abweichungsmeldung oben und ins Lagebild (Abschnitt 10a).
**Besser als jede Nachholregel ist Vorbeugen.** Die Kostenprognose aus #608 weiß schon
heute, ob das Budget für die geplanten Aufgaben reicht. Sie sollte vormittags melden,
dass bei diesem Verbrauch um 22:30 Schluss ist. Dann kann der Mensch rechtzeitig
nachlegen oder entscheiden, was entfällt.
Zur selben Baustelle gehört die **Budgetreservierung**, der offene Rest aus #682: Noch
prüft jeder Lauf das Budget einzeln vor dem Start, und bis zu `maxParallelTaskRuns` Läufe
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) —
@@ -889,6 +1087,87 @@ Daraus drei Festlegungen:
---
## 9a — Von Paperclip gelernt
*Ergänzt am 2026-09-24 nach Durchsicht des Paperclip-Quellcodes. Items #714, #715.*
[Paperclip](https://github.com/paperclipai/paperclip) ist das Projekt, das diesem Konzept
am nächsten kommt: Firma, Organigramm, Budgets, der Mensch als „Board" mit Vetorecht.
Einiges davon lohnt sich zu übernehmen, anderes bewusst nicht.
### Übernommen: die Zielkette (#714)
Jede Aufgabe hat eine **Elternaufgabe**, ganz oben steht das Ziel der Einheit. Damit kann
jeder Agent beantworten, *warum* er etwas tut. Die Kette geht als kurzer Absatz in den
Lauf mit. Das ist etwas anderes als `blocked_by`: Die Elternaufgabe ist der Zweck
(„wofür"), der Blocker die Voraussetzung („wann"). Heute kennt `TaskItem` nur den Blocker.
Daraus folgt eine zweite Fähigkeit, Paperclips *Task Watchdog*: **Kommt ein ganzer
Teilbaum zur Ruhe**, alle Blätter erledigt, abgebrochen, blockiert oder in Prüfung, und
es gibt keinen Weg mehr, auf dem er weiterläuft, dann prüft jemand, ob der Stillstand
berechtigt ist. Denn Agenten hören aus falschen Gründen auf: „erledigt" ohne Beleg, eine
Prüfung ohne Prüfer, ein falsch gelesener Blocker. Ohne Prüfung liegt der Baum einfach
still, und niemand merkt es. Die Aufteilung ist dieselbe wie überall: Ob der Baum ruht,
erkennt eine Abfrage (#646). Ob das in Ordnung ist, deutet die Aufsicht (#680) oder die
Leitung. Der Prüfer gibt zurück, er bessert nicht selbst nach.
### Übernommen: Rückmeldung nach Vertrauensstufe (#715)
Das ist der wertvollste Gedanke aus Paperclip, und er schließt eine Lücke, die dieses
Konzept bisher hatte. Ein Agent, der Rezensionen, Mails oder Webseiten liest, hat fremden
Text im Kontext. UntrustedContent (K2) rahmt diesen Text beim Lesen. Schreibt der Agent
seiner Leitung aber einen Kommentar in eigenen Worten, trägt er den fremden Text eine
Ebene höher, in einen Kontext mit mehr Rechten, und dort ist er nicht mehr gerahmt.
**Prompt-Injection über die Hierarchie.**
Nach oben gibt es deshalb genau drei Wege:
1. **Das Erledigt-Signal.** Immer an. Die Leitung wird geweckt und liest das Ergebnis im
Vorgang selbst.
2. **Ein Kommentar genau eine Ebene nach oben.** Nur ein Kommentar, keine Status- oder
Feldänderung, nie an die übernächste Ebene oder an Geschwister. **Nur für die
Standardstufe.**
3. **Für Agenten, die fremde Inhalte lesen: kein Freitext nach oben.** Stattdessen
verfasst der Code eine Systemmeldung „blockiert" oder „abgebrochen". Sie löst keine
weitere Meldung aus und geht je Zustand nur einmal hinaus.
**Woher die Stufe kommt:** aus den Werkzeugen, nicht aus einer Einstellung. Wer WebFetch,
Mail, Rocket.Chat oder ein Skript mit Außenkontakt hat, liest fremde Inhalte. Das weiß
der Code aus der Konfiguration, eine Selbstauskunft des Agenten ist dafür nicht nötig.
Eine Einstellung darf die Stufe nur verschärfen, nie lockern.
Für Querverbindungen gilt das **„Courier Pattern"**: Wer seitlich etwas braucht, schreibt
nicht in fremde Vorgänge, sondern legt beim Ziel eine vollständige, in sich geschlossene
Aufgabe an. Das ist die Adressbuchregel aus Abschnitt 3 und die Stille-Post-Regel aus
Abschnitt 5, nur zu Ende gedacht: Der Empfänger darf die Vorgänge des Absenders womöglich
gar nicht lesen, also muss alles Nötige in der Aufgabe selbst stehen.
**Reihenfolge:** vor der Freigabe durch die Leitung (#687). Eine Leitung, die Freigaben
erteilt, darf keinen Kontext haben, in den ein Untergebener fremden Text schreiben kann.
### Übernommen, im Kleinen
- **Budget in zwei Stufen.** Die harte Grenze gibt es schon (`BudgetGuard`, Tagesbudget
je Agent und Instanz). Dazu kommt eine Warnung bei 80 % an die Leitung, damit das
Anhalten keine Überraschung ist. Gehört zu #699.
- **Gestaffelte Anzeige.** Oben die lesbare Zusammenfassung, darunter die Schritte, ganz
unten das Rohprotokoll. Gilt für die Teamübersicht (Abschnitt 10a) und die
Laufansichten aus #660.
### Bewusst nicht übernommen
- **Rechte-Umgehung als Voreinstellung.** Der Claude-Code-Adapter startet mit
`dangerouslySkipPermissions = true`, und Skills sind „open by default". Das ist das
Gegenteil unserer Linie, jedem Agenten nur die Rechte zu geben, die er braucht.
- **Die ganze Firma sieht alle Arbeit.** Bei Paperclip ist das ausdrücklich gewollt.
Hier trennen die gemeinsamen Bereiche je Einheit bewusst (Abschnitt 3).
- **Die reine Steuerungsebene.** Siehe Abschnitt 7a.
- **Als Warnsignal: die Komplexität.** Allein die Regeln zur Ausführung umfassen dort
über 1.400 Zeilen, bei knapp 5.000 offenen Issues und im Wesentlichen einem einzigen
Hauptentwickler. Das mahnt, nur zu übernehmen, was eine Lücke schließt.
---
## 10 — Was sich am Menschen ändert
Bei fünf Agenten ist der Mensch der Koordinator. Bei fünfzig kann er es nicht sein, und
@@ -906,6 +1185,91 @@ der Versuch macht ihn zum Flaschenhals.
---
## 10a — Das Lagebild
*Ergänzt am 2026-09-24 auf Vorschlag des Nutzers. Item #711, baut auf #688 auf.*
Die **Teamübersicht**: eine grafische Ansicht wie über ein Büro. Nodes für Agenten, Kanten
für ihre Beziehungen. Auf einen Blick sieht man, wer gerade arbeitet, wer mit wem in den
letzten Sekunden gesprochen hat und wo es klemmt. Ein Klick führt zum Agenten.
Bei fünf Agenten ist das eine Annehmlichkeit. Bei fünfzig ist es die einzige Art, ein
Problem zu bemerken, bevor die Rechnung kommt oder ein Ergebnis fehlt. Abschnitt 10 sagt,
bei fünfzig Agenten müsse etwas anderes dem Menschen sagen, wo er hinsehen soll. Das
Lagebild ist dieses Etwas, zum Hinsehen, neben der Aufsicht (#680) zum Nachlesen.
### Drei Festlegungen
1. **Die Anordnung kommt aus der Struktur, nicht aus der Hand.** In n8n oder Node-RED ist
der Graph das Programm, dort zieht man Nodes, und das soll so sein. Hier zeigt der
Graph eine Wahrheit, die woanders liegt: Einheiten, Leitung, Adressbuch. Sind die
Nodes frei platzierbar und verbindbar, zeigt das Bild bald etwas anderes als die
Organisation. Verschieben per Drag & Drop kommt später als Bedienung für den Umzug
(Abschnitt 3), mit gestoppter Instanz, Bestätigung und Audit-Eintrag, nicht als
Zeichenwerkzeug.
2. **Einheiten sind aufklappbare Container, Probleme wandern nach oben.** Hundert Nodes
mit wechselnden Kanten sind ein Knäuel. Standardansicht sind die Abteilungen mit einer
Zusammenfassung: „4 arbeiten · 1 Problem · 2 Freigaben offen". Eine Markierung am
Container heißt „irgendwo darin", und man klappt auf, bis man den Agenten findet.
Kanten zwischen Einheiten werden gebündelt, mit Zähler.
3. **„Problem" stellt der Code fest, nicht das Modell.** Die Zustände:
| Zustand | Woher |
|---|---|
| arbeitet | aktiver Lauf |
| wartet auf Freigabe | offene StagedCalls |
| wartet auf Kollegen | laufende Delegation |
| Problem | letzter Lauf gescheitert, LoopGuard-Abbruch, Eskalation über Frist, Budget erschöpft |
| hängt | Lauf deutlich länger als bei diesem Agenten üblich |
| pausiert / aus | Konfiguration |
Immer Farbe **und** Symbol.
### Der Klick führt in die Seitenleiste, nicht direkt in den Chat
Die meisten Probleme löst man nicht im Gespräch: Oft wartet eine Freigabe, oder ein
Werkzeug ist dreimal gescheitert. Die Seitenleiste zeigt den laufenden Lauf, den letzten
Fehler im Wortlaut, offene Aufgaben und Freigaben (dort bestätigbar), die Kosten von heute
gegen den Durchschnitt und den Knopf „Chat öffnen". Das ist ein Klick mehr, dafür ist
der richtige Handgriff meist schon da.
### Technik
- **Datenquelle** ist der vorhandene Ereignisstrom (`MergedEventStream`). Neu sind
Ereignistypen mit Absender und Empfänger: Delegation, Task-Zuweisung, später Besprechung
und Eskalation, jeweils mit eigener Linienart. Das Verblassen nach einigen Sekunden
geschieht im Browser.
- **Bibliothek:** Cytoscape.js mit ELK-Layout. Es kann verschachtelte Container und
hierarchisches Layout und bleibt bei Hunderten Nodes flüssig, ohne React. Als Datei im
`wwwroot`, nicht über ein CDN.
- **Nur in der WebUI.** Ein zweiter Graph-Renderer in Avalonia wäre doppelte Arbeit, die
auseinanderläuft. Der Desktop bettet die Ansicht ein oder verlinkt sie.
- Englisch, Texte über Sprachpakete (#661).
### Stufen
- **Stufe 0**, ohne Einheiten: die vorhandenen Agenten mit Delegationskanten und
Zuständen. Das nützt sofort, auf der Test-VM mit sieben Agenten.
- **Stufe 1**, mit #683: Container und nach oben gereichte Probleme. Ab hier fällt das
Lagebild mit dem Organigramm aus #688 zusammen; es gibt nicht zwei Ansichten der
Organisation.
- **Später: die Zeitleiste.** „Wie sah das Büro um 3:12 Uhr aus?" Das kostet wenig, weil
nichts gelöscht wird (Abschnitt 8a): Das AuditLog wird nur neu abgespielt. Für die Frage,
wie ein Projekt gescheitert ist, ist das vielleicht das wertvollste Werkzeug überhaupt.
### Wie andere es machen
Paperclip hat ein statisches Organigramm mit einem Statuspunkt je Agent und daneben eine
Liste der laufenden Läufe, aber keine Ansicht der Kommunikation. LangGraph Studio zeigt den
laufenden Node eines *Ablaufgraphen*, also eines Programms, nicht einer Organisation.
AutoGen Studio, Flowise und Langflow sind Baukästen („Nodes ziehen = Programm"), gut zum
Bauen, schwach als Lagebild. ChatDev und AI Town zeigen das Büro wörtlich als
Pixelgrafik: charmant, aber mit wenig Information. Die Verbindung aus aufklappbarer
Organisation, Live-Zuständen und nach oben gereichten Problemen mit Direktzugriff hat
keines davon.
---
## 11 — Reihenfolge
Nichts davon muss auf einmal entstehen. Die Reihenfolge ist so gewählt, dass jede Stufe
@@ -914,10 +1278,11 @@ für sich schon nützt und keine die nächste blockiert.
| Stufe | Inhalt | Nutzen für sich |
|---|---|---|
| **0** | Parallelität deckeln, stille Kappung bei 500 beseitigen (#682) | Fehler, unabhängig von der Größe |
| **0a** | Lagebild ohne Einheiten (#711, Stufe 0) | Der Mensch sieht auf der Test-VM sofort, wer arbeitet und wo es klemmt |
| **1** | Einheiten als Struktur: Baum, Zugehörigkeit, vererbte Rechte, Adressbuch nach Einheit | Der Schnitt, der alles Weitere trägt |
| **2** | Ablage und Brett je Einheit | Buchhaltung und Einkauf treten sich nicht mehr auf die Füße |
| **3** | Budget und Parallelität je Einheit | Eine ausufernde Abteilung legt nicht die Instanz lahm |
| **4** | Ursprungsverweis in jedem Vorgang (#697) | Entscheidungen stützen sich auf das Original, nicht auf die vierte Nacherzählung |
| **4** | Ursprungsverweis in jedem Vorgang (#697); Zielkette (#714); Rückmeldung nach Vertrauensstufe (#715) | Entscheidungen stützen sich auf das Original, jede Aufgabe kennt ihren Zweck, und fremder Text steigt nicht in der Hierarchie auf |
| **5** | Eskalation, Konflikt und Beschluss (#689) | Eine Frage, die niemand beantworten kann, versandet nicht mehr |
| **6** | Besprechung als Mittel, mit Protokoll | Leitungen können sich abstimmen, ohne *n²* Nachrichten |
| **7** | Entscheidungsinstanz an der Spitze (#690) | Der Betrieb läuft weiter, wenn der Mensch schläft |
@@ -925,11 +1290,11 @@ für sich schon nützt und keine die nächste blockiert.
| **9** | Kontrollpflicht der Leitung, abgestufte personelle Mittel (#693); Doctrine (#698) | Fehler findet die Leitung, nicht erst der Mensch — und sie kann steuern |
| **9a** | Kosten je Einheit, Abweichungsmeldung (#699), Archiv (#700) | Ineffizienz wird sichtbar, bevor sie einen Monat lang läuft |
| **9b** | Abstimmung als dritte Freigabeform (#702) | Vier Blickwinkel statt einer Unterschrift, wo es sich lohnt |
| **10** | Oberfläche: Organigramm statt Agentenliste, Lage je Abteilung | Bei fünfzig Agenten ist eine flache Liste keine Übersicht |
| **10** | Oberfläche: Organigramm und Lagebild in einem (#688, #711 Stufe 1) | Bei fünfzig Agenten ist eine flache Liste keine Übersicht |
| **11** | Organisationsvorlagen (#691) | Ein Haus lässt sich weitergeben statt nachbauen |
Quer dazu, unabhängig von der Reihenfolge: der Werkzeugkasten (#692), der A/B-Vergleich
von Modellen (#694) und die Schemaauskunft (#676) — letztere ist Vorbedingung für
von Modellen (#694), die fremden Laufzeiten samt MCP-Server (#712, #713; nach der Sandbox #654) und die Schemaauskunft (#676) — letztere ist Vorbedingung für
Stufe 5, weil eine Eskalation einen belastbaren Negativbefund braucht.
**Zum Umzug (Abschnitt 3):** Er gehört zu Stufe 1. Eine Struktur, die sich nach dem
@@ -979,11 +1344,18 @@ Gegenlesen am 2026-09-22 als zu grob aufgefallen:
- **Was ist eine „Grenze" der Leitung?** Das Konzept sagt „die Leitung gibt frei, was
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).
alles davon an der Einheit, nicht an der Person der Leitung (Stufe 8). *Überholt am
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`
(#674). Ein Beschluss, den man aktiv lesen müsste, wird nicht gelesen (Stufe 5).
- **Auf welchem Account läuft eine fremde Laufzeit?** (Abschnitt 7a, vor #712.) Ein
Pro-Plan teilt sein Kontingent mit dem Menschen, und ob der automatisierte
Dauerbetrieb eines Verbraucher-Abos von den Nutzungsbedingungen gedeckt ist, ist
ungeprüft. Vorschlag: beide Anmeldewege bauen, als Voreinstellung den API-Schlüssel
nehmen und das Abo erst nach Prüfung der Bedingungen freigeben.
- ~~**Was passiert, wenn die Eskalationsgrenze erreicht ist?**~~ **Entschieden am
2026-09-22, und schärfer als vorgeschlagen: Eine Eskalation, die nicht gelöst werden
kann, darf es nicht geben.** Finden die Agenten binnen einer einstellbaren Frist keine
@@ -1024,3 +1396,10 @@ nur nicht selbst an.
| #654, #196, #657 | Sandbox, Shell, Skriptprüfung — der eigentliche Werkzeugkasten |
| #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 |
| #714 | Zielkette und Prüfung ruhender Aufgabenbäume |
| #715 | Rückmeldung nach Vertrauensstufe, Courier Pattern |
+19 -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;
@@ -725,7 +726,24 @@ public sealed class AppHost : IAsyncDisposable
var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
var scanner = new TaskScanner(taskRepository, dispatcher, 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,
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)
@@ -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);
}
+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;
}
@@ -152,6 +152,23 @@ public sealed class InstanceConfig
[JsonPropertyName("delegationGateTimeoutSeconds")]
public int DelegationGateTimeoutSeconds { get; set; } = 120;
/// <summary>
/// #682: How many scheduled task runs may be in flight at the same time. Due tasks
/// beyond this wait in order of priority and due time and start as soon as a run
/// finishes.
///
/// <para><b>Why a cap at all.</b> Cron expressions pile up on round times. Without a
/// cap, fifty due tasks meant fifty simultaneous model calls — provider rate limits,
/// a cost spike, and all fifty passing the instance budget check together, because
/// that check runs per run before it starts.</para>
///
/// <para>Per instance for now; per unit comes with #685. Values below 1 count as 1.
/// A run the human starts by hand does not wait for a free slot, but it occupies one
/// while it runs.</para>
/// </summary>
[JsonPropertyName("maxParallelTaskRuns")]
public int MaxParallelTaskRuns { get; set; } = 4;
[JsonPropertyName("agents")]
public List<AgentConfig> Agents { get; set; } = new();
+19 -8
View File
@@ -35,6 +35,12 @@ 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>
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
@@ -163,8 +169,11 @@ 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();
@@ -444,8 +453,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();
@@ -1638,17 +1649,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 +1687,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
};
}
@@ -19,6 +19,23 @@ public interface ITaskRepository
Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct);
/// <summary>
/// #682: Every task in status <c>todo</c> — the scanner's candidates, without a cap.
///
/// <para><see cref="ListAsync"/> clamps to 500 because it serves views and tools. The
/// scanner used it too, so beyond 500 tasks schedules silently fell out of the tick.
/// Only <c>todo</c> can be claimed, so this list is bounded by the work that is
/// actually waiting, not by the size of the board.</para>
/// </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"/>)
@@ -55,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,37 @@ 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);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE Status = @status";
cmd.Parameters.AddWithValue("@status", TaskText.Of(TaskItemStatus.Todo));
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>> ListBlockedByAsync(string blockerId, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
@@ -237,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)
{
@@ -303,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
{
+373 -18
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
{
@@ -84,6 +115,40 @@ public sealed class TaskScanner : IAsyncDisposable
/// <summary>Pause, bevor eine nicht gelaufene einmalige Aufgabe erneut versucht wird.</summary>
public static readonly TimeSpan Wiedervorlage = TimeSpan.FromMinutes(30);
/// <summary>#682: Default for <see cref="Config.InstanceConfig.MaxParallelTaskRuns"/>.</summary>
public const int DefaultMaxParallelRuns = 4;
private readonly int _maxParallelRuns;
/// <summary>
/// Runs started by the tick loop that have not finished yet. The loop no longer waits
/// for them (#682), so disposal has to.
/// </summary>
private readonly HashSet<Task> _inFlight = [];
/// <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;
@@ -94,7 +159,11 @@ public sealed class TaskScanner : IAsyncDisposable
IMarketCalendar? market = null,
TimeProvider? clock = null,
TimeSpan? tick = null,
TimeSpan? lease = null)
TimeSpan? lease = null,
int maxParallelRuns = DefaultMaxParallelRuns,
Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null,
ILateRunGate? lateRunGate = null,
IBudgetOutlook? budgetOutlook = null)
{
_repo = repo;
_dispatcher = dispatcher;
@@ -103,8 +172,15 @@ public sealed class TaskScanner : IAsyncDisposable
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Scanner");
_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>
public int MaxParallelRuns => _maxParallelRuns;
public void Start()
{
_loop ??= RunLoopAsync(_cts.Token);
@@ -158,7 +234,10 @@ public sealed class TaskScanner : IAsyncDisposable
try
{
await ScanOnceAsync(ct);
// #682: Start, don't wait. Before, the tick awaited every run it started,
// so one ten-minute run held back every other schedule for ten minutes.
// Now a finished run frees its slot and the next tick fills it.
await LaunchDueAsync(ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -169,20 +248,45 @@ public sealed class TaskScanner : IAsyncDisposable
/// <summary>
/// Ein Durchlauf: fällige Aufgaben beanspruchen und ausführen. Gibt die Zahl der
/// tatsächlich angestoßenen Läufe zurück. Öffentlich, damit Tests einen Takt
/// deterministisch auslösen können.
/// tatsächlich angestoßenen Läufe zurück und kehrt erst zurück, wenn sie fertig sind.
/// Öffentlich, damit Tests einen Takt deterministisch auslösen können — die
/// Taktschleife selbst wartet nicht auf die Läufe (#682).
/// </summary>
public async Task<int> ScanOnceAsync(CancellationToken ct)
{
var launched = await LaunchDueAsync(ct);
await Task.WhenAll(launched);
return launched.Count;
}
/// <summary>
/// Claims due tasks while a slot is free and starts them without waiting.
///
/// <para><b>Claim only what can start now (#682).</b> A claim sets the task to
/// <c>running</c> with a lease. Claiming everything and queueing it in memory would
/// let a waiting task outlive its lease and be claimed a second time. So tasks beyond
/// the free slots stay <c>todo</c> and are picked up by a later tick — a cron occurrence
/// is not lost by that, <see cref="TaskSchedule.DueOccurrence"/> searches back to the
/// last run.</para>
///
/// <para><b>Order:</b> priority first, then the oldest due occurrence — when slots are
/// short, what matters and what has waited longest goes first.</para>
/// </summary>
private async Task<IReadOnlyList<Task>> LaunchDueAsync(CancellationToken ct)
{
if (!IsAcceptingWork)
return 0;
return [];
var now = _clock.GetUtcNow().UtcDateTime;
var leaseCutoff = now - _lease;
var candidates = await _repo.ListAsync(new TaskQuery { Limit = 500 }, ct);
// #682: ungekappt. Vorher ListAsync mit Limit 500 — darüber fielen Termine lautlos aus.
var candidates = await _repo.ListRunnableAsync(ct);
var claimed = new List<(TaskItem Task, string Token)>();
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)
@@ -225,23 +329,122 @@ 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;
var token = Guid.NewGuid().ToString("N");
if (await _repo.TryClaimAsync(task.Id, occ, token, now, leaseCutoff, ct))
claimed.Add((task, token));
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));
}
// Gleichzeitig anstoßen — Läufe desselben Agenten serialisiert ohnehin das
// Agent-Gate der Engine; verschiedene Agenten laufen echt parallel.
await Task.WhenAll(claimed.Select(c => ProcessAsync(c.Task, c.Token, ct)));
// #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);
});
return claimed.Count;
var launched = new List<Task>();
var waiting = 0;
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.
if (OpenCount >= _maxParallelRuns)
{
waiting++;
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)));
}
// A cap that hides its effect is worse than none (the lesson of #673): say when
// due work had to wait, but only when that changes — the tick runs every minute.
if (waiting != _lastWaiting)
{
if (waiting > 0)
_logger.LogInformation(
"{Waiting} due task(s) waiting for a free slot — {Max} run(s) at a time (maxParallelTaskRuns)",
waiting, _maxParallelRuns);
else
_logger.LogInformation("No due tasks waiting for a slot anymore");
_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;
}
/// <summary>
/// Runs a claimed task and logs what escapes it. The tick loop does not await its runs,
/// so an exception here would otherwise go unobserved.
/// </summary>
private async Task RunLoggedAsync(TaskItem task, string token, CancellationToken ct)
{
try
{
await ProcessAsync(task, token, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Shutdown. The claim is released by the lease or the next start's reconciliation.
}
catch (Exception ex)
{
_logger.LogError(ex, "Run of task {TaskId} failed outside the dispatch", task.Id);
}
}
private Task Track(Task run)
{
lock (_inFlight)
_inFlight.Add(run);
_ = run.ContinueWith(t =>
{
lock (_inFlight)
_inFlight.Remove(t);
}, TaskScheduler.Default);
return run;
}
/// <summary>
@@ -304,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)
@@ -334,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.
@@ -378,6 +725,14 @@ public sealed class TaskScanner : IAsyncDisposable
try { await _loop; }
catch (OperationCanceledException) { }
}
// #682: The loop no longer waits for its runs, so wait for them here — otherwise
// a run could still write to the repository after the scanner is gone.
Task[] inFlight;
lock (_inFlight)
inFlight = [.. _inFlight];
await Task.WhenAll(inFlight);
_cts.Dispose();
}
}
@@ -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",
@@ -192,6 +192,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",
@@ -192,6 +192,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",
@@ -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;"
@@ -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);
}
+8 -1
View File
@@ -116,7 +116,14 @@ 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>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);
+4
View File
@@ -129,6 +129,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>
@@ -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);
}
}
@@ -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));
}
}
}
@@ -0,0 +1,288 @@
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>
/// #682: The scanner caps parallel runs and no longer drops candidates beyond 500.
///
/// Before, every due task started at once — fifty agents on a round cron time meant fifty
/// simultaneous model calls, all passing the instance budget check together. And the
/// candidate list was clamped to 500 without a word, so schedules beyond that silently
/// fell out of the tick.
/// </summary>
public sealed class ScannerCapacityTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 7, 31, 12, 0, 0, TimeSpan.Zero));
public ScannerCapacityTests()
{
_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, int maxParallelRuns)
=> new(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock, maxParallelRuns: maxParallelRuns);
private Task<TaskItem> SeedDue(string id, int priority = 3, TaskWhen? when = null)
=> _repo.UpsertAsync(new TaskItem
{
Id = id,
Title = id,
Assignee = "@new",
Status = TaskItemStatus.Todo,
Priority = priority,
When = when
}, default);
private static async Task WaitUntil(Func<bool> condition)
{
var deadline = DateTime.UtcNow.AddSeconds(10);
while (!condition())
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException("condition not reached within 10 s");
await Task.Delay(10);
}
}
// ═══════════════════ the cap ═══════════════════
[Fact]
public async Task No_more_runs_than_the_cap_are_ever_in_flight()
{
for (var i = 0; i < 7; i++)
await SeedDue($"t-{i}");
var dispatcher = new TrackingDispatcher(holdFor: TimeSpan.FromMilliseconds(40));
var scanner = Scanner(dispatcher, maxParallelRuns: 2);
// Several ticks until everything has run — each tick fills only the free slots.
for (var tick = 0; tick < 20 && dispatcher.Started.Count < 7; tick++)
await scanner.ScanOnceAsync(default);
dispatcher.Started.Count.ShouldBe(7, "every due task runs eventually");
dispatcher.MaxConcurrent.ShouldBeLessThanOrEqualTo(2);
}
[Fact]
public async Task Tasks_beyond_the_cap_stay_todo_and_are_not_claimed()
{
await SeedDue("t-a", priority: 5);
await SeedDue("t-b");
await SeedDue("t-c");
var dispatcher = new TrackingDispatcher(gated: true);
var scanner = Scanner(dispatcher, maxParallelRuns: 1);
var scan = scanner.ScanOnceAsync(default);
await WaitUntil(() => dispatcher.Started.Count == 1);
await Task.Delay(100); // give a (wrong) second claim the chance to happen
dispatcher.Started.ShouldBe(["t-a"]);
(await _repo.GetAsync("t-b", default))!.Status.ShouldBe(TaskItemStatus.Todo,
"a waiting task must not be claimed — its lease would run out while it waits");
(await _repo.GetAsync("t-c", default))!.Status.ShouldBe(TaskItemStatus.Todo);
dispatcher.ReleaseAll();
(await scan).ShouldBe(1);
}
[Fact]
public async Task A_task_that_had_to_wait_keeps_its_occurrence()
{
var at = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T11:00:00Z", TimeZone = "UTC" };
await SeedDue("t-first", priority: 5);
await SeedDue("t-later", when: at);
var dispatcher = new TrackingDispatcher(gated: true);
var scanner = Scanner(dispatcher, maxParallelRuns: 1);
var first = scanner.ScanOnceAsync(default);
await WaitUntil(() => dispatcher.Started.Count == 1);
dispatcher.ReleaseAll();
await first;
// Ten minutes later, the one-time occurrence at 11:00 is still due.
_clock.Advance(TimeSpan.FromMinutes(10));
var second = scanner.ScanOnceAsync(default);
await WaitUntil(() => dispatcher.Started.Count == 2);
dispatcher.ReleaseAll();
await second;
dispatcher.Started.ShouldBe(["t-first", "t-later"]);
}
[Fact]
public async Task A_run_started_by_hand_occupies_a_slot()
{
await SeedDue("t-manual");
await SeedDue("t-scheduled");
var dispatcher = new TrackingDispatcher(gated: true);
var scanner = Scanner(dispatcher, maxParallelRuns: 1);
var manual = scanner.RunTaskNowAsync("t-manual", default);
await WaitUntil(() => dispatcher.Started.Count == 1);
(await scanner.ScanOnceAsync(default)).ShouldBe(0, "the only slot is taken by the manual run");
dispatcher.ReleaseAll();
(await manual).ShouldBeTrue();
// The slot is free again: the waiting task starts on the next tick.
var next = scanner.ScanOnceAsync(default);
await WaitUntil(() => dispatcher.Started.Count == 2);
dispatcher.ReleaseAll();
(await next).ShouldBe(1);
}
[Fact]
public async Task A_value_below_one_still_lets_work_through()
{
await SeedDue("t-1");
var scanner = Scanner(new TrackingDispatcher(), maxParallelRuns: 0);
scanner.MaxParallelRuns.ShouldBe(1);
(await scanner.ScanOnceAsync(default)).ShouldBe(1);
}
[Fact]
public async Task A_long_run_does_not_hold_back_the_next_tick()
{
// Real clock and a short tick: this is about the loop, not a single scan.
await SeedDue("t-slow");
var dispatcher = new TrackingDispatcher(gated: true);
await using var scanner = new TaskScanner(_repo, dispatcher, NullLoggerFactory.Instance,
tick: TimeSpan.FromMilliseconds(50), maxParallelRuns: 2);
scanner.Start();
await WaitUntil(() => dispatcher.Started.Count == 1);
// While t-slow still runs, new work arrives. Before #682 the loop awaited every
// run it started, so t-new would have waited for t-slow to finish.
await SeedDue("t-new");
await WaitUntil(() => dispatcher.Started.Count == 2);
dispatcher.Started.ShouldBe(["t-slow", "t-new"]);
scanner.OpenCount.ShouldBe(2);
scanner.StopAccepting();
dispatcher.ReleaseAll();
await WaitUntil(() => scanner.OpenCount == 0);
}
// ═══════════════════ order ═══════════════════
[Fact]
public async Task Higher_priority_goes_first()
{
await SeedDue("t-low", priority: 1);
await SeedDue("t-high", priority: 5);
await SeedDue("t-mid", priority: 3);
var dispatcher = new TrackingDispatcher();
await Scanner(dispatcher, maxParallelRuns: 1).ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-high", "t-mid", "t-low"]);
}
[Fact]
public async Task At_equal_priority_the_longest_waiting_goes_first()
{
TaskWhen At(string utc) => new() { Kind = TaskWhenKind.At, Value = utc, TimeZone = "UTC" };
await SeedDue("t-1130", when: At("2026-07-31T11:30:00Z"));
await SeedDue("t-0900", when: At("2026-07-31T09:00:00Z"));
await SeedDue("t-1000", when: At("2026-07-31T10:00:00Z"));
var dispatcher = new TrackingDispatcher();
await Scanner(dispatcher, maxParallelRuns: 1).ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-0900", "t-1000", "t-1130"]);
}
// ═══════════════════ no silent clamp ═══════════════════
[Fact]
public async Task More_than_500_due_tasks_are_all_considered()
{
for (var i = 0; i < 520; i++)
await SeedDue($"t-{i:000}");
var dispatcher = new TrackingDispatcher();
var dispatched = await Scanner(dispatcher, maxParallelRuns: 1000).ScanOnceAsync(default);
dispatched.ShouldBe(520, "before #682 the tick stopped at 500 without a word");
}
// ═══════════════════ test double ═══════════════════
/// <summary>
/// Records starts and the highest number of runs in flight at once. Either finishes
/// immediately, after <c>holdFor</c>, or — <c>gated</c> — only on <see cref="ReleaseAll"/>.
/// </summary>
private sealed class TrackingDispatcher(TimeSpan? holdFor = null, bool gated = false) : ITaskDispatcher
{
private readonly Lock _lock = new();
private readonly List<TaskCompletionSource> _gates = [];
private int _current;
public List<string> Started { get; } = [];
public int MaxConcurrent { get; private set; }
public async Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
{
TaskCompletionSource? gate = null;
lock (_lock)
{
Started.Add(task.Id);
_current++;
MaxConcurrent = Math.Max(MaxConcurrent, _current);
if (gated)
{
gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_gates.Add(gate);
}
}
try
{
if (gate is not null)
await gate.Task;
else if (holdFor is { } delay)
await Task.Delay(delay, ct);
return TaskLauf.Erledigt;
}
finally
{
lock (_lock)
_current--;
}
}
public void ReleaseAll()
{
lock (_lock)
{
foreach (var gate in _gates)
gate.TrySetResult();
_gates.Clear();
}
}
}
}
@@ -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()
{