Author SHA1 Message Date
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
7 changed files with 714 additions and 19 deletions
+251 -4
View File
@@ -1,6 +1,6 @@
# Organisation — 50 bis 100 Agenten geordnet zusammenarbeiten lassen # 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). Konzept, noch nicht gebaut.*
> Herkunft: Richtungsentscheidung des Nutzers am 2026-09-21. Die bisherige Regel im > Herkunft: Richtungsentscheidung des Nutzers am 2026-09-21. Die bisherige Regel im
> [InstanceSetupGuide](InstanceSetupGuide.md) („mehr als 5 Agenten kritisch > [InstanceSetupGuide](InstanceSetupGuide.md) („mehr als 5 Agenten kritisch
@@ -609,6 +609,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 das angenehm; bei fünfzig ist es die einzige Art, den Überblick zu behalten, welches
Modell womit umgehen kann. 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 ## 8 — Kontrolle: eine Leitung delegiert nicht nur
Modelle machen Fehler. Das ist keine Randbedingung, sondern die Arbeitsgrundlage — vier Modelle machen Fehler. Das ist keine Randbedingung, sondern die Arbeitsgrundlage — vier
@@ -889,6 +959,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 ## 10 — Was sich am Menschen ändert
Bei fünf Agenten ist der Mensch der Koordinator. Bei fünfzig kann er es nicht sein, und Bei fünf Agenten ist der Mensch der Koordinator. Bei fünfzig kann er es nicht sein, und
@@ -906,6 +1057,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 ## 11 — Reihenfolge
Nichts davon muss auf einmal entstehen. Die Reihenfolge ist so gewählt, dass jede Stufe Nichts davon muss auf einmal entstehen. Die Reihenfolge ist so gewählt, dass jede Stufe
@@ -914,10 +1150,11 @@ für sich schon nützt und keine die nächste blockiert.
| Stufe | Inhalt | Nutzen für sich | | Stufe | Inhalt | Nutzen für sich |
|---|---|---| |---|---|---|
| **0** | Parallelität deckeln, stille Kappung bei 500 beseitigen (#682) | Fehler, unabhängig von der Größe | | **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 | | **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 | | **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 | | **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 | | **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 | | **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 | | **7** | Entscheidungsinstanz an der Spitze (#690) | Der Betrieb läuft weiter, wenn der Mensch schläft |
@@ -925,11 +1162,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 | | **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 | | **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 | | **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 | | **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 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. 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 **Zum Umzug (Abschnitt 3):** Er gehört zu Stufe 1. Eine Struktur, die sich nach dem
@@ -984,6 +1221,11 @@ Gegenlesen am 2026-09-22 als zu grob aufgefallen:
Maschinerie, solange niemand nachsieht. Vorschlag: Das Eskalationswerkzeug sucht von Maschinerie, solange niemand nachsieht. Vorschlag: Das Eskalationswerkzeug sucht von
sich aus, bevor es einen Vorgang anlegt — genau wie `task_find` vor `task_create` 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). (#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 - ~~**Was passiert, wenn die Eskalationsgrenze erreicht ist?**~~ **Entschieden am
2026-09-22, und schärfer als vorgeschlagen: Eine Eskalation, die nicht gelöst werden 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 kann, darf es nicht geben.** Finden die Agenten binnen einer einstellbaren Frist keine
@@ -1024,3 +1266,8 @@ nur nicht selbst an.
| #654, #196, #657 | Sandbox, Shell, Skriptprüfung — der eigentliche Werkzeugkasten | | #654, #196, #657 | Sandbox, Shell, Skriptprüfung — der eigentliche Werkzeugkasten |
| #687 | Freigabe durch die Leitung innerhalb von Grenzen | | #687 | Freigabe durch die Leitung innerhalb von Grenzen |
| #688 | Organigramm in Oberfläche und WebUI | | #688 | Organigramm in Oberfläche und WebUI |
| #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 |
+2 -1
View File
@@ -725,7 +725,8 @@ public sealed class AppHost : IAsyncDisposable
var dispatcher = new EngineTaskDispatcher( var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory); engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory); var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory,
maxParallelRuns: instance.MaxParallelTaskRuns);
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten. // Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
_ = Task.Run(async () => _ = Task.Run(async () =>
@@ -152,6 +152,23 @@ public sealed class InstanceConfig
[JsonPropertyName("delegationGateTimeoutSeconds")] [JsonPropertyName("delegationGateTimeoutSeconds")]
public int DelegationGateTimeoutSeconds { get; set; } = 120; 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")] [JsonPropertyName("agents")]
public List<AgentConfig> Agents { get; set; } = new(); public List<AgentConfig> Agents { get; set; } = new();
@@ -19,6 +19,16 @@ public interface ITaskRepository
Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct); 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> /// <summary>
/// Beansprucht einen fälligen Termin atomar. Genau ein gleichzeitiger Aufruf gewinnt; /// Beansprucht einen fälligen Termin atomar. Genau ein gleichzeitiger Aufruf gewinnt;
/// jeder weitere erhält <c>false</c>. Setzt den Marker (<paramref name="occurrenceKey"/>) /// jeder weitere erhält <c>false</c>. Setzt den Marker (<paramref name="occurrenceKey"/>)
@@ -208,6 +208,21 @@ public sealed class SqliteTaskRepository : ITaskRepository
return await cmd.ExecuteNonQueryAsync(ct); return await cmd.ExecuteNonQueryAsync(ct);
}, ct); }, ct);
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) public async Task<IReadOnlyList<TaskItem>> ListBlockedByAsync(string blockerId, CancellationToken ct)
{ {
await using var conn = await _storage.OpenConnectionAsync(ct); await using var conn = await _storage.OpenConnectionAsync(ct);
+131 -14
View File
@@ -84,6 +84,20 @@ public sealed class TaskScanner : IAsyncDisposable
/// <summary>Pause, bevor eine nicht gelaufene einmalige Aufgabe erneut versucht wird.</summary> /// <summary>Pause, bevor eine nicht gelaufene einmalige Aufgabe erneut versucht wird.</summary>
public static readonly TimeSpan Wiedervorlage = TimeSpan.FromMinutes(30); 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;
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private Task? _loop; private Task? _loop;
@@ -94,7 +108,8 @@ public sealed class TaskScanner : IAsyncDisposable
IMarketCalendar? market = null, IMarketCalendar? market = null,
TimeProvider? clock = null, TimeProvider? clock = null,
TimeSpan? tick = null, TimeSpan? tick = null,
TimeSpan? lease = null) TimeSpan? lease = null,
int maxParallelRuns = DefaultMaxParallelRuns)
{ {
_repo = repo; _repo = repo;
_dispatcher = dispatcher; _dispatcher = dispatcher;
@@ -103,8 +118,12 @@ public sealed class TaskScanner : IAsyncDisposable
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Scanner"); _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Scanner");
_tick = tick ?? TimeSpan.FromSeconds(60); _tick = tick ?? TimeSpan.FromSeconds(60);
_lease = lease ?? TimeSpan.FromMinutes(15); _lease = lease ?? TimeSpan.FromMinutes(15);
_maxParallelRuns = Math.Max(1, maxParallelRuns);
} }
/// <summary>How many scheduled runs may be in flight at once (#682).</summary>
public int MaxParallelRuns => _maxParallelRuns;
public void Start() public void Start()
{ {
_loop ??= RunLoopAsync(_cts.Token); _loop ??= RunLoopAsync(_cts.Token);
@@ -158,7 +177,10 @@ public sealed class TaskScanner : IAsyncDisposable
try 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) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
@@ -169,20 +191,42 @@ public sealed class TaskScanner : IAsyncDisposable
/// <summary> /// <summary>
/// Ein Durchlauf: fällige Aufgaben beanspruchen und ausführen. Gibt die Zahl der /// 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 /// tatsächlich angestoßenen Läufe zurück und kehrt erst zurück, wenn sie fertig sind.
/// deterministisch auslösen können. /// Öffentlich, damit Tests einen Takt deterministisch auslösen können — die
/// Taktschleife selbst wartet nicht auf die Läufe (#682).
/// </summary> /// </summary>
public async Task<int> ScanOnceAsync(CancellationToken ct) 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) if (!IsAcceptingWork)
return 0; return [];
var now = _clock.GetUtcNow().UtcDateTime; var now = _clock.GetUtcNow().UtcDateTime;
var leaseCutoff = now - _lease; 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)>(); var due = new List<(TaskItem Task, string Occurrence)>();
foreach (var task in candidates) foreach (var task in candidates)
{ {
if (task.Status != TaskItemStatus.Todo) if (task.Status != TaskItemStatus.Todo)
@@ -232,16 +276,81 @@ public sealed class TaskScanner : IAsyncDisposable
if (task.OnlyWhenMarketOpen && !_market.IsOpen(now)) if (task.OnlyWhenMarketOpen && !_market.IsOpen(now))
continue; continue;
var token = Guid.NewGuid().ToString("N"); due.Add((task, occ));
if (await _repo.TryClaimAsync(task.Id, occ, token, now, leaseCutoff, ct))
claimed.Add((task, token));
} }
// Gleichzeitig anstoßen — Läufe desselben Agenten serialisiert ohnehin das // Occurrence keys are ISO-8601 UTC ("O"), so ordinal order is time order.
// Agent-Gate der Engine; verschiedene Agenten laufen echt parallel. due.Sort((a, b) =>
await Task.WhenAll(claimed.Select(c => ProcessAsync(c.Task, c.Token, ct))); {
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;
foreach (var (task, occ) 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;
}
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;
}
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> /// <summary>
@@ -378,6 +487,14 @@ public sealed class TaskScanner : IAsyncDisposable
try { await _loop; } try { await _loop; }
catch (OperationCanceledException) { } 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(); _cts.Dispose();
} }
} }
@@ -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();
}
}
}
}