Zuletzt aktualisiert:

Coach-Daemon (SILV-448) — Runbook

Stand 04.09.2026 · Strato. Der Coach-Daemon beantwortet die Knopf-Anfragen der App (POST /api/coach/ask, Vertrag coach-app-vertrag) über Dennis' Claude-Max-Abo mit dem Python Agent SDK — keine API-Tokens (Dennis, hart). Form und Begründung: coach-service-abo-form.

Was läuft wo

Teil Ort Zweck
app/coach_daemon.py Host, Venv app/.venv-coach (claude-agent-sdk 0.2.152 + gebündelte CLI 2.1.259, httpx, starlette/uvicorn, pyyaml) Daemon: /ask, /health, /reload
app/coach-daemon.sh Host Einzelinstanz (flock), Log app/data/coach-daemon.log
Crontab * * * * * coach-daemon.sh Host Guard: startet neu, falls tot (Re-Run = No-Op); Neustart ≤ 60 s nach Absturz (verifiziert)
~/running-coaching Host (Checkout, lesend) Skills (docs/coach-skills/, später .claude/skills/coach/) + knowledge/ für den Prompt
~/.claude/.credentials.json Host Abo-Login (OAuth); Refresh-Token läuft bis 03.10.2026
app/data/coach-cwd/ Host leeres Arbeitsverzeichnis der CLI (kein CLAUDE.md, keine Settings)

Netz: der Daemon lauscht nur auf der Docker-Bridge 172.17.0.1:9310 (VPS öffentlich, ufw aus → nie 0.0.0.0). Container erreicht ihn als host.docker.internal:9310 (extra_hosts: host-gateway, compose), Host-Worker als http://172.17.0.1:9310. Jeder Aufruf braucht X-Internal-Token.

Ablauf eines Knopfs

  1. Backend coach_api._dispatch pusht POST /ask mit {request_id, user_id, button, effort, model, answer_max_lines, writes, photo_mode, context, callback} (Timeout 3 s) → Daemon antwortet sofort 202 und arbeitet asynchron.
  2. Usage-Gate: GET api.anthropic.com/api/oauth/usage (Bearer aus der Credentials-Datei, Cache 5 min; der Endpoint ist selbst rate-limitiert → bei 429 15 min Pause, letzter Wert bleibt). five_hour.utilization ≥ 90 % (COACH_USAGE_GATE) → meta + coach_error code=quota mit „frei wieder ab HH:MM“ (Berlin). Kein Ausweichen auf ein kleineres Modell.
  3. Warmen Client holen (Pool je Effort: low=1, medium=1, high=0, COACH_POOL; Effort ist ein CLI-Prozess-Flag → ein Pool je Stufe; Clients > 10 min werden erneuert). Kein warmer → frisch starten (+~1,5 s). meta mit harness ok|cold, usage_pct, model, effort, warm, expected_ttft_s (17/30/40 je low/medium/high — Benchmark).
  4. Prompt (Spec §7): system_prompt = kurzer Harness-Kopf + _basis.md + Kern (kommunikation.md, memory/* ohne Index, ziele.md „Fünf Regeln“ inkl. Ampel, ernaehrung.md Budgetlogik/Zielwerte/Logging/Supplemente, athlet.md kurz, datenquellen.md „Zwei Budgets“) + mahlzeiten.md + Wellness-Regeln (plan-2026-08.md Ampel) + Ausgabeformat §3.4 — stabil je Repo-Stand (~10k Tokens, Cache-Prefix). User-Prompt = Skill (ohne Frontmatter; ohne Skill-Datei ein generischer Stub) + Stand-Kurz (stand.md: Kopfzeile, Anker-Tabelle, Physiologie-Tabelle, Waage/Ruhepuls/KFA, Protein-Ziel) + die Kontextblöcke aus dem Push als JSON + Eingabe.
  5. SDK: tools=[], max_turns=1, setting_sources=None, permission_mode=dontAsk, Auto-Memory aus, no-session-persistence, include_partial_messages=True, Modell claude-fable-5-1 (COACH_MODEL). photo_mode=raw → Bild aus app/data/images/<basename> als base64-Block (sonst nur die Foto-Schätzung als Text).
  6. Streaming: das Modell liefert JSON; der Daemon zieht nur den Inhalt von answer_md inkrementell heraus (dekodiert) und schickt ihn gebündelt (~120 ms) als coach_token. Am Ende JSON parsen → coach_done mit meta{model, effort, context_tokens, cached_tokens, output_tokens, latency_ms, ttft_ms, usage_pct, repo}. Ein Client je Anfrage, danach weg.
  7. Fehlerbilder → coach_error: quota (Gate oder Rate-Limit rejected), refusal (stop_reason=refusal), login (401/Auth — dazu einmal je Stunde Push an Dennis „Abo-Login kaputt“, Heartbeat pool_ready=0 → /connection cold), unparseable (kein Coach-JSON), upstream (sonstiges), harness_down (Fallback-Worker erreicht den Daemon nicht).

Fallback: kommt der Push nicht an, legt das Backend den ai_jobs-Job coach_ask an; ai_jobs.job_coach_ask holt GET /api/coach/internal/{rid}/request (Push-Body neu gebaut) und reicht ihn dem Daemon; ist der immer noch weg → sofort coach_error harness_down (statt 150 s Stream-Timeout). Kein zweiter KI-Weg.

Heartbeat alle 30 s → POST /api/coach/internal/heartbeat {pool_ready, usage_pct, model, version}; /connection.harness = ok (Pool warm) · cold (Pool leer/Login kaputt) · down (kein Heartbeat seit 90 s). Katalog: beim Start und bei Repo-Änderung PUT /api/coach/buttons (Backend-Defaults aus coach_app.DEFAULT_BUTTONS, überschrieben durch Skill-Frontmatter; version = <Datum>.<git-short>). Der Heartbeat-Loop erkennt einen neuen HEAD (nach git pull) und lädt Prompt-Kit + Pool neu (POST /reload erzwingt das).

Bedienung

curl -s http://172.17.0.1:9310/health | python3 -m json.tool     # Pool, Usage, Login, Zähler
tail -f app/data/coach-daemon.log                                  # je Anfrage: ttft/total/ctx/cached
cd ~/running-coaching && git pull                                  # neue Skills/Knowledge → Reload automatisch (≤ 30 s)
pkill -f '^\.venv-coach/bin/python coach_daemon'                   # Neustart (Cron-Guard holt ihn ≤ 60 s)

Login erneuern (Push „Coach: Abo-Login kaputt“ oder login_ok=false): auf dem VPS als dennis claude starten und /login — Dennis macht den Browser-Schritt selbst. Danach Daemon neu starten.

Abo-Fenster teilen: der Coach zieht aus demselben 5-h-Fenster wie die Claude-Code-Sessions auf diesem Konto (StratoClaude/MacClaude). Ein langer Bau-Tag kann das Fenster leeren; dann sagt der Coach ab 90 % ehrlich ab (quota, Reset-Zeit im Text). Das ist gewollt (Dennis: kein Ausweichen), aber beim Planen großer Sessions mitdenken.

Grenzen / offen