19 KiB
Taskboard — Aufgaben statt Delay-Schleifen
Setzt A1 aus der Roadmap um. Das Taskboard ist das Fundament, auf dem
Audit (A3), Staging (A2), Marktkalender (C1) und das Ergebnisregister (C7/C8)
aufsetzen. Es löst zugleich sechs Altpunkte auf einmal (F-A5 Queue, F-A4 Run-Historie,
B8 Rekursion, B6 Task.Delay-Überlauf, B7 Cron in Lokalzeit, T7 RunAsync vs.
ChatAsync).
Aufbau analog zum Memory-Konzept: erst warum die vorhandenen Mechanismen nicht reichen, dann Dateiformat, Wahrheitsaufteilung, Scanner-Verhalten, Invarianten, Migration.
Das Problem
Heute gibt es zwei getrennte, je für sich unzureichende Wege, einen Agenten Arbeit tun zu lassen:
- Der Cron-Scheduler (
AgentScheduler,ToolJobScheduler) hängt starr am Agenten: eine Cron-Zeile je Agent, ausgeführt über einTask.Delaybis zum nächsten Termin. Ein jährlicher Termin bedeutet einTask.Delayüber Monate (B6). Cron läuft in Lokalzeit ohne explizite Zone (B7). Ob mit oder ohne Kontext gelaufen wird, entscheidet ein implizites Flag (UseChatContext, T7). - Die
coordination/*.md-Dateien im SharedWorkspace sind die improvisierte Antwort der Agenten darauf, dass es kein Aufgabenmodell gibt:task_*-,status_*- undbroadcast-Dateien, per Konvention beschrieben, ohne Schema, ohne Claiming, ohne Zustandsübergänge. Zwei Agenten, die dieselbe Datei „übernehmen", tun das ohne jede Absicherung.
Delegation läuft heute über rekursives send_message/spawn — ein Agent ruft
synchron einen anderen, der wieder einen dritten (B8: Zyklengefahr, deshalb ein
LoopGuard als Notbremse). Es gibt keine Run-Historie am Auftrag und keine Queue.
Warum ein neues Subsystem, nicht der vorhandene State-Store
Dieselbe Überlegung wie beim Gedächtnis: IStateStore ist eine Schlüssel-Wert-Tabelle
für kleine Marker. Ein Aufgabenmodell mit Status, Zuweisung, Abhängigkeiten und
Terminen darin abzulegen hieße, JSON in eine Value-Spalte zu schreiben — nicht
filterbar, nicht atomar claimbar, nicht auswertbar.
Der Kern ist eine atomare Anspruchsnahme (Claim). Genau das kann ein Dateisystem
nicht verlässlich und der Schlüssel-Wert-Store nicht ausdrücken, eine SQL-Zeile mit
einem bedingten UPDATE aber sehr wohl. Deshalb eine eigene Tabelle auf dem
vorhandenen SqliteStorage (WAL,
busy_timeout, prozessweite Schreib-Warteschlange) — dieselbe Grundlage, die schon
Gedächtnis, Zustand und Verbrauch teilen.
Wahrheitsaufteilung — Datei ist Definition, DB ist Koordination
Die eine Entscheidung, an der alles hängt:
| Ebene | Wahrheit über | Wer schreibt |
|---|---|---|
| Markdown-Datei (Frontmatter + Rumpf) | die Definition der Aufgabe: Titel, Priorität, Assignee, Termin, Abnahme, Abhängigkeiten. Menschen- und agentenlesbar. | Mensch (Editor), Agent (task_*-Tool) |
SQLite-Tabelle Tasks |
den Ausführungszustand: Status, Claim, Lease, Last-Fired-Marker je Termin, Blocker-Auflösung. | ausschließlich das Taskboard selbst (Importer, Scanner, Tool) |
Regel: Für die Definition ist die Datei die Wahrheit. Für jede Ausführungsentscheidung ist die DB die Wahrheit. Weichen beide ab (Absturz zwischen DB-Claim und Datei-Schreiben), gewinnt die DB, und die Datei wird bei der Reconciliation nachgezogen.
Warum nicht alles nur in die DB und die Datei als reine Projektion? Weil die Datei der Bedienpunkt ist: Ein Mensch soll eine Aufgabe im Editor anlegen und ändern können, ein Agent über sein Tool, und beides soll im SharedWorkspace sichtbar und versionierbar bleiben. Warum nicht alles nur in Dateien? Weil das Claiming dort nicht atomar geht — siehe oben. Die Aufteilung nimmt von beidem das Belastbare.
Der Importer ist die Brücke: Er liest die Frontmatter-Definition und spiegelt sie
idempotent in die DB-Zeile (UPSERT auf task_id). Er läuft beim Start (alle Dateien),
nach jeder task_*-Änderung (die betroffene Datei) und optional per
FileSystemWatcher mit Debounce (~500 ms, wie bei A4), damit von Hand editierte
Dateien zeitnah einfließen.
Dateiformat
Aufgaben liegen als eine Datei je Aufgabe unter SharedWorkspace/tasks/. Der
Dateiname ist beschreibend (recherche-nvda-earnings.md); die stabile Identität ist
die id im Frontmatter, nicht der Name — so überlebt eine Aufgabe das Umbenennen.
---
id: t-8f3a2c # stabil, beim Anlegen vergeben; Wahrheit der Identität
title: NVDA Earnings recherchieren
status: todo # backlog | todo | in_progress | in_review | done | canceled | blocked
type: work # work | approval | human_input
priority: 3 # 1 (niedrig) .. 5 (hoch)
assignee: "@crawler" # @new | @<agentId> | @human
when: # optional; fehlt = einmalige Aufgabe, sofort fällig
kind: cron # at | every | cron
value: "0 7 * * 1-5"
tz: Europe/Berlin # PFLICHT, wenn when gesetzt ist — kein Termin ohne Zone
require_approval: false # true = gilt erst nach Review (in_review) als done
acceptance: | # Abnahmekriterien, gegen die das Ergebnis geprüft wird
Aktuelle Zahlen mit Datum und Quelle, in SharedWorkspace/data/nvda.json abgelegt.
blocked_by: [t-4b1e] # diese Aufgabe startet erst, wenn alle Blocker done sind
onlyWhenMarketOpen: false # C1: Termin nur auslösen, wenn der Markt offen ist
---
Freitext-Rumpf: Auftragsbeschreibung, Kontext, Verweise. Geht als Aufgabenstellung
an den Agenten. Kommentare (Ergebnisse, Kritik, Reopen) werden unten angehängt.
Feldregeln:
when.kind:at(einmaliger Zeitpunkt, ISO 8601),every(Intervall, z. B.30m),cron(5-Felder-Ausdruck wie bisher).tzist bei allen dreien Pflicht — das ist die Antwort auf B7. Die vorhandeneCronExpressionrechnet heute zonen-blind inDateTime.Now; sie wird in einen zonen-bewussten Aufruf gekapselt (nächsten Termin intzbestimmen, dann in UTC vergleichen). Das verzahnt sich mit R2 (TimeProvider) aus der Teststrategie.assigneeersetzt dasUseChatContext-Flag durch eine explizite Angabe (T7):@newbzw.@new:<agentId>→ frischer Lauf ohne Historie (AgentEngine.RunAsync). Bloßes@newist nur eindeutig, wenn die Instanz genau einen Agenten hat; sonst benennt@new:<agentId>den Ziel-Agenten. Ist er nicht auflösbar, scheitert der Dispatch mit klarer Meldung, statt einen falschen Agenten zu raten.@<agentId>→ bestehender Agent mit seinem Kontext (AgentEngine.ChatAsync)@human→ wartet auf einen Menschen; kein Modell-Lauf
type:work(Standard),approvalundhuman_input. Bei den letzten beiden ist ein Mensch der Assignee; seine Antwort ist das Ergebnis und Input für Folgeaufgaben. Kein Sonderpfad — nur ein Assignee, der kein Modell ist.
Scanner-Verhalten
Ein einziger Takt (~60 s) statt vieler langer Task.Delay. Damit kennt das System
keine Monats-Delays mehr (B6), und ein verpasster Takt ist ein verpasster Termin, kein
Zeitbombe.
Je Takt:
-
Fällige Termine bestimmen. Aus jeder aktiven Task-Zeile den nächsten Termin in ihrer
tzberechnen und gegen „jetzt" prüfen. Ein Termin ist durch einen Occurrence-Key eindeutig:task_id+ geplante Feuerzeit (beiat/cron/every). -
Claim vor Lauf (at-most-once). Bevor gelaufen wird, wird der Occurrence-Key atomar beansprucht:
UPDATE Tasks SET claim_token = @token, claimed_at = @now, last_occurrence = @occ, status = 'in_progress' WHERE id = @id AND status IN ('todo','backlog') AND (last_occurrence IS NULL OR last_occurrence < @occ) AND (claim_token IS NULL OR claimed_at < @leaseCutoff);Genau eine Zeile betroffen = Anspruch gewonnen. Ein zweiter, gleichzeitiger Takt findet die Bedingung nicht mehr erfüllt (Rowcount 0) und läuft nicht — so wird ein doppelter Tick zu einem Lauf.
-
Dispatch nach Assignee.
@new→RunAsync;@<agent>→ChatAsync;@human→ kein Lauf, Status bleibt/wirdin_reviewbzw.todo, die UI zeigt die Aufgabe als wartend. Der Rumpf (+ Abnahmekriterien) ist die Nachricht an den Agenten. -
Abschluss verbuchen. Last-Fired-Marker setzen (
last_occurrence = @occ), Status fortschreiben, Claim lösen. Beirequire_approval→in_reviewstattdone. Der Lauf wird am Task verknüpft (Grundlage für A3-Receipts und die Run-Historie F-A4).
Kein Retry-Sturm. Der Marker wird auch bei einem fehlgeschlagenen Lauf gesetzt: ein fehlgeschlagener Lauf bleibt der einzige Versuch für diesen Termin. Wiederholung ist eine bewusste Entscheidung (neuer Termin oder Reopen), kein Automatismus. Das schützt vor einem Agenten, der bei jedem Takt erneut in denselben Fehler läuft und Budget verbrennt.
Serialisierung mit den Chat-Läufen (B2). Der Scanner ruft die Engine über
dieselben Einstiegspunkte wie WebView, ToolJob und AgentComm. ChatAsync ist bereits
je Agent über ein SemaphoreSlim-Gate serialisiert
(AgentEngine) — der Scanner fügt
sich dort ein, statt einen zweiten, konkurrierenden Pfad in den geteilten
Konversationskontext aufzumachen. Der Claim ist eine DB-Grenze (welcher Termin wird
behandelt), das Agent-Gate eine Kontext-Grenze (kein verschränkter Nachrichtenstrom).
Beide werden gebraucht; keine ersetzt die andere.
Startup-Reconciliation
Beim Start wird Soll (Frontmatter) gegen Ist (DB-Marker/Claims) abgeglichen, statt verpasste Läufe still zu überspringen:
- Alle Task-Dateien importieren (UPSERT), gelöschte Dateien in der DB als
archivedmarkieren. - Stale Claims freigeben: Ein Claim, dessen
claimed_atälter ist als die Lease-Dauer (ein während des Laufs abgestürzter Prozess), wird verworfen — die Bedingungclaimed_at < @leaseCutoffim Claim-UPDATEerledigt das ohnehin, die Reconciliation stellt den Status zusätzlich vonin_progressauftodozurück. - Verpasste Termine erkennen: Liegt der letzte planmäßige Termin nach dem Last-Fired-Marker, ist ein Lauf ausgefallen. Er wird als solcher gemeldet (Log, später A5), nicht heimlich verschluckt. Ob nachgeholt wird, ist Politik — Standard: einmal nachholen, sonst würde ein über Nacht ausgeschalteter Rechner beim Start eine Welle auslösen.
Die drei Invarianten
Der Scanner-Kern ist in der Roadmap als der heikle Teil markiert (die Fehlerklasse, die hier schon einmal schiefging). Er wird strikt gegen diese Invarianten gebaut und mit Property-Tests (FsCheck, siehe Teststrategie) abgesichert:
- Nie zwei Claims auf einen Task-Termin. Garantiert durch das bedingte
UPDATE: Rowcount ≤ 1 pro Occurrence-Key. Test: N parallele Claims auf denselben Key → genau einer gewinnt. - Kein Dispatch bei offenem Blocker. Eine Aufgabe mit unerfüllten
blocked_byist nichttodo, sondernblocked, und die Claim-Bedingung (status IN ('todo','backlog')) greift nicht. Test: Blocker offen → kein Lauf; letzter Blockerdone→ genau ein Auto-Dispatch. - Doppelter Tick = ein Lauf. Zwei Takte im selben Fenster konkurrieren um denselben
Occurrence-Key; der Claim lässt nur einen durch. Test: zwei gleichzeitige
ScanOnce→ ein Lauf, ein Marker.
Diese drei sind keine Kür, sondern die Bedingung dafür, dass der Kern mit einem schwächeren Modell umgesetzt werden darf.
Abhängigkeiten und Eskalation
blocked_bymit Auto-Dispatch: Wird eine Aufgabedone, sucht das Board alle Aufgaben, derenblocked_bysie enthält. Sind für eine davon alle Blockerdone, wechselt sieblocked → todound der Scanner nimmt sie beim nächsten Takt auf.- Blocker-Eskalation: Meldet ein Agent einen Blocker (
task_update status=blockedmit Begründung), fällt die Aufgabe und der Zuständige (Lead/Benutzer) wird benachrichtigt. Bis A5 (Matrix) steht, geht das über den vorhandenen Log-/UI-Weg.
Zusammenspiel mit Staging (A2) und Reopen
require_approval/in_review: Eine Aufgabe mitrequire_approval: truegilt nach dem Lauf nicht alsdone, sondern alsin_review. Das ist der natürliche Andockpunkt für A2: Die Freigabe erzeugt einen Folge-Task, der den Agenten mit dem eingefrorenen Aufruf weckt (so bleibt der Lauf regulär beendet, kein pausierter In-Memory-Zustand — genau die Architektur-Vorgabe aus der Roadmap-Einstufung für A2).- Reopen/Feedback: Ergebnis + Kritik gehen per
task_commentan denselben Agenten zurück (ChatAsyncin dessen Kontext), statt eine neue Aufgabe von vorn zu beginnen. Die Aufgabe kehrt nachtodo/in_progresszurück, der Verlauf am Task bleibt erhalten.
Agenten-Tool
Ein Tool Taskboard im Muster von MemoryTool (eine Aktion je Aufruf), das über einen
neuen ITaskRepository auf dem AgentToolContext arbeitet (analog IMemoryRepository? Memory):
| Aktion | Zweck |
|---|---|
task_create |
Aufgabe anlegen — schreibt Datei und DB-Zeile (über den Importer). Vergibt die id. |
task_list |
Aufgaben filtern (Status, Assignee, Betreff). Gekappte, kontextschonende Ausgabe wie bei Memory. |
task_update |
Status/Felder ändern; blocked melden; Ergebnis eintragen. |
task_comment |
Kommentar/Kritik anhängen; mit Reopen den Zuständigen erneut wecken. |
Agent-zu-Agent-Delegation läuft künftig hierüber: Statt rekursivem send_message legt
ein Agent eine Aufgabe mit assignee: @<other> an. Das ist strukturell zyklenfrei (B8) —
eine Aufgabe ist ein Datensatz, kein synchroner Aufruf-Stack.
Sicherheit: Ein Task-Rumpf ist Prompt-Input für den Assignee. Fremdbestimmte Inhalte (Ergebnisse anderer Tools, die in einen Task fließen) werden als Daten gerahmt, nicht als Anweisung — dieselbe Linie wie K2. Irreversibles, das ein Task auslöst, läuft über A2.
Migration
Die coordination/*.md-Dateien gehen im Taskboard auf. Die Altdateien haben kein Schema
(freies Markdown wie # Task: …, ## Status: ASSIGNED), deshalb bewusst konservativ
(CoordinationMigration, beim Start ausgeführt):
task_*-Dateien → einmalig alsbacklog-Aufgaben übernommen: Titel aus der# Task:-Überschrift (sonst erste Überschrift, sonst Dateiname), das ganze Markdown als Rumpf,assignee: @humanals sicherer Default, bis ein Mensch sie zuordnet.backlog(nichttodo), damit der Scanner nichts unbesehen ausführt.status_*,broadcast, Incident-Berichte,*.json-Artefakte → keine Aufgaben; bleiben unangetastet (später nach A5/Matrix bzw. verfallen als Altbestand).
Idempotent durch Verschieben statt Löschen: eine übernommene task_*-Datei wandert
nach coordination/migrated/ — die Historie bleibt, ein zweiter Start findet sie nicht
mehr. Vor der Migration greift die übliche Regel: frisches Backup.
Ein Takt für alles — Ablösung der Alt-Scheduler
Der Scanner ist der einzige periodische Treiber. Die früheren AgentScheduler und
ToolJobScheduler (zwei Task.Delay-Schleifen mit B6/B7, ungetestet) sind gelöscht —
alles Periodische ist jetzt ein Task:
- Geplanter Agent-Lauf (früher
scheduler-Config) → ein normaler Task mitwhen: cronund Assignee@new:<agent>. - Tool-Job-Poll (früher
toolJobs-Config, z. B.telegram_poll) → ein Task vom Typtool_jobmittool_name/job_type. Beim fälligen Termin tickt der Dispatcher denIToolJobProviderund weckt den Zielagenten (Assignee) nur, wenn der Tick etwas meldet — mit oder ohne Kontext, je nachToolJobResult.
Zwei Feinheiten, die dabei geradegezogen wurden:
- Wiederkehrende Tasks (
cron/every/tool_job) kehren nach dem Feuern auftodozurück statt aufdone— sonst liefe ein Cron-Task nur ein einziges Mal. Der Last-Fired-Marker verhindert weiterhin, dass derselbe Termin doppelt feuert. backlogist ein Halte-Status: Der Scanner claimt nurtodo. Eine Aufgabe inbacklog(frisch importiert, migriert, oder ein deaktivierter Poll) ruht, bis ein Mensch sie auftodosetzt.
Die Alt-Konfiguration (scheduler, toolJobs) wird beim Start einmalig und
nicht-destruktiv in Tasks migriert (SchedulerTaskMigration, stabile Ids
sched-<agent> / tj-<agent>-<job>). Ein manuelles „Jetzt ausführen" im Host läuft über
TaskScanner.RunTaskNowAsync.
Verzahnung
- C1 Marktkalender:
onlyWhenMarketOpengehört ins Frontmatter, nicht in einen eigenen Mechanismus — der Scanner überspringt einen Termin, wenn der Markt zu ist. - A3 Audit/Receipts: Jeder Lauf wird am Task verknüpft; der Abschluss-Beleg (Schritte, Tokens, Kosten) fällt daraus ab und macht C7 weitgehend zum Abfallprodukt.
- F-A5/F-A4: Das Board ist die Queue; die verknüpften Läufe sind die Historie.
Umsetzungsreihenfolge
Bewusst so geschnitten, dass der heikle Kern zuletzt und gegen grüne Invarianten kommt:
- Modelle +
ITaskRepository+ Schema + Frontmatter-Parser. Reine, testbare Bausteine. Der Parser wird eng gebaut (die Frontmatter ist ein kleiner, flacher Satz aus Skalaren und kurzen Listen) — keine YAML-Bibliothek, passend zum dependency-armen Stil des Projekts. 4.6-tauglich. Taskboard-Tool + Importer. Datei ↔ DB,task_*-Aktionen. 4.6-tauglich.- Scanner-Kern (Claim, Dispatch, Reconciliation, Auto-Dispatch) — strikt gegen die drei Invarianten, mit Property-Tests. Der in der Roadmap für Opus 5/Fable markierte Teil; mit Opus 4.8 nur streng nach diesem Dokument und mit den Invarianten-Tests als Netz.
- Migration der
coordination/*.md.
Neue Subsysteme kommen mit Tests nach der Teststrategie; die Invarianten-Tests sind bei Punkt 3 die Absicherung, kein Nice-to-have.
Offen
- Nachhol-Politik verpasster Termine — umgesetzt als „höchstens einmal nachholen":
der Scanner nimmt den jüngsten verpassten Termin, nicht jeden einzelnen. Eine frische
Aufgabe holt zudem keinen Termin von vor ihrer Anlage nach. Ob das je Task
abschaltbar sein soll (
catchUp: true|false), ist offen. - Marktkalender (C1) — der Scanner fragt eine
IMarketCalendar(derzeit Platzhalter „immer offen"). C1 liefert später den echten Kalender; die Verzahnung steht. - DST-Randfall — eine bei der Zeitumstellung nicht existierende Ortszeit (Frühjahr, „02:30") wird derzeit übersprungen statt verschoben. Für die geplanten Termine unkritisch; die Härtung gehört zur Scheduler-Nacharbeit (Teststrategie R4/R5).
- Priorität als Reihenfolge — bei mehreren fälligen Aufgaben desselben Agenten
bestimmt
prioritydie Reihenfolge; ob strikt oder gewichtet, ist noch offen. - Aufräumen —
done/canceled-Aufgaben nach einer Frist archivieren, damittasks/nicht zuwächst (dieselbe Überlegung wie „Verfall" beim Gedächtnis).