Files
talk/.superpowers/sdd/plan/task-4-report.md
T

88 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>`, 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.