refactor: ein Binary — talk startet den Daemon selbst, talkd und Degraded Mode entfallen

Zwei Binaries erzeugten den halben Komplexitätsballast: Degraded Mode mit
Lock-Datei und Stale-Lock-Race, „läuft talkd?", Autostart-Konfiguration je
OS, doppelte Chain-Konstruktion in cmd/talk und cmd/talkd — und der
Degraded Mode kannte weder Rate-Limit noch Ruhezeiten.

Jetzt:
- internal/speak: der eine Sprech-Kern (Config + Sink + Kette → Ansage);
  Daemon und Direkt-Pfad benutzen ihn, es gibt keine zweite
  Zusammensetzung der Kette mehr.
- internal/daemon: Run (der bisherige talkd-Kern), Spawn (detached per
  Setsid bzw. DETACHED_PROCESS, stdout/stderr → daemon.log), PID-Datei
  daemon.pid, Shutdown-Trigger.
- `talk daemon run|start|stop`: Vordergrund (systemd/launchd), expliziter
  Hintergrund-Start, Stop per POST /shutdown — stop wartet, bis HTTP weg
  und die PID-Datei gelöscht ist (Prozess wirklich beendet).
- Autostart: say/wrap starten den Daemon beim ersten Sprechbefehl selbst
  (daemon.autostart, Default an; Env TALK_AUTOSTART). Verliert ein
  paralleler Aufruf das Port-Rennen, erkennt er das am Kind-Ende und nutzt
  den Daemon des anderen — kein Timeout-Warten.
- Direkt-Pfad statt Degraded Mode: ohne erreichbaren oder startbaren
  Daemon spricht talk direkt (Mute respektiert, kein Lock, Hinweis +
  Grund + Log-Pfad auf stderr). Steuerbefehle starten den Daemon bewusst
  nicht; `talk status` zeigt „läuft nicht" (Exit 1).
- cmd/talkd und cmd/talk/degraded.go entfernt; README-Daemon-Abschnitt
  neu.

Tests: Harness baut nur noch talk und startet `talk daemon run`;
TALK_AUTOSTART=false als Grundausstattung aller Tests (kein Test
hinterlässt Daemons), autostart_test.go schaltet es bewusst an: Autostart
beim ersten say, Idempotenz, paralleler Start in EINE Queue, expliziter
start/stop, Port fremd belegt → Direkt-Pfad ohne Timeout. Cross-Compile
aller sechs Targets mit CGO_ENABLED=0 verifiziert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 09:49:16 +02:00
1 parent b86632006e
commit bc26a73aeb
29 files changed
+1145 -584

No files matched your search

+44 -20
View File
@@ -6,9 +6,10 @@
**talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` —
spricht eine kurze Nachricht über eine TTS-Provider-Kette
(ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch den Daemon
`talkd`. Cross-platform (Windows/macOS/Linux, amd64/arm64), statische Binaries,
keine Runtime-Abhängigkeiten.
(ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch einen
Hintergrund-Daemon, den talk beim ersten Aufruf selbst startet. Ein
statisches Binary, cross-platform (Windows/macOS/Linux, amd64/arm64), keine
Runtime-Abhängigkeiten.
## Befehle
@@ -20,6 +21,7 @@ keine Runtime-Abhängigkeiten.
| `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben |
| `talk status` | Daemon-Status (Queue, Mute, Provider, Version) |
| `talk cancel` | laufende und geplante Ansagen abbrechen |
| `talk daemon run\|start\|stop` | Daemon im Vordergrund betreiben / im Hintergrund starten / stoppen (normalerweise nicht nötig, siehe unten) |
| `talk voices` | verfügbare Stimmen anzeigen |
| `talk test` | Test-Ansage ausgeben |
| `talk doctor` | Selbstdiagnose des Setups |
@@ -29,17 +31,37 @@ Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
`--voice`, `--lang`, `--provider`.
**Hinweis:** In diesem Stand sind implementiert: `say` (und `talk "Text"`)
mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking über
`talkd`, siehe unten), `mute`, `unmute`, `status`, `cancel` sowie `wrap`.
Die übrigen Befehle sind Stubs und melden `not implemented yet` (Exit-Code
≠ 0).
mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking,
Autostart, siehe unten), `mute`, `unmute`, `status`, `cancel`, `daemon`
sowie `wrap`. Die übrigen Befehle sind Stubs und melden `not implemented
yet` (Exit-Code ≠ 0).
## Daemon talkd (Task 04–06)
## Daemon
`talkd` besitzt die Ansage-Queue und spricht seriell ab — genau eine Ansage
gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low` (FIFO je
Level). Der HTTP-Server lauscht nur auf `127.0.0.1`; Port: Default `7800`,
config `[server] port`, Env `TALK_PORT` (schlagen den Default).
Es gibt genau ein Binary. Der Daemon ist `talk daemon run` — derselbe Code,
im Hintergrund. Er besitzt die Ansage-Queue und spricht seriell ab — genau
eine Ansage gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low`
(FIFO je Level). Der HTTP-Server lauscht nur auf `127.0.0.1`; Port: Default
`7800`, config `[server] port`, Env `TALK_PORT` (schlagen den Default).
**Autostart:** Läuft kein Daemon, startet `talk say` (und `talk wrap`) ihn
beim ersten Sprechbefehl selbst — losgelöst vom Terminal, mit Protokoll in
`daemon.log` und Prozess-ID in `daemon.pid` im Config-Dir; stderr meldet
einmalig „Daemon gestartet". Der Daemon läuft dann weiter (Leerlauf kostet
nichts Nennenswertes), bis `talk daemon stop`, Abmelden oder Neustart.
Parallele erste Aufrufe sind harmlos: Verliert einer das Port-Rennen,
benutzt er den Daemon des anderen. Wer den Daemon selbst verwaltet
(systemd, launchd, Task Scheduler mit `talk daemon run`), schaltet den
Autostart ab:
```toml
[daemon]
autostart = false # Env TALK_AUTOSTART=false
```
Steuerbefehle (`mute`, `unmute`, `status`, `cancel`) starten den Daemon
bewusst nicht: `talk status` sagt dann „läuft nicht" (Exit 1), `talk mute`
schreibt die Mute-Datei direkt.
| Endpunkt | Wirkung |
| --- | --- |
@@ -48,8 +70,9 @@ config `[server] port`, Env `TALK_PORT` (schlagen den Default).
| `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "mcp": true, "cors": [origins]}` |
| `POST /cancel` | bricht die laufende Ansage ab + leert die Queue → `200 {"cancelled":n}` |
| `POST /mute` / `POST /unmute` | persistenter Mute-State → `200 {"muted":bool}` |
| `POST /shutdown` | fährt den Daemon herunter (`talk daemon stop`) → `200 {"ok":true}` |
| `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call` — ein Tool `speak` (siehe unten) |
| `GET /mcp` | `405` — talkd bietet keine server-initiierten Streams an |
| `GET /mcp` | `405` — der Daemon bietet keine server-initiierten Streams an |
- **CLI-Verhalten:** `talk say` (und `talk "Text"`) erkennt den Daemon via
`GET /status`. Läuft er: enqueue + sofortige Rückkehr (non-blocking,
@@ -61,10 +84,11 @@ config `[server] port`, Env `TALK_PORT` (schlagen den Default).
`false`) und überlebt Daemon-Restarts. `talk mute` ohne laufenden Daemon
schreibt die Datei direkt — der Daemon liest sie bei jeder Ansage neu,
der Zustand greift also auch offline.
- **Degraded Mode:** Antwortet kein Daemon, spielt `talk` lokal und
sequenziell weiter — serialisiert über die Lock-Datei `talk.lock` im
Config-Dir (Kollision = warten; verwaisste Locks gelten nach 60 s als
verfallen). Mute wird auch hier respektiert. Nichts bricht still.
- **Direkt-Pfad:** Ist kein Daemon erreichbar und keiner startbar
(Autostart aus, Port fremd belegt, Start gescheitert), spricht `talk`
direkt im eigenen Prozess — blockierend, ohne Warteschlange (parallele
Aufrufe können überlappen), mit Hinweis und Grund auf stderr. Mute wird
auch hier respektiert. Nichts bricht still.
### Rate-Limit + Ruhezeiten (Task 05)
@@ -129,7 +153,7 @@ allowed_origins = ["https://chat.az-gruppe.com"] # Env TALK_ALLOWED_ORIGINS
- **Die Tür ist serverseitig zu, nicht nur browserseitig.** CORS allein
verhindert nur das *Lesen* der Antwort; einen POST mit `text/plain`-Body
oder einen Formular-POST schickt jede Webseite ohne Preflight ab. Deshalb
lehnt talkd jede Anfrage mit fremdem `Origin`-Header mit `403` ab, die
lehnt der Daemon jede Anfrage mit fremdem `Origin`-Header mit `403` ab, die
nicht POST /speak von einer gelisteten Origin ist — also ungelistete
Origins auf /speak, jeden Browser-Zugriff auf `/status`, `/mute`,
`/unmute`, `/cancel` und `/mcp`, auch von gelisteten Origins. Nur der
@@ -261,8 +285,8 @@ talk wrap --wait -- make test
Befehle (Binary nicht gefunden) zählen als 127 (Shell-Konvention),
Signaltode portabel als 1. Benutzungsfehler von talk selbst (kein
Befehl, ungültige Flags) scheitern vor dem Start des Befehls.
- **Ansageweg:** derselbe wie say — Daemon-Pfad (enqueue, non-blocking;
`--wait` respektiert), ohne Daemon Degraded Mode über die Lock-Datei.
- **Ansageweg:** derselbe wie say — Daemon (enqueue, non-blocking;
`--wait` respektiert; Autostart), ohne Daemon der Direkt-Pfad.
wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`,
`--lang`, `--provider`) und reicht sie an die Ansage durch.