# Task 4 Report: talkd-Daemon + Queue + Mute **Status:** DONE **Branch:** feat/v1 **Commits:** 7 (716f6b2 → dc94f45, auf 3b44605) **Verifikation:** `go vet ./...` ✓ · `gofmt -l .` leer ✓ · `go test ./... -count=1` ✓ (inkl. `-race` für cmd/internal, `count=5` für die Prozessgrenzen-Tests gegen Flakiness) · `go build ./...` ✓ · Cross-Compile-Smoke `CGO_ENABLED=0` für darwin/windows/amd64+arm64 und linux/arm64 ✓ ## Geliefert ### internal/queue (queue.go, mute.go, rate.go) - **Queue:** Worker-Loop mit serialisierter Abarbeitung (genau eine Ansage gleichzeitig). Reihenfolge: `now` > `high` > `normal` > `low`, FIFO je Level (Enqueue-Seq als Tiebrecher). IDs: 6 Hex-Zeilen (crypto/rand, Fallback praktisch unerreichbar). - **MuteStore:** persistenter Mute-State als Datei `mute` im Config-Dir, Inhalt `true`/`false`, atomar geschrieben (Temp-Datei + Rename, MkdirAll bei Bedarf). Fehlende/unlesbare Datei = nicht stumm (nie „stumm aus Versehen“). Da der Daemon die Datei bei jeder /speak-Anfrage neu liest, wirkt auch eine offline (CLI, Degraded Mode) geschriebene Datei sofort. - **RateLimiter (Seam für Task 05):** Struktur mit `Allow`/`Reserve`/`Next` steht, ist getestet (internal/queue/queue_test.go) und bewusst NOCH NICHT in den Worker-Loop verdrahtet — Task-04-Default unbegrenzt, wie der Brief verlangt. Die Seam ist in rate.go wörtlich dokumentiert (Task 05: Parking/Discard/burst-Entscheidung + config `queue.rate_limit`). ### internal/api (types.go, server.go, client.go) - Endpunkte exakt nach Brief, methodengebunden (ServeMux-Muster → 405/404 von selbst): - `POST /speak {text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}`; bei Mute `202 {"id":"","muted":true}` (verworfen, dokumentiert). Validierung → 400 mit `{"error":…}`: kaputtes JSON, leerer Text, Müll-Priority, unbekannter Provider, Müll-Lang; Body-Limit 64 KiB. - `GET /status` → `{version, queue, queue_ids, current (null im Leerlauf), muted, provider}`. `queue_ids` ist die dokumentierte Ergänzung (wartende IDs in Abarbeitungs-Reihenfolge) — die CLI braucht sie für `--wait`. - `POST /cancel` → `200 {"cancelled":n}` (n = laufende + wartende). - `POST /mute` / `POST /unmute` → `200 {"muted":bool}`. - **Client** (CLI-Seite): `Status/Speak/Cancel/SetMute`. `api.HTTPError` (unerwarteter Status) ist von Netz-Fehlern unterscheidbar — die CLI degeneriert nur bei Netz-Fehlern und zeigt HTTP-Fehler ehrlich an. ### cmd/talkd (main.go ersetzt den Platzhalter) - HTTP nur auf `127.0.0.1:`, Port aus config `[server] port` / Env `TALK_PORT` / Default 7800. Port belegt → klarer Fehler mit Hinweis. - Ansagen laufen über die **unveränderte Provider-Kette** (internal/provider wurde nicht angefasst): pro Item wird `provider.NewChain` gebaut (Pin/Voice sind Anfrage-Properties), `chain.Speak(ctx, …)` spielt mit Fallback + Sink. Sprache: Item-Pin oder langdetect im Daemon. - `/status` „aktiver Provider“: Pin des laufenden Items, sonst erste verfügbare Stufe einer Basiskette (Available ist billig, kein Netz-Call); im Leerlauf der zuletzt aktive. Die Kette meldet die erfolgreiche Stufe nicht selbst — deshalb Auflösung über Available (dokumentierte Entscheidung, s. „Offene Punkte“). - Signale (SIGINT/SIGTERM): erst `srv.Shutdown` (keine neuen Anfragen), dann `q.Close()` (laufende Ansage abbrechen, Worker-Exit abwarten). - Mute ohne Config-Dir: Start-Warnung, Daemon läuft weiter (Mute dann deaktiviert, `/mute` → 500). ### cmd/talk (daemon.go, degraded.go, control.go, say.go refaktoriert) - **say/Default:** Daemon-Erkennung via `GET /status` (1 s Budget). Läuft er: enqueue + sofort zurück (non-blocking Default; stdout = Item-ID, eine Zeile); `--wait` pollt /status (100 ms), bis die Ansage weder läuft noch wartet; `--now` → `now:true`; bei Mute → stdout `muted`, Exit 0. - **Degraded Mode:** Daemon nicht erreichbar → stderr-Hinweis, dann lokal: Mute-Datei respektieren, Lock-Datei `talk.lock` im Config-Dir (O_CREATE|O_EXCL, reine stdlib; Kollision = warten, max 10 s, danach ungeschützt mit Warnung; verwaistes Lock nach 60 s entfernen), dann dieselbe Kette blockierend im CLI-Prozess. Ohne Config-Dir: ohne Lock/Mute weiterlaufen (Best-effort, konsequent zu config.Load). - **mute/unmute:** Daemon-Aufruf (`POST /mute`/`/unmute`), stdout `muted`/`unmuted`. Daemon tot: Mute-Datei direkt schreiben (dieselbe Quelle, die der Daemon liest — greift sofort und überlebt Restarts). - **status:** plain Block (`daemon: … (version …)`, `queue: N`, `current: …`, `mute: an|aus`, `provider: …`). Daemon tot → Exit 1 mit Hinweis. - **cancel:** `POST /cancel`, stdout `cancelled: N`. Daemon tot → Exit 1. - Handler-Signatur der Befehlstabelle um `stdout` erweitert (say/Steuer-Befehle geben Ergebnisse aus); `--priority` wird früh validiert; `--lang`-Validierung unverändert früh. ### config - `Server.Port` (`[server] port`), Env `TALK_PORT` (validiert 1–65535, klarer Fehler bei Müll), Default 7800. Tischgesteuerte Tests ergänzt. ### Tests **Prozessgrenze (primäre Seam), `test/`-Paket:** talk + talkd werden einmal pro Lauf gebaut und als echte Prozesse gefahren; ElevenLabs-Fake per httptest (protokolliert Ankünfte separat und Antworten mit Zeiten + Completed-Flag für Abbrüche), `TALK_AUDIO_SINK=null`, `TALK_EDGE_ENDPOINT` auf toten Loopback (Kette fällt nie auf echte Fremd-Endpunkte), Config-Dir isoliert (HOME/XDG auf Temp), **Ports: immer freie ephemere Ports per TALK_PORT — nie 7800, nie ein echter Daemon**. Getestet: - Serialisierung (2+1 schnelle Enqueues → nacheinander, nicht überlappend, beobachtbar an Fake-Ankunfts-/Antwort-Zeiten + null-Sink-Verwurf-Zeilen im Daemon-Log) - Priorität (queue_ids in Abarbeitungs-Reihenfolge + echte Abspiel-Reihenfolge high→normal→low, FIFO je Level) - `--now` über die CLI springt vor wartende normal-Items (unterbricht nicht den Laufenden) - cancel: bricht die Synthese ab (Fake sieht den Abbruch), leert die Queue, `cancelled:2`, Daemon danach gesund - non-blocking (say kehrt in <500 ms zurück, Ansage läuft weiter) + `--wait` (kehrt erst nach Ende zurück) - Mute-Persistenz über Daemon-Restart (Abnahmekriterium) + offline geschriebene Mute-Datei wirkt im laufenden Daemon - Degraded Mode: Daemon tot → Lock-Pfad (Lock danach freigegeben), Lock-Serialisierung zweier paralleler CLIs, mute respektiert - Steuer-Befehle über CLI mit/ohne Daemon (plain Output, Exit-Codes) - 3 parallele talk-Aufrufe → 3 Ansagen nacheinander (Abnahmekriterium, wörtlich) - HTTP-Vertrag: 400-Validierung, 405, Status-Felder im Leerlauf (version dev, provider elevenlabs) **Unit (Neben-Seams):** Queue-Abarbeitung/Current/Pending/Cancel/Close/IDs über steuerbaren Speaker (deterministisch, kein Timing), MuteStore-Roundtrip, RateLimiter-Semantik, config-Port-Auflösung, CLI-Steuerbefehle ohne Daemon (in-process über die run-Seam). **Test-Isolation der Bestands-Tests:** `setGetenv`-Grundausstattung hat jetzt `TALK_PORT=1` (analog zum toten TALK_EDGE_ENDPOINT) — die vorhandenen say-Tests laufen deterministisch durch den Degraded Mode und können nie einen zufällig laufenden echten talkd treffen. ## Concurrency-Design (geforderte Notizen) **Serialisierung.** Ein einziger Worker-Goroutine (`loop`) entnimmt Items; nur er ruft den Speaker. Enqueue (HTTP-Handler, beliebig parallel) hängt unter `mu` an die Liste und signalisiert `notEmpty`. Wait/Signal folgt dem klassischen Muster: Der Worker prüft die Predicate (`len(items)==0 && !closed`) unter demselben Mutex, unter dem Enqueue anhängt — verlorene Wakeups sind strukturell ausgeschlossen; das Signal ist bewusst ein `sync.Cond` (kein Kanal-Select nötig, kein Missed-Wake-Reasoning). **Cancel.** `Cancel()` nimmt `mu`, verwirft die Warteschlange und ruft bei laufender Ansage `cancelCur()`. Pop und das Setzen von `current`/`cancelCur` geschehen im Worker unter EINEM Lock-Halten — es gibt kein Fenster, in dem ein Item weder „wartend“ noch „laufend“ ist und der Cancelisierung entwischen könnte. Der Abbruch wirkt kooperativ: Der Speaker (Provider-Kette) erhält den ctx in alle Synthese-Calls (HTTP via NewRequestWithContext, Prozesse via CommandContext) — die Kette bricht ab, ohne dass der Worker etwas tun müsste. Nach Rückkehr des Speakers räumt der Worker `current` unter `mu` ab. Zählt ein Item als abgebrochen, das in dem Moment just fertig wurde, ist das Punkt-in-Zeit-Semantik (kosmetisch, dokumentiert). **Shutdown.** `Close()` setzt `closed`, verwirft wartende Items, bricht die laufende Ansage ab (_cancelCur_) und broadcastet; danach wartet Close auf `done` (geschlossen vom Worker-Loop nach dessen letztem Durchlauf). Enqueue nach Close → `ErrClosed` → API 503. Die Daemon-Shutdown-Reihenfolge ist: HTTP-Listener dicht (`srv.Shutdown`, keine neuen Anfragen) → `q.Close()` (Abbruch + Worker-Exit abwarten) → Prozessende. So kann nie eine Ansage den Daemon überleben, und ein Handler kann nie in eine geschlossene Queue enqueuen, ohne die 503 zu sehen. `Close` ist idempotent (idempotenter Zweitaufruf wartet nur noch auf `done`). **Bewusste Einfachheit:** EIN Mutex für alles Queue-interne (Items, current, cancelCur, closed) statt feiner Locks; lineare Pop-Suche (n ist klein); `logf` nie unter dem Mutex (Aufrufer achten darauf). `-race` über cmd/internal grün, Queue-Tests zusätzlich 3× wiederholt. ## Entscheidungen & Dokumentationspflichten - **Mute-Discard-Punkt:** Mute wird bei `/speak` geprüft (202 `{muted:true}`, Item verworfen) — wie im Brief. Bereits laufende Ansagen werden durch `talk mute` NICHT abgebrochen (cancel ist der explizite Befehl dafür); das ist die schlichte, dokumentierte Lesart des Briefs. - **„Aktiver Provider“ in /status:** Die Kette (unveränderlich, Task-03-Bestand) meldet die erfolgreiche Stufe nicht zurück. Auflösung daher über Pin des Items bzw. erste `Available()`-Stufe der Basiskette (billig, ohne Netz-Call); im Leerlauf der zuletzt aktive. Für doctor/status ausreichend; Task 09 (Status-Dashboard) kann das ggf. verfeinern. - **CLI-stdout-Formate (plain, skriptbar):** say → Item-ID (eine Zeile) bzw. `muted`; mute/unmute → `muted`/`unmuted`; cancel → `cancelled: N`; status → Block mit `daemon:/queue:/current:/mute:/provider:`. In README dokumentiert. - **Degraded-Lock:** Warten max 10 s, danach ungeschützt mit Warnung („best-effort“); verwaiste Locks (Crash) gelten nach 60 s als verfallen und werden entfernt. Bewusst stdlib-only (O_EXCL statt flock — Letzteres ist auf Windows nicht portabel). - **status/cancel ohne Daemon = Exit 1** (sie sind Daemon-Anfragen); say ohne Daemon = Degraded Mode Exit 0. ## Selbst-Review (3b44605..HEAD, 26 Dateien, +3166/−102) - `internal/provider` und `internal/audio`: **null Zeilen geändert** (Bindung „Kette unverändert“ eingehaltenn). - Bestehende Tests nur minimal angefasst: `setGetenv`-Basis (TALK_PORT=1, Isolation), `wantStubCommands` (vier Befehle sind jetzt real). Kein Assert geschwächt. - Ein im Review gefundener Wortlaut-Fehler („nicht erreichbar“ bei HTTP-500 in setMute) ist gefixt (Commit e1a3981 amendiert). - Bekannte kosmetische Grenzen: Cancel-Zählung bei exakt gleichzeitigem Natürliche-Ende (s. o.); `--wait` ohne Timeout (bewusst — Escape ist Ctrl-C); Daemon-Probe-Budget 1 s kann bei pathologisch langsamen Loopbacks einen unnötigen Degraded-Lauf auslösen (ohne Doppl-Ansage, da kein enqueue stattfand). ## Offene Punkte für Folge-Tasks - **Task 05:** RateLimiter in den Worker-Loop verdrahten (`queue.rate_limit`, Parking/Discard/`allow_high_burst`), Ruhezeiten; `talk status` um deren State ergänzen. Struktur + `Next()` sind vorbereitet. - **Task 06:** CORS (nur /speak) und MCP — Handler-Konstruktion in `api.NewHandler` ist dafür die eine Stelle. - .scratch/ unangetastet; kein Push erfolgt.