Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df5d004e4 | ||
|
|
3ba9ebc565 | ||
|
|
ac63e58141 | ||
|
|
f4189e0e57 |
@@ -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). Konzept, noch nicht gebaut.*
|
||||
|
||||
> Herkunft: Richtungsentscheidung des Nutzers am 2026-09-21. Die bisherige Regel im
|
||||
> [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
|
||||
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
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| **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 +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 |
|
||||
| **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
|
||||
@@ -984,6 +1221,11 @@ Gegenlesen am 2026-09-22 als zu grob aufgefallen:
|
||||
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 +1266,8 @@ 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 |
|
||||
| #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 |
|
||||
|
||||
@@ -725,7 +725,8 @@ 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 scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory,
|
||||
maxParallelRuns: instance.MaxParallelTaskRuns);
|
||||
|
||||
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
|
||||
_ = Task.Run(async () =>
|
||||
|
||||
@@ -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,6 +19,16 @@ 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>
|
||||
/// Beansprucht einen fälligen Termin atomar. Genau ein gleichzeitiger Aufruf gewinnt;
|
||||
/// 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);
|
||||
}, 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)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
|
||||
@@ -84,6 +84,20 @@ 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;
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
@@ -94,7 +108,8 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
IMarketCalendar? market = null,
|
||||
TimeProvider? clock = null,
|
||||
TimeSpan? tick = null,
|
||||
TimeSpan? lease = null)
|
||||
TimeSpan? lease = null,
|
||||
int maxParallelRuns = DefaultMaxParallelRuns)
|
||||
{
|
||||
_repo = repo;
|
||||
_dispatcher = dispatcher;
|
||||
@@ -103,8 +118,12 @@ 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);
|
||||
}
|
||||
|
||||
/// <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 +177,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 +191,42 @@ 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)>();
|
||||
var due = new List<(TaskItem Task, string Occurrence)>();
|
||||
foreach (var task in candidates)
|
||||
{
|
||||
if (task.Status != TaskItemStatus.Todo)
|
||||
@@ -232,16 +276,81 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
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));
|
||||
due.Add((task, occ));
|
||||
}
|
||||
|
||||
// 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)));
|
||||
// Occurrence keys are ISO-8601 UTC ("O"), so ordinal order is time order.
|
||||
due.Sort((a, b) =>
|
||||
{
|
||||
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>
|
||||
@@ -378,6 +487,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user