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

7.5 KiB
Raw Blame History

Task-5-Report: Rate-Limit + Ruhezeiten

Status: DONE · Branch feat/v1 · Basis dc94f45 (Task 4) Commits: b20e6dd (config) → 5385a4b (queue) → b56c8ab (api/talkd/cli) → 5979d45 (Prozessgrenzen-Tests) → 24ceff3 (README). Kein Push, .scratch/ unangetastet.

Timing-Semantik (verbindlich, wer wartet / wer verfällt / was now darf)

Rangordnung: mute (API, immer) > now > Ruhezeit > Rate-Limit.

Situation low normal high now:true
Rate-Fenster voll (Start der letzten Ansage < queue.rate_limit her) verworfen — schon beim Eintritt, 202 {id:"",discarded:"rate_limit"}; kam es bei freiem Fenster rein, verfällt es beim Worker-Durchlauf (Log-Zeile) parkt in der Queue, spielt zum Slot (rate_limit.next_slot in /status; Slot-Wecker weckt den Worker) mit allow_high_burst=true (Default): spielt sofort (bewusster Verstoß); false: parkt wie normal spielt sofort — umgeht auch das Rate-Limit (korrigierte Brief-Entscheidung umgesetzt)
Ruhezeit aktiv (quiet_hours, lokal, über Mitternacht) verworfen, 202 {discarded:"quiet_hours"} dito dito — Burst ist ein Rate-Recht, kein Ruhe-Recht spielt sofort — bricht auch die Ruhezeit (dokumentierter Ausbruch)
quiet_mode="toast" / / / / : statt Verwurf Toast-Ersetzung, 202 {discarded:"quiet_hours",toast:true}
Fenster frei + keine Ruhezeit spielt spielt spielt spielt + springt vor

Weitere Regeln, dokumentiert und getestet:

  • Jede gestartete Ansage bucht das Fenster neu (Reserve vor dem Abspielen) — auch eine per now oder high-burst ausgebrochene und auch eine fehlgeschlagene (kaputter Provider darf nicht zum Maschinengewehr werden). Fenster = Mindestabstand zwischen Ansage-Starts.
  • While parked bleibt ein normal/high-Item voll sichtbar (queue_ids, --wait funktioniert); neu eintreffende now-/burst-fähige Items werden vorgezogen (Rank-Ordnung unverändert: now > high > normal > low, FIFO je Level).
  • --wait auf ein verworfenes Item endet sauber (ID verschwindet aus der Queue — dasselbe Muster wie bei cancel).

Umsetzung

  • internal/queue/quiet.go (neu): QuietHours — Parse HH:MM-HH:MM (leer = aus, Start==End = aus), Active (Tag-/Nachtfenster, Start inklusiv, End exklusiv), Until (Fenster-Ende für /status), String (Roundtrip).
  • internal/queue/rate.go: RateLimiter (aus T4, Next() liefert jetzt Nullzeit bei nie gebuchtem Fenster laut Doku), dazu Laws + reine decide-Ordnung, Admission (Enqueue-Ergebnis mit Verwurfs-Grund), LawState (/status-Sicht), Grund-Konstanten rate_limit/quiet_hours.
  • internal/queue/queue.go: Loop erweitert (nicht umgeschrieben — Rank/Serialisierung/Mute unangetastet): readyLocked/takeLocked (parken ohne Entnahme), armWakeLocked (Slot-Wecker broadcastet die Cond; Wait-Bedingung prüft die Gesetze neu → verlorene Signale harmlos), Enqueue entscheidet Ruhezeit/low-Budget schon beim Eintritt (damit die 202 den Grund nennen kann). Lock-Ordnung Queue → RateLimiter durchgehend gewahrt; Race-Detektor grün.
  • internal/config: [queue] rate_limit = "30s" (Dauer-String, "0s" = unbegrenzt; TOML-Decode via time.Duration-TextUnmarshaler, verifiziert), allow_high_burst (Default true; partielle [queue]-Tabellen behalten Defaults — getestet), quiet_hours/quiet_mode (Default aus/discard). Env: TALK_RATE_LIMIT, TALK_ALLOW_HIGH_BURST, TALK_QUIET_HOURS (off = aus), TALK_QUIET_MODE. Validierung gegen queue.ParseQuietHours (eine Quelle; Import config→queue, kein Zyklus).
  • internal/api: /speak antwortet bei Verwurf 202 {id:"",discarded:…} (+toast:true) — analog zum Mute-Muster, kein Fehler; /status additiv rate_limit {interval, allow_high_burst, next_slot} + quiet_hours {window, active, mode, until} (RFC3339 bzw. null). Kein Feld gebrochen.
  • cmd/talkd: verwired Laws aus der Config, Startup-Log nennt das Gesetz.
  • CLI: say meldet discarded: <grund> (+ (toast)) plain auf stdout mit Exit 0 (wie muted); talk status zeigt rate-limit: … (high-burst …), nächster slot … und ruhezeit: … (modus …, aktiv bis …/inaktiv).
  • README: kompletter Abschnitt zu Semantik + Config/Env + Rangordnung; Endpunkt-Tabelle aktualisiert.

Tests

  • Prozessgrenze (test/ratelimit_test.go, echte Binaries, Fake-Provider, sink=null, ephemeral Ports): kurzes Fenster (1 s) → 5 Ansagen: genau 1 spielt sofort, low verfällt beim Eintritt (HTTP und CLI-Output), normal parkt (queue_ids + next_slot sichtbar) und spielt frühestens zum Slot (Timing am Fake gemessen, ≥ 850 ms Abstand); high-burst an (spielt im Fenster) / aus (parkt, cancel räumt, Daemon gesund — das Warten-Aus mit langem Fenster deckt der Park-Test); now bricht durch Rate-Limit und Ruhezeit; Ruhezeit-Fenster künstlich um die lokale Jetztzeit (±1 min) → discard (auch high, kein Provider-Kontakt, Log-Zeile, until in /status) und toast (Toast-Protokoll-Zeile); /status- + talk status-Felder (Defaults 30 s/Burst an/Slot frei, gebuchter Slot nach Ansage).
  • Unit (internal/queue): QuietHours-Parse/Active/Until/String (tischgesteuert inkl. Mitternacht); Queue-Gesetze mit gesteuerter Spielerin (low verfällt, normal parkt + spielt, burst ein/aus, now-Ausbruch + Folgebuchung, Ruhezeit discard/toast auch für high, LawState).
  • Config (internal/config): Defaults, volle/partielle TOML, Env-Overrides inkl. off, klare Fehler für Müll.
  • Harness: Basis-Env TALK_RATE_LIMIT=0s — die Task-04-Semantik-Tests (Serialisierung, Priorität, …) erwarten dichtes Durchspielen und blieben unverändert; Gesetz-Tests überschreiben bewusst.

Verifikation: gofmt -l . leer · go vet ./... grün · go test ./... grün · go test -race -count=1 ./... grün (queue+test zusätzlich -count=2 zur Flakiness-Probe) · go build ./... grün · Cross-Compile-Smoke CGO_ENABLED=0 GOOS=darwin/windows GOARCH=amd64 für talk+talkd erfolgreich.

Entscheidungen & Hinweise

  1. now:true umgeht auch das Rate-Limit — die CORRECTION im Brief ist bindend umgesetzt und überall (Code-Kommentare, README, Tests) konsistent dokumentiert; ein now-Bruch startet trotzdem ein neues Fenster.
  2. Ruhezeiten sind per Default aus (leer). Ein Default-Fenster „18:00–08:00" würde jeden Cron/Test in den Abendstunden stilllegen (CI läuft zu jeder Uhrzeit) — Opt-in wie Mute, im Config-Kommentar begründet.
  3. quiet_mode="toast" ist derzeit eine Daemon-Protokoll-Zeile über den Notification-Pfad (Log-Writer), keine OS-Benachrichtigung — die Brief-Vorgabe „log line via notification path if trivial, else document limitation": der Notification-Pfad existiert noch nicht (internal/notify ist doc-only), also die dokumentierte Einschränkung. Die Ersetzung sitzt im Gesetz an genau einer Stelle (verdictToast), die OS-Anbindung ersetzt nur das Log-Ziel.
  4. Degraded Mode respektiert weiterhin nur Mute, nicht Rate-Limit/Ruhezeit (Task-4-Umfang unverändert; der Brief fordert das Gesetz für den Daemon). Der Lock-File-Pfad bleibt sequenziell-best-effort. Für V1 akzeptiert; falls gewünscht, als Folge-Task.
  5. Verwurf beim Eintritt (low über Budget, Ruhezeit) vs. Worker-Durchlauf: Eintritt, damit die 202-Antwort den Grund nennen kann (so der Brief); was bei freiem Fenster eintritt und erst später verfällt, verfällt im Worker mit Log-Zeile — beides getestet bzw. in der Semantik-Tabelle dokumentiert.
  6. Stale Slot-Wecker nach Close sind harmlos (Broadcast ohne Waiter; Code-Kommentar erläutert es).