chore(sdd): handoff snapshot — ledger, briefs, reports, review packages + HANDOFF.md for cross-machine continuation
This commit is contained in:
1 parent
acfba19fc2
commit
b934571b97
25 files changed
+17590
No files matched your search
@@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user