From 798de0be8f6730c87d3dd5cb79084d78bf4105d0 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 24 Jul 2026 20:06:10 +0200 Subject: [PATCH] Governor Phase 0 fertig + Phase 2: Hart-Deckel default + Sprach-Signal an Lucy Phase 0 abgeschlossen: - Hart-Deckel jetzt Default AN (Soft+5000), da der weiche Schnitt allein bei Weiterarbeit ueber die Grenze eine Fassade erzeugt (P0-Befund). GOV_HARD_CEILING=0 schaltet ihn aus. gov-ctl reicht Arg 2 nur bei Bedarf durch. - README: Empfehlung/Doku auf Default-an aktualisiert, Commit-Msg-Restpunkt notiert (--no-auto-commits als saubere Option). Phase 2 (Voice-Hook): beim Feuern (soft/hard) POSTet der Governor best-effort + gedrosselt (Default 300 s) eine Meldung an Lucys vorhandene Announce-Pipeline (POST :9001/api/voice/announce, source=governor, priority=normal). Lucy pollt, dedupliziert und spricht sie via lokales TTS -- KEIN Lucy-Code noetig. E2E bewiesen: Feuern -> ANNOUNCE status=200 -> Eintrag id 394 source=governor in der Queue. GOV_ANNOUNCE_URL="" schaltet es ab. announce-test.sh beigelegt. Co-Authored-By: Claude Opus 4.8 --- deploy/governor/README.md | 26 +++++++++----- deploy/governor/announce-test.sh | 22 ++++++++++++ deploy/governor/gov-ctl.sh | 10 +++--- deploy/governor/governor.py | 62 ++++++++++++++++++++++++++++++-- 4 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 deploy/governor/announce-test.sh diff --git a/deploy/governor/README.md b/deploy/governor/README.md index 2880925..0da6c58 100644 --- a/deploy/governor/README.md +++ b/deploy/governor/README.md @@ -39,18 +39,27 @@ Alle anderen Pfade (`/v1/models` etc.) werden roh durchgereicht. Streaming (SSE) byteweise durchgereicht; die echten `prompt_tokens` aus der Antwort werden zur Kalibrierung mitgeloggt. +### Sprach-Signal an Lucy (Phase 2) +Beim Feuern (soft ODER hart) POSTet der Governor — best-effort, gedrosselt (Default 300 s, +damit die Pro-Runde-Feuerung nicht spammt) — eine Meldung an Lucys vorhandene Announce-Pipeline +(`POST :9001/api/voice/announce`, `source:governor`, `priority:normal`). Lucy pollt diese Queue +ohnehin, dedupliziert per Cursor und spricht sie über ihr lokales TTS — gated durch ihren +„Box-Meldungen laut"-Schalter. **Kein Lucy-Code nötig.** Abschalten: `GOV_ANNOUNCE_URL=""`. + ### Umgebungsvariablen `GOV_PORT` (8100) · `GOV_HOST` (0.0.0.0) · `GOV_UPSTREAM` (http://127.0.0.1:8080) · -`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) · -`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG`. +`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (Default Soft+5000; 0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) · +`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG` · `GOV_ANNOUNCE_URL` (:9001/api/voice/announce; ""=aus) · +`GOV_ANNOUNCE_THROTTLE` (300 s) · `GOV_ANNOUNCE_TEXT`. ## Auf der Box laufen lassen (wie in P0 aufgesetzt) ```bash # Aider (einmalig, unter isoliertem Python 3.12 — System-Python 3.14 bricht Aiders Pins): pipx install uv && uv tool install --python 3.12 aider-chat -# Dateien liegen in ~/governor-p0/. Governor starten (Soft 25k, Hart aus): -~/governor-p0/gov-ctl.sh start 25000 0 # oder: start 25000 30000 (Hart an) +# Dateien liegen in ~/governor-p0/. Governor starten (Hart-Deckel default AN = Soft+5000): +~/governor-p0/gov-ctl.sh start 25000 # Soft 25k, Hart 30k (auto) +# ~/governor-p0/gov-ctl.sh start 25000 0 # Hart AUS (nur weicher Schnitt) # Aider-Sitzung durch den Governor: ~/governor-p0/run-driver.sh ~/governor-p0/msgs-todo.txt @@ -91,15 +100,16 @@ verweigert das Modell zwar den Code, schreibt die Absichten aber fortschreitend in SAVEPOINT — genährt von Aiders **irreführenden Commit-Nachrichten** (die aus dem SAVEPOINT- Absichtstext geschöpft werden). Ergebnis: eine Fassade (behauptete test_todo.py/README, die es nicht gab). Deshalb der optionale **Hart-Deckel** (`GOV_HARD_CEILING`): oberhalb davon antwortet -der Governor selbst, das Modell kann keine degradierenden Savepoints mehr schreiben. **Empfehlung: -Hart-Deckel aktiv** (z. B. `Soft 25000, Hart 30000` — ein Finalisier-Zug Luft, dann harter Riegel). +der Governor selbst, das Modell kann keine degradierenden Savepoints mehr schreiben. **Der Hart- +Deckel ist jetzt Default AN** (`GOV_HARD_CEILING` unset → Soft+5000; explizit `0` schaltet ihn +aus): ein Finalisier-Zug Luft, dann harter Riegel — das schliesst die Fassaden-Lücke. ### Weitere Befunde / Fallen - **Aiders eigene Zusammenfassung MUSS aus** (`summarizer.max_tokens` hoch) — sonst compactet Aider selbst und der Governor greift nie. Siehe oben. - **Commit-Nachrichten überzeichnen** in der Abschluss-Phase (aus SAVEPOINT-Absicht). Der Code - ist die Wahrheit; git-Nachrichten sind es hier nicht. → Für Phase 1: Auto-Commit nahe/nach dem - Feuern zügeln oder Savepoint mit ehrlicher fixer Nachricht committen. + ist die Wahrheit; git-Nachrichten sind es hier nicht. Der Hart-Deckel (jetzt Default) begrenzt + das auf ~1 Zug; wer es ganz sauber will, startet Aider mit `--no-auto-commits` (Commits von Hand). - **Python 3.14 auf der Box bricht Aiders Pins** (numpy 1.24.3) → Aider via `uv` unter isoliertem Python 3.12 installiert. - **Aider-Scripting-API (`coder.run`) hängt** in einer Datei-Hinzufügen-Reflexion (Edits landeten diff --git a/deploy/governor/announce-test.sh b/deploy/governor/announce-test.sh new file mode 100644 index 0000000..7798782 --- /dev/null +++ b/deploy/governor/announce-test.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# announce-test.sh — loest ein Feuern aus und prueft, ob der Governor das Sprach- +# Signal an Lucys Announce-Queue (:9001) postet (Phase 2). Voraussetzung: Governor +# mit niedriger Schwelle gestartet, damit ein einzelner Request feuert. +set -u +GOV="http://127.0.0.1:8100/v1/chat/completions" + +python3 - <<'PY' > /tmp/gov_fire.json +import json +print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"y"*35000}],"max_tokens":8,"stream":False})) +PY + +echo "=== Feuern ausloesen (est ~10000 Tokens, ueber Hart-Deckel) ===" +curl -s -m 20 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_fire.json | jq '{id, finish: .choices[0].finish_reason}' + +sleep 1.5 # Announce-Thread durchlassen +echo "=== Governor-Log (letzte 4 Zeilen) ===" +tail -4 ~/governor-p0/governor.log + +echo "=== Announce-Queue: Governor-Eintraege ===" +curl -s -m 8 "http://127.0.0.1:9001/api/voice/announcements?after=0&limit=100" \ + | jq '[.items[] | select(.source=="governor")] | (last // "KEINE governor-Meldung gefunden")' diff --git a/deploy/governor/gov-ctl.sh b/deploy/governor/gov-ctl.sh index 4c9c819..68ae855 100644 --- a/deploy/governor/gov-ctl.sh +++ b/deploy/governor/gov-ctl.sh @@ -13,18 +13,20 @@ OUT="$DIR/governor.stdout" start() { stop local thr="${1:-25000}" - local hard="${2:-${GOV_HARD_CEILING:-0}}" cd "$DIR" : > "$LOG" # Voll detachen: eigene Session, alle FDs weg vom Aufrufer. - # CPT (Zeichen/Token) aus der Umgebung durchreichen, Default 3.5 (kalibriert 24.07.). - setsid env GOV_THRESHOLD="$thr" GOV_HARD_CEILING="$hard" \ + # Hart-Deckel nur setzen, wenn explizit uebergeben (Arg 2); sonst rechnet + # governor.py den Default (Soft + 5000). CPT-Default 3.5 (kalibriert 24.07.). + local hardenv=() + if [ -n "${2:-}" ]; then hardenv=(GOV_HARD_CEILING="$2"); fi + setsid env GOV_THRESHOLD="$thr" "${hardenv[@]}" \ GOV_CHARS_PER_TOKEN="${GOV_CHARS_PER_TOKEN:-3.5}" \ GOV_LOG="$LOG" GOV_PORT=8100 \ python3 "$DIR/governor.py" >"$OUT" 2>&1 & echo $! > "$PIDF" sleep 1.2 - echo "started pid=$(cat "$PIDF") threshold=$thr hard=$hard" + echo "started pid=$(cat "$PIDF") threshold=$thr hard=${2:-auto}" ss -tlnp 2>/dev/null | grep ":8100" >/dev/null && echo "listening :8100 OK" || echo "WARN: not listening" } diff --git a/deploy/governor/governor.py b/deploy/governor/governor.py index 49890e8..63a4866 100644 --- a/deploy/governor/governor.py +++ b/deploy/governor/governor.py @@ -21,11 +21,14 @@ Konfiguration per Umgebungsvariablen (alle optional): GOV_HOST Listen-Adresse (Default 0.0.0.0) GOV_UPSTREAM Modell-Endpoint (Default http://127.0.0.1:8080) GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 25000) - GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default 0) + GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN) GOV_CHARS_PER_TOKEN Heuristik Zeichen->Token (Default 3.5, kalibriert) GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log) GOV_DIRECTIVE Text des Soft-Einschubs (sonst Default unten) GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort (sonst Default unten) + GOV_ANNOUNCE_URL Lucy-Sprach-Signal-Endpunkt; "" = aus (Default :9001/api/voice/announce) + GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300) + GOV_ANNOUNCE_TEXT Text des Sprach-Signals (sonst Default) """ import http.client @@ -43,7 +46,11 @@ PORT = int(os.environ.get("GOV_PORT", "8100")) HOST = os.environ.get("GOV_HOST", "0.0.0.0") UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:8080") THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "25000")) -HARD_CEILING = int(os.environ.get("GOV_HARD_CEILING", "0")) # 0 = deaktiviert +# Hart-Deckel: Default AN (Soft + 5000 = ein Finalisier-Zug Luft), weil der weiche +# Schnitt allein bei Weiterarbeit ueber die Grenze eine Fassade erzeugt (Befund P0). +# Explizit setzbar; GOV_HARD_CEILING=0 schaltet ihn aus. +_hard_env = os.environ.get("GOV_HARD_CEILING") +HARD_CEILING = (THRESHOLD + 5000) if _hard_env is None else int(_hard_env) CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.5")) LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log")) @@ -69,6 +76,18 @@ DEFAULT_HARDSTOP = ( ) HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP) +# Sprach-Signal an Lucy (Phase 2): beim Feuern POSTet der Governor eine Meldung an die +# vorhandene MC2-Announce-Pipeline (:9001). Lucy pollt sie ohnehin, dedupliziert und +# spricht sie (gated durch ihren "Box-Meldungen laut"-Schalter). Best-effort, gedrosselt +# gegen die Pro-Runde-Feuerung. GOV_ANNOUNCE_URL="" schaltet das Signal ab. +ANNOUNCE_URL = os.environ.get("GOV_ANNOUNCE_URL", "http://127.0.0.1:9001/api/voice/announce") +ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300")) # Sekunden +DEFAULT_ANNOUNCE = ( + "Commander, die Coding-Sitzung wird voll — ungefähr {est} Tokens. Ich sichere den " + "Stand im Savepoint; am besten fangen wir gleich frisch an." +) +ANNOUNCE_TEXT = os.environ.get("GOV_ANNOUNCE_TEXT", DEFAULT_ANNOUNCE) + up = urlparse(UPSTREAM) UP_HOST = up.hostname or "127.0.0.1" UP_PORT = up.port or 80 @@ -80,6 +99,9 @@ HOP_BY_HOP = { _log_lock = threading.Lock() _PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)') +_announce_lock = threading.Lock() +_last_announce = 0.0 # Zeitstempel der letzten Meldung (Drossel) +_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None def log(line: str) -> None: @@ -115,6 +137,40 @@ def estimate_tokens(messages) -> int: return int(chars / CHARS_PER_TOKEN) +def _post_announce(est) -> None: + """POSTet die Meldung an die MC2-Announce-Pipeline. Laeuft im Hintergrund-Thread.""" + try: + text = (ANNOUNCE_TEXT.replace("{est}", str(est)) + .replace("{threshold}", str(THRESHOLD))) + body = json.dumps({"text": text, "subject": "[Governor]", + "source": "governor", "priority": "normal"}).encode("utf-8") + conn = http.client.HTTPConnection(_an.hostname or "127.0.0.1", + _an.port or 80, timeout=4) + conn.request("POST", _an.path or "/api/voice/announce", body=body, + headers={"Content-Type": "application/json", + "Content-Length": str(len(body))}) + resp = conn.getresponse() + resp.read() + conn.close() + log(f"ANNOUNCE -> Lucy status={resp.status} est={est}") + except Exception as exc: # noqa: BLE001 + log(f"ANNOUNCE fehlgeschlagen: {exc!r}") + + +def maybe_announce(est) -> None: + """Sprach-Signal an Lucy ausloesen — gedrosselt, damit die Pro-Runde-Feuerung + nicht spammt (eine Aeusserung je Ueberschreitungs-Episode). Best-effort.""" + if not _an: + return + global _last_announce + now = time.time() + with _announce_lock: + if now - _last_announce < ANNOUNCE_THROTTLE: + return + _last_announce = now + threading.Thread(target=_post_announce, args=(est,), daemon=True).start() + + class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = "Governor/0.2" @@ -161,6 +217,8 @@ class Handler(BaseHTTPRequestHandler): model = "" if is_chat and body: action, body, est, streaming, model = self._decide(body) + if action in ("soft", "hard"): + maybe_announce(est) # Sprach-Signal an Lucy (gedrosselt) # HARTER STOPP: selbst antworten, Upstream nie fragen. if action == "hard":