From a5410642faa6d1e6a4b2ac7b22170f2d94194565 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 7 Aug 2026 11:47:50 +0200 Subject: [PATCH] feat: ide-lane hardening (ci ampel in auftragsbuch, hermes auto-bugfix, cleanup) --- backend/app.py | 10 +- backend/routers/sysadmin.py | 10 - backend/services/auftragsbuch.py | 46 ++ backend/services/jobengine.py | 195 -------- backend/services/ketten_digest.py | 190 -------- backend/services/sysadmin_report.py | 178 -------- deploy/ampel-waechter.sh | 10 +- deploy/governor/CONVENTIONS.md | 33 -- deploy/governor/README.md | 134 ------ deploy/governor/SAVEPOINT.template.md | 23 - deploy/governor/announce-test.sh | 22 - deploy/governor/driver.py | 67 --- deploy/governor/gov-ctl.sh | 54 --- deploy/governor/governor.py | 568 ------------------------ deploy/governor/governor.service | 27 -- deploy/governor/hardstop-test.sh | 29 -- deploy/governor/msgs-todo.txt | 13 - deploy/governor/run-aider-msg.sh | 19 - deploy/governor/run-aider.sh | 20 - deploy/governor/run-driver.sh | 8 - deploy/mc2-morgen-digest.service | 7 - deploy/mc2-morgen-digest.timer | 10 - deploy/morgen-digest.sh | 62 --- frontend/src/lib/api.ts | 1 + frontend/src/views/AuftragsbuchView.tsx | 17 + 25 files changed, 75 insertions(+), 1678 deletions(-) delete mode 100644 backend/routers/sysadmin.py delete mode 100644 backend/services/jobengine.py delete mode 100644 backend/services/ketten_digest.py delete mode 100644 backend/services/sysadmin_report.py delete mode 100644 deploy/governor/CONVENTIONS.md delete mode 100644 deploy/governor/README.md delete mode 100644 deploy/governor/SAVEPOINT.template.md delete mode 100644 deploy/governor/announce-test.sh delete mode 100644 deploy/governor/driver.py delete mode 100644 deploy/governor/gov-ctl.sh delete mode 100644 deploy/governor/governor.py delete mode 100644 deploy/governor/governor.service delete mode 100644 deploy/governor/hardstop-test.sh delete mode 100644 deploy/governor/msgs-todo.txt delete mode 100644 deploy/governor/run-aider-msg.sh delete mode 100644 deploy/governor/run-aider.sh delete mode 100644 deploy/governor/run-driver.sh delete mode 100644 deploy/mc2-morgen-digest.service delete mode 100644 deploy/mc2-morgen-digest.timer delete mode 100644 deploy/morgen-digest.sh diff --git a/backend/app.py b/backend/app.py index 1930f21..786f3c7 100644 --- a/backend/app.py +++ b/backend/app.py @@ -34,13 +34,12 @@ from routers import ( models, routing, system, - sysadmin, voice, wissen, zeitmaschine, ) from routers import reminders as reminders_router -from services import ketten_digest, metrics_history, reminders, sentry, warmer, sysadmin_report +from services import metrics_history, reminders, sentry, warmer from starlette.requests import Request # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration @@ -66,11 +65,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: if sentry.ENABLED: tasks.append(asyncio.create_task(sentry.sentry_loop())) tasks.append(asyncio.create_task(reminders.reminders_loop())) - # Einmal am Tag (08:00) Telegram Report als Sysadmin - tasks.append(asyncio.create_task(sysadmin_report.report_loop())) - if ketten_digest.ENABLED: - # Werkstatt-Status aufs Handy: Fragen sofort, Meilensteine sofort, sonst 60-min-Puls. - tasks.append(asyncio.create_task(ketten_digest.digest_loop())) # 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert. tasks.append(asyncio.create_task(metrics_history.sampler_loop())) # Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client @@ -112,7 +106,7 @@ app.include_router(health.router) app.include_router(models.router) app.include_router(routing.router) app.include_router(system.router) -app.include_router(sysadmin.router) + app.include_router(connect.router) app.include_router(agent.router) app.include_router( diff --git a/backend/routers/sysadmin.py b/backend/routers/sysadmin.py deleted file mode 100644 index 39c5cb3..0000000 --- a/backend/routers/sysadmin.py +++ /dev/null @@ -1,10 +0,0 @@ -from fastapi import APIRouter -from services import sysadmin_report - -router = APIRouter(prefix="/api") - -@router.post("/sysadmin/report/trigger") -async def trigger_sysadmin_report() -> dict: - """Stößt den Sysadmin-Telegram-Report sofort manuell an.""" - success = await sysadmin_report.generate_and_send_report() - return {"ok": success, "message": "Report wurde angestoßen und via Telegram versendet." if success else "Fehler beim Versenden des Reports."} diff --git a/backend/services/auftragsbuch.py b/backend/services/auftragsbuch.py index 0dd9098..71caac8 100644 --- a/backend/services/auftragsbuch.py +++ b/backend/services/auftragsbuch.py @@ -26,6 +26,7 @@ import re import shutil import subprocess import time +import urllib.request from pathlib import Path from config import MODELS_DIR @@ -60,6 +61,46 @@ _KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$") _fetch_cache: dict = {} _FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen +_CI_CACHE: dict = {} # { (repo, branch, head_sha): {"status": "success", "ts": time} } + +def _gitea_creds() -> tuple | None: + try: + cred = Path("~/.git-credentials").expanduser() + for line in cred.read_text(encoding="utf-8").splitlines(): + m = re.match(r"https://([^:]+):([^@]+)@git\.tobisniceshomelab", line.strip()) + if m: return m.group(1), m.group(2) + except Exception: + pass + return None + +def _fetch_ci_status(repo: str, branch: str, head_sha: str) -> str | None: + # Nur auf der Box (wo creds liegen) sinnvoll + creds = _gitea_creds() + if not creds: return None + user, token = creds + cache_key = (repo, branch, head_sha) + cached = _CI_CACHE.get(cache_key) + if cached and (time.time() - cached["ts"] < 30 or cached["status"] in ("success", "failure", "skipped")): + return cached["status"] + + full_repo = "Hitonabi/mission-control-v2" if repo == "mc2" else "Hitonabi/lucy" + url = f"http://192.168.178.153:3000/api/v1/repos/{full_repo}/actions/runs?branch={urllib.parse.quote(branch)}&limit=1" + try: + req = urllib.request.Request(url, headers={"Authorization": "token " + token}) + with urllib.request.urlopen(req, timeout=5) as r: + data = json.load(r) + runs = data if isinstance(data, list) else data.get("runs", data.get("workflow_runs", [])) + if runs: + run = runs[0] + if run.get("head_sha", "").startswith(head_sha[:7]): + st = run.get("status") + if st: + _CI_CACHE[cache_key] = {"status": st, "ts": time.time()} + return st + except Exception as e: + log.warning("auftragsbuch: CI-Status fetch fehler: %s", e) + return None + def _available() -> bool: @@ -176,6 +217,10 @@ def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]: stempel = gutachten.get(f"{repo}:{branch}") if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val): stempel = None + + head_sha = (_git(repo, ["rev-parse", ref]).stdout or "").strip() + ci_status = _fetch_ci_status(repo, branch, head_sha) if head_sha else None + stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip() files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines() files = [] @@ -209,6 +254,7 @@ def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]: # es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo). "frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist, "status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status, + "ci_status": ci_status, }) except Exception: log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True) diff --git a/backend/services/jobengine.py b/backend/services/jobengine.py deleted file mode 100644 index cfda835..0000000 --- a/backend/services/jobengine.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt. -Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job. -""" - -import glob -import os -import shlex -import subprocess -import threading -import time -import uuid - -JOBS: dict[str, dict] = {} -_PROCS: dict[str, subprocess.Popen] = {} -_LOG_CAP = 400 - - -def _append_log(job: dict, line: str) -> None: - job["log"].append(line) - if len(job["log"]) > _LOG_CAP: - del job["log"][0] - - -def _pump_output(job: dict, stream) -> None: - """Liest byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile.""" - buf = b"" - overwrite = False - pending_cr = False - - def commit(): - line = buf.decode("utf-8", "replace") - if overwrite and job["log"]: - job["log"][-1] = line - else: - _append_log(job, line) - - while True: - ch = stream.read(1) - if not ch: - break - if pending_cr: - pending_cr = False - if ch == b"\n": - commit(); overwrite = False; buf = b"" - continue - commit(); overwrite = True; buf = b"" - if ch == b"\r": - pending_cr = True - elif ch == b"\n": - commit(); overwrite = False; buf = b"" - else: - buf += ch - if pending_cr: - commit(); overwrite = True; buf = b"" - if buf: - commit() - - -def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None): - job = JOBS[job_id] - job["state"] = "running" - try: - actual_args = list(args) - if sudo_password is not None: - for i, arg in enumerate(actual_args): - if isinstance(arg, str): - actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ") - - proc = subprocess.Popen( - actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - stdin=subprocess.PIPE if sudo_password is not None else None, - bufsize=0, - env={**os.environ, **(env or {})}, - ) - _PROCS[job_id] = proc - - if sudo_password is not None and proc.stdin: - proc.stdin.write((sudo_password + "\n").encode("utf-8")) - proc.stdin.flush() - proc.stdin.close() - - _pump_output(job, proc.stdout) - proc.wait() - job["returncode"] = proc.returncode - job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed") - - # Check if failed due to sudo authorization failure - if proc.returncode != 0 and job["log"]: - log_str = "\n".join(job["log"]) - if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str: - job["sudo_failed"] = True - except Exception as exc: - _append_log(job, f"[mc] Fehler: {exc}") - job["state"] = "failed" - job["returncode"] = -1 - finally: - _PROCS.pop(job_id, None) - job["finished_at"] = time.time() - cb = job.pop("_on_done", None) - if cb and job["state"] == "done": - try: - cb() - except Exception as exc: - _append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}") - - -def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None: - """Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie).""" - if not total_bytes or total_bytes <= 0: - return - job = JOBS.get(job_id) - if job is not None: - job["progress"] = 0 - job["total_bytes"] = total_bytes - - def _watch(): - pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete") - prev_t = prev_b = None - rate = 0.0 - while True: - j = JOBS.get(job_id) - if not j or j["state"] in ("done", "failed", "canceled"): - break - try: - inc = glob.glob(pat, recursive=True) - cur = sum(os.path.getsize(f) for f in inc) if inc else 0 - if cur: - j["progress"] = min(99, int(cur * 100 / total_bytes)) - j["done_bytes"] = cur - now = time.time() - if prev_t is not None and now > prev_t and cur >= prev_b: - inst = (cur - prev_b) / (now - prev_t) - rate = inst if rate == 0 else 0.3 * inst + 0.7 * rate - if rate > 0: - j["rate_bps"] = rate - j["eta_s"] = int((total_bytes - cur) / rate) - prev_t, prev_b = now, cur - except Exception: - pass - time.sleep(1.0) - j = JOBS.get(job_id) - if j and j["state"] == "done": - j["progress"] = 100 - j.pop("eta_s", None) - - threading.Thread(target=_watch, daemon=True).start() - - -def start_job(args: list[str], label: str, env: dict | None = None, on_done=None, - sudo_password: str | None = None, group: str | None = None) -> str: - job_id = uuid.uuid4().hex[:12] - # Mask password in log if present in args - log_args = list(args) - JOBS[job_id] = { - "id": job_id, "label": label, "state": "queued", "group": group, - "log": ["$ " + " ".join(shlex.quote(a) for a in log_args)], - "returncode": None, "started_at": time.time(), "finished_at": None, - } - if on_done: - JOBS[job_id]["_on_done"] = on_done - threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start() - return job_id - - -def active_in_group(group: str) -> dict | None: - """Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None. - Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig.""" - for j in JOBS.values(): - if j.get("group") == group and j.get("state") in ("running", "queued"): - return j - return None - - -def cancel_job(job_id: str) -> bool: - job = JOBS.get(job_id) - if not job or job["state"] in ("done", "failed", "canceled"): - return False - job["canceled"] = True - _append_log(job, "[mc] Abbruch angefordert…") - proc = _PROCS.get(job_id) - if proc is not None: - try: - proc.terminate() - except Exception: - pass - else: - job["state"] = "canceled" - job["finished_at"] = time.time() - return True - - -def public_jobs() -> list[dict]: - """Jobs ohne interne Felder (_on_done) für die API.""" - return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()] diff --git a/backend/services/ketten_digest.py b/backend/services/ketten_digest.py deleted file mode 100644 index 84e145e..0000000 --- a/backend/services/ketten_digest.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -Ketten-Digest — Telegram-Status für lange Aufgaben-Ketten (User-Wunsch 20.07.2026). - -Wenn die Werkstatt eine Karten-Familie abarbeitet (z. B. „Homelab-Dashboard 2.0" mit -19 verketteten Karten), soll der Commander nicht raten müssen: dieser Wächter liest -die Queue-Sicht (services/ideen.py, inkl. Ketten-Anreicherung) und meldet per Telegram - - • SOFORT, wenn eine Karte hängt und eine Frage an den Commander hat (blocked) — - einmal pro Karte, mit der Frage im Wortlaut, - • bei MEILENSTEINEN (Projekt komplett fertig) sofort, - • sonst höchstens einmal pro PULS_SEKUNDEN (Default 60 min) einen Zwischenstand - je aktivem Projekt („5/19 fertig · läuft: … · als Nächstes: …"). - -Taktung war explizite User-Wahl („Meilensteine + 60-min-Puls, Fragen immer sofort"). -Zustand (was wurde wann gemeldet) liegt als JSON neben den Modellen und übersteht -Neustarts — sonst käme nach jedem Deploy ein Duplikat-Schwall. Auf Windows (Dev) No-op. -""" - -import asyncio -import json -import logging -import os -import time -import zlib -from pathlib import Path - -from config import MODELS_DIR - -log = logging.getLogger(__name__) - -ENABLED = os.name == "posix" and os.environ.get("MC_KETTEN_DIGEST", "1") != "0" -INTERVAL = int(os.environ.get("MC_KETTEN_DIGEST_INTERVAL", "300")) # Prüf-Tick: 5 min -PULS_SEKUNDEN = int(os.environ.get("MC_KETTEN_DIGEST_PULS", "3600")) # Zwischenstand: 60 min -# Hänger-Alarm: laufende Karte ohne Lebenszeichen (Heartbeat) länger als diese Schwelle -# → sofortige Telegram-Warnung (einmal pro Karten-Lauf). Live-Fall 20.07.: Worker wartete -# 45 min auf einen selbst gestarteten uvicorn — „pid_alive" hielt den Claim ewig frisch. -HANG_SEKUNDEN = int(os.environ.get("MC_KETTEN_HANG", "900")) -KANBAN_LOGS = Path(os.environ.get("MC_KANBAN_LOGS", "~/.hermes/kanban/logs")).expanduser() -STATE_PATH = Path(os.environ.get("MC_KETTEN_DIGEST_STATE", str(MODELS_DIR / "mc2-ketten-digest.json"))) - - -def _kontext_druck(task_id: str) -> int: - """Wie oft der Worker zuletzt den Kontext verdichten musste (Log-Marker) — - mehrfaches „Compacting context" heißt: die Aufgabe sprengt das Fenster.""" - try: - p = KANBAN_LOGS / f"{task_id}.log" - with p.open("rb") as f: - f.seek(max(0, p.stat().st_size - 200_000)) - tail = f.read().decode("utf-8", errors="replace") - return tail.count("Compacting context") - except Exception: - return 0 - - -def _load_state() -> dict: - try: - d = json.loads(STATE_PATH.read_text(encoding="utf-8")) - return d if isinstance(d, dict) else {} - except Exception: - return {} - - -def _save_state(state: dict) -> None: - try: - tmp = STATE_PATH.with_suffix(".tmp") - tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") - tmp.replace(STATE_PATH) - except OSError: - log.warning("ketten-digest: Zustand %s nicht schreibbar", STATE_PATH, exc_info=True) - - -def _melden(subject: str, text: str) -> None: - """Aufs Handy UND in den Briefkasten (silent — Lucy muss den Status nicht sprechen).""" - from services import announce - announce.notify_telegram(subject, text) - try: - announce.add(text, subject, "ketten-digest", "silent") - except Exception: - pass - - -def _tick() -> None: - from services import ideen - - data = ideen.list_queue() - if not data.get("available"): - return - items = data.get("items") or [] - projekte = data.get("projekte") or [] - state = _load_state() - gemeldete_fragen: dict = state.setdefault("fragen", {}) - projekt_state: dict = state.setdefault("projekte", {}) - dirty = False - now = int(time.time()) - - # 1) Hängende Karten mit Frage → sofort, einmal pro Karte. Der Schlüssel enthält den - # Fragen-Text-Hash: hängt dieselbe Karte später mit NEUER Frage, wird wieder gemeldet. - for it in items: - if it.get("status") != "blocked": - continue - frage = (it.get("frage") or "").strip() - key = f"{it['id']}:{zlib.crc32(frage.encode('utf-8')):x}" - if key in gemeldete_fragen: - continue - text = (f"Aufgabe hängt und wartet auf dich: „{(it.get('titel') or '')[:120]}“\n" - + (f"Frage: {frage[:500]}\n" if frage else "") - + "Antworten geht im Auftragsbuch (Zentrale) — die Box macht dann weiter.") - _melden("[Werkstatt]", text) - gemeldete_fragen[key] = now - dirty = True - - # 1b) Hänger-Alarm: laufende Karte ohne Lebenszeichen → einmal pro Karten-Lauf warnen. - gemeldete_haenger: dict = state.setdefault("haenger", {}) - for it in items: - if it.get("status") != "running": - continue - puls = it.get("puls_alter") - if puls is None or puls < HANG_SEKUNDEN: - continue - key = f"{it['id']}:{it.get('gestartet') or 0}" - if key in gemeldete_haenger: - continue - minuten = int(puls / 60) - text = (f"Karte scheint zu hängen: „{(it.get('titel') or '')[:120]}“ — " - f"seit {minuten} min kein Lebenszeichen vom Worker.") - druck = _kontext_druck(it["id"]) - if druck >= 2: - text += (f"\nDer Worker musste {druck}× den Kontext verdichten — " - "die Aufgabe ist womöglich zu groß geschnitten.") - text += "\nIn der Zentrale: „Neuer Versuch“ startet sie frisch, „Stopp“ hält sie an." - _melden("[Werkstatt]", text) - gemeldete_haenger[key] = now - dirty = True - if len(gemeldete_haenger) > 200: - for k in sorted(gemeldete_haenger, key=gemeldete_haenger.get)[:100]: - gemeldete_haenger.pop(k, None) - dirty = True - - # Fragen-Gedächtnis klein halten (Karten verschwinden irgendwann ins Archiv). - if len(gemeldete_fragen) > 200: - for k in sorted(gemeldete_fragen, key=gemeldete_fragen.get)[:100]: - gemeldete_fragen.pop(k, None) - dirty = True - - # 2) Projekt-Status: Abschluss sofort, sonst gedrosselter Puls solange gearbeitet wird. - for p in projekte: - ps = projekt_state.setdefault(p["key"], {}) - fertig, gesamt = p.get("fertig", 0), p.get("gesamt", 0) - - if gesamt > 0 and fertig >= gesamt and not ps.get("abschluss_gemeldet"): - _melden("[Werkstatt]", f"Projekt fertig: „{p['titel']}“ — alle {gesamt} Karten erledigt. " - "Ergebnisse liegen im Auftragsbuch.") - ps.update(abschluss_gemeldet=True, letzter_puls=now, letzter_stand=fertig) - dirty = True - continue - - aktiv = p.get("laeuft", 0) > 0 - if not aktiv: - continue - if now - int(ps.get("letzter_puls") or 0) < PULS_SEKUNDEN: - continue - laufende = [i for i in items if i.get("projekt") == p["key"] and i["status"] == "running"] - naechste = [i for i in items if i.get("projekt") == p["key"] - and i["status"] in ("ready", "todo") and not i.get("wartet_auf")] - zeilen = [f"Werkstatt-Status „{p['titel']}“: {fertig}/{gesamt} fertig."] - for l in laufende[:2]: - note = f" — {l['notiz']}" if l.get("notiz") else "" - zeilen.append(f"Läuft: {l['titel'][:90]}{note}") - if naechste: - zeilen.append(f"Als Nächstes: {naechste[0]['titel'][:90]}") - haengt = p.get("haengt", 0) - if haengt: - zeilen.append(f"{haengt} Karte(n) warten auf deine Antwort im Auftragsbuch.") - _melden("[Werkstatt]", "\n".join(zeilen)) - ps.update(letzter_puls=now, letzter_stand=fertig) - dirty = True - - if dirty: - _save_state(state) - - -async def digest_loop() -> None: - """Hintergrund-Task im MC2-Lifespan (Muster: reminders_loop).""" - log.info("ketten-digest: aktiv (Tick %ss, Puls %ss)", INTERVAL, PULS_SEKUNDEN) - while True: - try: - await asyncio.to_thread(_tick) - except Exception: - log.debug("ketten-digest: Tick fehlgeschlagen", exc_info=True) - await asyncio.sleep(INTERVAL) diff --git a/backend/services/sysadmin_report.py b/backend/services/sysadmin_report.py deleted file mode 100644 index 52b9779..0000000 --- a/backend/services/sysadmin_report.py +++ /dev/null @@ -1,178 +0,0 @@ -import asyncio -import json -import logging -import psutil -import time -from datetime import datetime, timezone -import httpx - -from services import maintenance, announce, llamaswap, auftragsbuch, discover -from config import V1_UPSTREAM - -log = logging.getLogger(__name__) - -# Wenn aktiv, wird der Report um die angegebene Stunde (0-23) lokaler Zeit verschickt -SCHEDULE_HOUR = 8 - -async def fetch_tech_news() -> str: - """Holt die Top 3 Tech-News von HackerNews als Futter.""" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - top_ids_resp = await client.get("https://hacker-news.firebaseio.com/v0/topstories.json") - top_ids = top_ids_resp.json()[:3] - news_items = [] - for item_id in top_ids: - item_resp = await client.get(f"https://hacker-news.firebaseio.com/v0/item/{item_id}.json") - item = item_resp.json() - title = item.get('title', 'Ohne Titel') - url = item.get('url', f"https://news.ycombinator.com/item?id={item_id}") - news_items.append(f"- {title} ({url})") - return "\n".join(news_items) if news_items else "Keine News gefunden." - except Exception as e: - log.warning(f"Fehler beim Abrufen der News: {e}") - return "News konnten nicht abgerufen werden." - -async def generate_and_send_report() -> bool: - """Sammelt System-Status, fragt das lokale LLM nach einer Zusammenfassung als Lucy und sendet via Telegram.""" - log.info("Sysadmin-Report gestartet...") - - # 1. Daten sammeln - ram = psutil.virtual_memory() - ram_gb_total = round(ram.total / (1024**3), 1) - ram_gb_used = round(ram.used / (1024**3), 1) - cpu_percent = psutil.cpu_percent(interval=1) - disk = psutil.disk_usage('/') - disk_gb_free = round(disk.free / (1024**3), 1) - - try: - upd = maintenance.updates() - except Exception: - upd = {"os": 0, "engine": False, "swap": False, "hermes": False} - - running_models = llamaswap.get_running_models() - - # News - news_text = await fetch_tech_news() - - # Discover: Bessere Modelle - disc_data = discover.safe_discover(ram_gb_total) - rec_models = [] - if disc_data and "categories" in disc_data: - for c in disc_data["categories"]: - if c.get("recommended"): - rec_models.append(c["recommended"]) - rec_str = ", ".join(rec_models[:3]) if rec_models else "Keine neuen Empfehlungen" - - # Auftragsbuch - try: - auftraege_data = auftragsbuch.list_proposals() - open_count = auftraege_data.get("open_count", 0) - auftraege_str = f"{open_count} offene Aufgaben (Karten), die auf dich warten." if open_count > 0 else "Das Auftragsbuch ist leer (Keine offenen Aufgaben)." - except Exception as e: - log.warning(f"Fehler im Sysadmin Report bei Auftragsbuch: {e}") - auftraege_str = "Fehler beim Lesen des Auftragsbuchs." - - events = announce.list_recent(limit=15) - event_lines = [] - for e in events: - ts_str = datetime.fromtimestamp(e["ts"]).strftime("%d.%m. %H:%M") - text = e.get('text', '') - if len(text) > 100: - text = text[:100] + "..." - event_lines.append(f"- [{ts_str}] {e.get('subject', '')}: {text}") - - events_str = "\n".join(event_lines) if event_lines else "Keine besonderen Vorkommnisse." - - evidence = f""" -Hardware: RAM {ram_gb_used}/{ram_gb_total} GB ({ram.percent}%), CPU {cpu_percent}%, Speicher {disk_gb_free} GB frei. -Laufende Modelle: {', '.join(running_models) if running_models else 'Keine'} -Updates anstehend: OS: {upd.get('os', 0)}, Engine: {upd.get('engine', False)}, Swap: {upd.get('swap', False)}, Hermes: {upd.get('hermes', False)} -Offene Freigaben für Commander (Auftragsbuch): {auftraege_str} -Modell-Empfehlungen für diese Box: {rec_str} - -Heutige Tech-News: -{news_text} - -Kürzliche Ereignisse (Chronik): -{events_str} -""" - - prompt = f"""Du bist Lucy, die KI-Sysadmin der Mission-Control-Box (einem lokalen AI-Stack). -Du KENNST die Box in- und auswendig. Dein Boss braucht einen gehaltvollen, klugen Morgen-Bericht via Telegram. - -Regeln: -1. Wie geht's der Box? (Kurzer Hardware Check, check ob Modelle laufen). -2. News: Übersetze die Tech-News kurz auf Deutsch und übernimm IMMER die Quellen-URLs in deine Nachricht! -3. Updates: Sind welche offen? -4. Modelle & Aufgaben: Empfiehl nur Modelle, die nicht ohnehin schon laufen. Erinnere den Commander an offene Aufgaben im Auftragsbuch. -5. Benutze Emojis, mach es lesbar (Bulletpoints). -6. Chronik nur erwähnen, wenn es Probleme gab (sonst weglassen). -7. Antworte DIREKT mit dem Text der Nachricht (keine Einleitung). -8. SEI INFORMATIV, professionell-frech und zeige, dass du das System verstehst. - -Rohe Daten: -{evidence} -""" - - endpoint = "http://127.0.0.1:9001/v1/chat/completions" # Internes Gateway für model: auto - - # Da das Gateway (Router) auf :9010 lauscht, können wir auch das nutzen, - # aber Port 8080 (llama-swap engine) ist der direkteste Weg für den internen LLM-Call. - model = "gpt-oss-120b" # Fallback, falls auto nicht geht, llama-swap routet das meist passend - - req_body = { - "model": "auto", - "messages": [ - {"role": "system", "content": "Du bist Lucy, KI-Sysadmin der Box."}, - {"role": "user", "content": prompt} - ], - "max_tokens": 1000, - "temperature": 0.4 - } - - digest = "" - try: - async with httpx.AsyncClient(timeout=180.0) as client: - resp = await client.post(endpoint, json=req_body) - resp.raise_for_status() - data = resp.json() - digest = data["choices"][0]["message"].get("content", "").strip() - except Exception as e: - log.error(f"Fehler beim LLM-Aufruf für Sysadmin-Report: {e}") - # Fallback, falls LLM nicht erreichbar ist - digest = f"🤖 [LLM offline] Hier sind die rohen Daten:\n{evidence}" - - if digest: - announce.notify_telegram("[🌅 Morgen-Digest]", digest) - log.info("Sysadmin-Report via Telegram versendet.") - return True - return False - - -async def report_loop() -> None: - """Täglicher Background-Task für den Sysadmin-Report um SCHEDULE_HOUR Uhr.""" - log.info("Sysadmin-Report Background-Loop gestartet.") - while True: - try: - now = datetime.now() - # Finde die Zeit bis zum nächsten SCHEDULE_HOUR:00 - target_hour = SCHEDULE_HOUR - if now.hour >= target_hour: - # Nächster Tag - seconds_until = ((24 - now.hour - 1) * 3600) + ((60 - now.minute - 1) * 60) + (60 - now.second) + (target_hour * 3600) - else: - # Selber Tag - seconds_until = ((target_hour - now.hour - 1) * 3600) + ((60 - now.minute - 1) * 60) + (60 - now.second) - - log.info(f"Sysadmin-Report: Warte {seconds_until} Sekunden bis zum nächsten Report.") - await asyncio.sleep(seconds_until) - - await generate_and_send_report() - - # Warte kurz, um nicht sofort wieder zu triggern, falls es extrem schnell ging - await asyncio.sleep(60) - except asyncio.CancelledError: - break - except Exception as e: - log.error(f"Unerwarteter Fehler im Sysadmin-Report-Loop: {e}", exc_info=True) - await asyncio.sleep(300) diff --git a/deploy/ampel-waechter.sh b/deploy/ampel-waechter.sh index 35dbe31..a7d4ad1 100644 --- a/deploy/ampel-waechter.sh +++ b/deploy/ampel-waechter.sh @@ -12,7 +12,7 @@ TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null | [ -n "$TOKEN" ] || exit 0 python3 - "$TOKEN" "$STATE" <<'PY' -import json, sys, urllib.request +import json, sys, urllib.request, subprocess, os token, state_pfad = sys.argv[1], sys.argv[2] # API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026); @@ -79,6 +79,14 @@ if neu: for full, sha, name in neu: print(f"• {full} @ {sha} ({name})") print(f" {WEB}/{full}/actions") + try: + subprocess.run([ + os.path.expanduser("~/.local/bin/hermes"), "kanban", "create", + "--title", f"CI rot: {full} @ {sha}", + "--body", f"Der CI Lauf '{name}' in {full} ist fehlgeschlagen. Bitte Log ansehen und Fix vorschlagen." + ], capture_output=True) + except Exception as e: + print(f"Fehler bei kanban_create: {e}", file=sys.stderr) print("Nichts aus diesen Ständen ist ‚fertig', solange die Ampel rot ist.") with open(state_pfad, "w") as f: diff --git a/deploy/governor/CONVENTIONS.md b/deploy/governor/CONVENTIONS.md deleted file mode 100644 index e16a0bf..0000000 --- a/deploy/governor/CONVENTIONS.md +++ /dev/null @@ -1,33 +0,0 @@ -# Arbeitsregeln (Aider liest diese Datei als schreibgeschützten Kontext) - -Diese Regeln gelten für JEDE Sitzung. Sie sind der Kern der Session-Hygiene: -Wissen lebt in `SAVEPOINT.md` und in der git-Historie, NICHT im Chat-Verlauf. - -## 1. Zu Beginn: erst SAVEPOINT.md lesen -Bevor du irgendetwas tust, lies `SAVEPOINT.md` vollständig. Es ist die Wahrheit über -den aktuellen Stand. Richte dich danach — nicht nach Annahmen. Erfinde keinen Kontext, -der nicht in `SAVEPOINT.md`, im Code oder in der git-Historie steht. Wenn etwas unklar -ist, sag es, statt es zu erfinden. - -## 2. Nach jedem sinnvollen Schritt: SAVEPOINT.md aktualisieren -Sobald du eine sinnvolle Änderung abgeschlossen hast (eine Funktion, ein Fix, ein -Testlauf), aktualisiere `SAVEPOINT.md`. Halte es kurz und ehrlich. Struktur: - -- **Ziel** — was insgesamt gebaut werden soll (ein bis zwei Sätze). -- **Erledigt** — was jetzt wirklich funktioniert (nur Bewiesenes; keine Fassade). -- **Nächster Schritt** — die genau eine Sache, die als Nächstes zu tun ist. -- **Offene Fragen** — Entscheidungen, die noch anstehen. -- **Stolpersteine** — alles, worüber eine frische Sitzung sonst stolpern würde. -- **Dateien** — die wichtigsten Dateien und was sie enthalten. - -Schreibe es so, dass eine frische Sitzung OHNE jede Erinnerung allein aus `SAVEPOINT.md` -plus git sauber weitermachen kann. Das ist der Test: kein verstecktes Wissen im Chat. - -## 3. Kleine, überprüfbare Schritte -Ändere wenig pro Runde. Behaupte nichts als fertig, was du nicht geprüft hast. Wenn ein -Test existiert, nenne sein Ergebnis. Wenn du unsicher bist, prüfe, statt zu raten. - -## 4. Wenn der Governor das Sitzungs-Limit meldet -Erscheint eine Nachricht mit `[GOVERNOR — SITZUNGS-LIMIT ERREICHT]`, dann beginne KEINE -neuen Code-Änderungen mehr. Finalisiere nur `SAVEPOINT.md` (Stand vollständig, nächster -Schritt präzise) und weise den Nutzer an, eine frische Sitzung zu starten. Sonst nichts. diff --git a/deploy/governor/README.md b/deploy/governor/README.md deleted file mode 100644 index 0da6c58..0000000 --- a/deploy/governor/README.md +++ /dev/null @@ -1,134 +0,0 @@ -# Governor — Phase 0 - -Dünner, zustandsloser Token-Wächter-Proxy zwischen einem Off-the-shelf-Coding-Agenten -(**Aider**) und dem lokalen Modell-Endpoint (llama-swap `:8080`). Er erzwingt -**Session-Hygiene per hartem Schnitt statt Auto-Compaction**: wenn die Anfrage (= ganze -Sitzungshistorie, die jede Runde mitkommt) eine Schwelle übersteigt, schiebt er eine -Anweisung ein, `SAVEPOINT.md` zu finalisieren und zu stoppen — damit Wissen in -`SAVEPOINT.md` + git lebt, nicht im degradierenden Chat-Kontext. - -Das ist **Phase 0** des Ablöse-Plans „Lucy IDE-Modus + Governor": den Kern beweisen, -**ohne** eine Zeile Lucy-Code. Kein bespoke Editor, kein Aider-Fork, kein Modelltausch. - -## Bausteine (dieses Verzeichnis) -| Datei | Zweck | -|---|---| -| `governor.py` | Der Proxy. Nur Standardbibliothek (läuft mit System-`python3`), zustandslos. | -| `CONVENTIONS.md` | Aiders schreibgeschützte Arbeitsregeln: SAVEPOINT.md zuerst lesen, laufend pflegen, keine Fassade. | -| `SAVEPOINT.template.md` | Anfangs-Savepoint für ein frisches Projekt. | -| `driver.py` | Treibt eine akkumulierende Aider-Sitzung über die Scripting-API (eine Zeile = eine Runde). | -| `gov-ctl.sh` | Governor sauber starten/stoppen/status (detached via `setsid`). | -| `run-driver.sh` | `run-driver.sh [repo]` — Aider-Sitzung durch den Governor. | -| `reset-repo.sh` | Wegwerf-Test-Repo frisch aufsetzen. | -| `hardstop-test.sh` | Direkte curls für die drei Pfade (passthrough / soft / hart). | -| `msgs-*.txt` | Nachrichtenskripte für die Testläufe. | - -## Verhalten des Governors -Pro `/v1/chat/completions`-Anfrage schätzt er die Tokenzahl (`Zeichen / GOV_CHARS_PER_TOKEN`, -kalibriert auf CPT **3.5** → `est ≈ echte prompt_tokens` auf ~1–3 % bei echten Sessions): - -- **est < Soft** → unverändert durchreichen. -- **est ≥ Soft (`GOV_THRESHOLD`, Default 25000)** → hängt die SAVEPOINT-Stopp-Anweisung als - letzte User-Nachricht an, leitet weiter, loggt `FIRED`. Das Modell schreibt EINEN - ehrlichen Abschluss-Savepoint. -- **est ≥ Hart (`GOV_HARD_CEILING`, Default 0 = aus)** → der Governor antwortet SELBST mit - einer kurzen Stopp-Nachricht, **ohne** das Modell zu fragen; loggt `HARDSTOP`. Verhindert, - dass über die Grenze hinaus weitergearbeitet wird (siehe Befund unten). - -Alle anderen Pfade (`/v1/models` etc.) werden roh durchgereicht. Streaming (SSE) wird -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` (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 (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 -``` -Aider zeigt mit `OPENAI_API_BASE=http://127.0.0.1:8100/v1` und Modell -`openai/Qwen3-Coder-Next` auf den Governor. - -## Wichtig: Aiders eigene Zusammenfassung MUSS aus -Der Treiber setzt `coder.summarizer.max_tokens` auf ~1e9. Sonst fasst Aider die Historie -selbst zusammen (Auto-Compaction) und die Anfrage wächst nie bis zur Schwelle — der -Governor wäre ausgehebelt, und man bekäme genau die über-komprimierte Halluzination, die -der Plan verwirft. Der Governor soll die **alleinige** Sitzungsgrenze sein. - -## Ergebnisse & Befunde (24.07.2026) - -### Akzeptanz — alle vier Kriterien bewiesen (Box, Qwen3-Coder-Next) -1. **Governor zählt + feuert an der Schwelle** — 12-Runden-Todo-Lauf (Soft 8000): Runden 1-6 - `ok`, ab Runde 7 `FIRED` (est 8546 / echt 8652). Hart-Deckel: Anfrage mit est 11429 ≥ 10000 - → `HARDSTOP`, Governor antwortet selbst (kein Modell-Call). Alles im Log. -2. **Aider pflegt SAVEPOINT.md** — über den ganzen Aufbau hinweg strukturiert gehalten - (Ziel/Erledigt/Nächster Schritt/Stolpersteine/Dateien) gemäß `CONVENTIONS.md`. -3. **An der Grenze: ehrlicher Abschluss + Stopp** — beim ersten Feuern (Runde 7) schrieb das - Modell einen **ehrlichen** Savepoint (nur real Gebautes unter „Erledigt", Tests als nächster - Schritt) und verweigerte neuen Code. -4. **Frische Sitzung macht sauber weiter — keine Fassade** — neue Aider-Sitzung las den - Grenz-Savepoint, baute `test_todo.py` (der exakte nächste Schritt), und **alle 9 unittest- - Tests laufen grün** gegen die echte API. Kein Erfinden. - -### Kalibrierung -`CHARS_PER_TOKEN = 3.5` → `est` traf die echten `prompt_tokens` bei realen Sessions auf ~1-3 %. -(Nur künstlicher, extrem repetitiver Fülltext bricht die Heuristik — irrelevant für echten Code.) -Der Coder läuft mit 128k Kontext (`-c 131072`), also keine Modell-Kappung bei 25k. - -### Wichtigster Befund: Soft reicht nicht allein → Hart-Deckel nachgerüstet -Der **weiche** Schnitt erzeugt genau EINEN ehrlichen Grenz-Savepoint — solange die Grenze -respektiert wird. Schickt man aber über die Grenze hinaus weiter Aufträge (wie im Stresstest), -verweigert das Modell zwar den Code, schreibt die Absichten aber fortschreitend als „erledigt" -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. **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. 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 - nicht). Für Einzel-Runden `aider --message` nutzen (sauberer, unterstützt). Der `driver.py` - taugt für Mehr-Runden-Akkumulation (Governor-Test), nicht als Produktions-Treiber. - -### Bekannte Grenzen des Governors (aus adversarialer Review, für später) -- **Chunked Request-Bodies ohne `Content-Length`** werden verworfen (Aiders httpx sendet immer - `Content-Length` → schlummernd, aber ein Proxy-Hop mit Chunking bräche). -- **Tool-/Function-Calling**: der Soft-Einschub als letzte `user`-Nachricht kann die Nachrichten- - reihenfolge stören, wenn Aider ein Tool-Calling-Edit-Format nutzt (Aiders diff/whole sind reiner - Text → schlummernd). `estimate_tokens` zählt `tools`/`tool_calls` nicht mit. -- **`https://`-Upstream** wird nicht unterstützt (nur `http.client.HTTPConnection`). Für den - lokalen `:8080`-Endpoint irrelevant. -Behoben aus derselben Review: **inkrementelles Streaming** (`read1()` statt `read()` — vorher -puffernd), **Config-Crash** bei `{ }` in `GOV_DIRECTIVE` (sichere Substitution), **Query-String** -umging die Erkennung, **Socket-Leak** im Fehlerpfad, doppelte `Date`/`Server`-Header. - -## Nächste Schritte (Phase 1+) -Terminal in Lucy einbetten (xterm.js + node-pty, MC2-Muster kopieren) → Voice-Hook auf das -Governor-Signal → Feinschliff + Aider/Pi-Finalentscheid. Governor evtl. später in mc2-gateway. -Kandidat für Phase 1-Härtung: Auto-Commit-Zügelung + Hart-Deckel als Default. diff --git a/deploy/governor/SAVEPOINT.template.md b/deploy/governor/SAVEPOINT.template.md deleted file mode 100644 index 686616e..0000000 --- a/deploy/governor/SAVEPOINT.template.md +++ /dev/null @@ -1,23 +0,0 @@ -# SAVEPOINT - -> Lebende Übergabe-Datei. Die aktuelle Sitzung hält sie fortlaufend aktuell; eine -> frische Sitzung liest sie ZUERST und macht allein daraus plus git weiter. -> (Anfangszustand — von der ersten Sitzung zu ersetzen.) - -## Ziel -_(noch nichts — von der ersten Sitzung zu füllen)_ - -## Erledigt -- _(noch nichts)_ - -## Nächster Schritt -- Auftrag des Nutzers entgegennehmen und beginnen. - -## Offene Fragen -- _(keine)_ - -## Stolpersteine -- _(keine bekannt)_ - -## Dateien -- `SAVEPOINT.md` — diese Übergabe-Datei. diff --git a/deploy/governor/announce-test.sh b/deploy/governor/announce-test.sh deleted file mode 100644 index 7798782..0000000 --- a/deploy/governor/announce-test.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/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/driver.py b/deploy/governor/driver.py deleted file mode 100644 index 36e1268..0000000 --- a/deploy/governor/driver.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -"""driver.py — treibt EINE Aider-Sitzung ueber die Scripting-API durch den Governor. - -Jede Zeile der Nachrichtendatei ist eine User-Runde. Weil derselbe Coder alle Runden -bedient, akkumuliert die Historie und die Anfrage waechst jede Runde — genau das, was -der Governor beobachtet. Aiders EIGENE History-Zusammenfassung wird abgeschaltet, damit -der Governor die alleinige Sitzungsgrenze ist (der Plan verwirft Auto-Compaction bewusst). - -Aufruf (cwd muss das Test-Repo sein, mit venv-python): - .../aider-chat/bin/python driver.py -""" -import os -import sys - -from aider.coders import Coder -from aider.io import InputOutput -from aider.models import Model - - -def main() -> int: - # Endpoint VOR den Aider-Aufrufen setzen (litellm liest es zur Laufzeit). - os.environ.setdefault("OPENAI_API_BASE", "http://127.0.0.1:8100/v1") - os.environ.setdefault("OPENAI_API_KEY", "dummy") - if len(sys.argv) < 2: - print("usage: driver.py ", file=sys.stderr) - return 2 - with open(sys.argv[1], encoding="utf-8") as fh: - messages = [ln.strip() for ln in fh if ln.strip() and not ln.lstrip().startswith("#")] - - model = Model("openai/Qwen3-Coder-Next") - io = InputOutput(yes=True) # alle Rueckfragen automatisch bejahen - coder = Coder.create( - main_model=model, - io=io, - fnames=["SAVEPOINT.md"], # editierbar - read_only_fnames=["CONVENTIONS.md"], # nur-lesbar - auto_commits=True, # jede Etappe -> git-Commit - stream=False, # deterministische Logs fuer P0 - map_tokens=512, - use_git=True, - ) - - # Aiders eigene Zusammenfassung ausschalten: too_big() wird nie wahr. - try: - coder.summarizer.max_tokens = 10 ** 9 - print(f"[driver] summarizer.max_tokens -> {coder.summarizer.max_tokens}", flush=True) - except Exception as exc: - print(f"[driver] WARN konnte summarizer nicht abschalten: {exc}", flush=True) - - for i, msg in enumerate(messages, 1): - print(f"\n===== TURN {i}/{len(messages)} =====", flush=True) - print(f">> {msg[:140]}", flush=True) - try: - coder.run(with_message=msg) - except Exception as exc: - print(f"[driver] TURN {i} Fehler: {exc!r}", flush=True) - break - sent = getattr(coder, "total_tokens_sent", "?") - recv = getattr(coder, "total_tokens_received", "?") - print(f"-- aider kum: gesendet={sent} empfangen={recv}", flush=True) - - print("\n===== SESSION ENDE =====", flush=True) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/deploy/governor/gov-ctl.sh b/deploy/governor/gov-ctl.sh deleted file mode 100644 index 68ae855..0000000 --- a/deploy/governor/gov-ctl.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -# gov-ctl.sh — Governor sauber starten/stoppen (Phase-0-Helfer). -# Nutzung: -# gov-ctl.sh start [SCHWELLE] # startet detached, Default-Schwelle 25000 -# gov-ctl.sh stop -# gov-ctl.sh status -set -u -DIR="$HOME/governor-p0" -PIDF="$DIR/governor.pid" -LOG="$DIR/governor.log" -OUT="$DIR/governor.stdout" - -start() { - stop - local thr="${1:-25000}" - cd "$DIR" - : > "$LOG" - # Voll detachen: eigene Session, alle FDs weg vom Aufrufer. - # 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=${2:-auto}" - ss -tlnp 2>/dev/null | grep ":8100" >/dev/null && echo "listening :8100 OK" || echo "WARN: not listening" -} - -stop() { - pkill -f "$DIR/governor.py" 2>/dev/null || true - [ -f "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null || true - sleep 0.6 - rm -f "$PIDF" -} - -status() { - if pgrep -f "$DIR/governor.py" >/dev/null; then - echo "running pid=$(pgrep -f "$DIR/governor.py" | tr '\n' ' ')" - ss -tlnp 2>/dev/null | grep ":8100" || true - else - echo "not running" - fi -} - -case "${1:-status}" in - start) shift; start "${1:-25000}" "${2:-}" ;; - stop) stop; echo stopped ;; - status) status ;; - *) echo "usage: gov-ctl.sh {start [soft] [hart]|stop|status}"; exit 2 ;; -esac diff --git a/deploy/governor/governor.py b/deploy/governor/governor.py deleted file mode 100644 index 5a37341..0000000 --- a/deploy/governor/governor.py +++ /dev/null @@ -1,568 +0,0 @@ -#!/usr/bin/env python3 -"""Governor v2 — Token-Waechter-Proxy vor dem MC2-Gateway. - -Sitzt zwischen den Coding-Agenten (OpenCode/Zed, Nacht-Laeufe, Hermes-Worker) und dem -MC2-Gateway (:9001). Reicht ALLES unveraendert durch — mit einer Ausnahme bei -/v1/chat/completions: er bestimmt die Groesse der Anfrage (= Sitzungsgroesse, weil die -ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen: - - est >= SOFT: haengt eine Stopp-Anweisung als letzte User-Nachricht an ("SAVEPOINT.md - finalisieren + stoppen") und leitet weiter. Loggt FIRED. - est >= HART: antwortet SELBST mit einer kurzen Stopp-Nachricht, OHNE das Modell zu - fragen. Verhindert Fassaden jenseits der Grenze. Loggt HARDSTOP. - ---- Was v2 gegenueber v0.2 aendert (25.07.2026) --------------------------------------- -1. EHRLICH ZAEHLEN. v0.2 zaehlte nur Text in `messages` und ignorierte `tools`/ - `tool_calls`. Bei werkzeugdichten Agenten lag es um Faktor 3 daneben (gemessen im - eigenen Log: est=3353 exact=10224). v2 zaehlt den GANZEN Anfragekoerper — inklusive - Werkzeug-Schemata, Werkzeug-Aufrufe und Werkzeug-Ergebnisse. -2. SELBST-KALIBRIERUNG. Aus jeder Antwort liest der Governor die echten - `usage.prompt_tokens` und korrigiert damit sein Zeichen-pro-Token-Verhaeltnis — - pro Modell, gleitend. Die Schaetzung wird also im Betrieb immer genauer, statt auf - einem einmal geratenen Wert festzuhaengen. -3. TOOL-CALL-SICHERER EINSCHUB. Der Soft-Einschub wird NUR angehaengt, wenn die - Nachrichtenkette das erlaubt (letzte Nachricht ist nicht ein Assistant mit offenen - tool_calls und keine tool-Antwort). Sonst wartet er auf die naechste Runde. Ohne - diese Pruefung zerbricht der Einschub bei OpenCode die Werkzeug-Reihenfolge. -4. STATUS-ENDPUNKT. GET /governor/status liefert Zaehlerstand, Kalibrierung und die - letzten Laeufe als JSON — Datenquelle fuer die MC2-Oberflaeche, das OpenCode-Plugin - und Lucys `loop_status`. - -Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3. -Bewusst ohne Datenbank: ein kleiner Ring im Speicher, mehr braucht es nicht. - -Konfiguration per Umgebungsvariablen (alle optional): - GOV_PORT Listen-Port (Default 8100) - GOV_HOST Listen-Adresse (Default 0.0.0.0) - GOV_UPSTREAM Ziel (Default http://127.0.0.1:9001) - GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 45000) - GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN) - GOV_CHARS_PER_TOKEN Startwert Zeichen->Token (Default 3.2, danach gelernt) - GOV_CALIBRATE Selbst-Kalibrierung an/aus (Default 1) - GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log) - GOV_DIRECTIVE Text des Soft-Einschubs - GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort - GOV_ANNOUNCE_URL Lucy-Sprach-Signal; "" = aus (Default :9001/api/voice/announce) - GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300) - GOV_ANNOUNCE_TEXT Text des Sprach-Signals - GOV_EXEMPT_MODELS Modelle ohne Schnitt, kommasepariert (Default: hermes,fast,embed, - reranker,vision,scout — Lucys Alltag wird nie unterbrochen) -""" - -import http.client -import json -import os -import re -import threading -import time -from collections import deque -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import urlparse - -# ---- Konfiguration --------------------------------------------------------- - -PORT = int(os.environ.get("GOV_PORT", "8100")) -HOST = os.environ.get("GOV_HOST", "0.0.0.0") -# Ziel ist das MC2-Gateway, NICHT llama-swap direkt: so bleiben MC2s Rollen-Aliase, -# Bild-Weiche und Telemetrie erhalten. Der Governor ist eine Schicht davor, kein Ersatz. -UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:9001") -THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "45000")) -_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.2")) -CALIBRATE = os.environ.get("GOV_CALIBRATE", "1") != "0" -LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log")) - -# Lucys Alltagsmodelle bekommen NIE einen Savepoint-Einschub: sie fuehren Gespraeche, -# keine Bau-Sitzungen. Nur die Coding-Rollen laufen gegen die Schwelle. -_DEFAULT_EXEMPT = "hermes,fast,embed,reranker,vision,scout" -EXEMPT_MODELS = {m.strip().lower() for m in - os.environ.get("GOV_EXEMPT_MODELS", _DEFAULT_EXEMPT).split(",") if m.strip()} - -DEFAULT_DIRECTIVE = ( - "[GOVERNOR — SITZUNGS-LIMIT ERREICHT] Der Kontext dieser Sitzung ist auf ~{est} " - "Tokens gewachsen (Limit {threshold}). Beginne oder setze JETZT KEINE weiteren " - "Code-Aenderungen fort. Stattdessen, in dieser Reihenfolge:\n" - "1. Aktualisiere SAVEPOINT.md so, dass es den aktuellen Stand vollstaendig festhaelt: " - "was WIRKLICH erledigt ist (nur was im Code steht — nichts aus Absicht oder git-" - "Nachrichten ableiten), der genaue naechste Schritt, offene Fragen und alle " - "Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung allein aus " - "SAVEPOINT.md plus git-Historie sauber weitermachen kann.\n" - "2. Halte dann an und sage dem Nutzer in einem Satz, dass er eine frische Sitzung " - "starten soll. Gib ausser der SAVEPOINT.md-Aktualisierung und diesem Hinweis nichts aus." -) -DIRECTIVE = os.environ.get("GOV_DIRECTIVE", DEFAULT_DIRECTIVE) - -DEFAULT_HARDSTOP = ( - "[GOVERNOR — HARTER STOPP] Das Sitzungs-Limit ist ueberschritten und der Savepoint " - "sollte bereits finalisiert sein. Diese Sitzung nimmt keine weiteren Auftraege mehr an. " - "Bitte starte eine FRISCHE Sitzung — sie liest SAVEPOINT.md und die git-Historie und " - "macht sauber weiter. (Keine Code-Aenderung in dieser Antwort.)" -) -HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP) - -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")) -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 - -HOP_BY_HOP = { - "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", - "te", "trailers", "transfer-encoding", "upgrade", -} - -_log_lock = threading.Lock() -_PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)') -_announce_lock = threading.Lock() -_last_announce = 0.0 -_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None - -# ---- Zustand (klein, im Speicher) ------------------------------------------ -# Kalibrierung je Modell: gleitender Mittelwert von zeichen/echte_tokens. Startwert ist -# GOV_CHARS_PER_TOKEN; jede Antwort mit usage zieht ihn Richtung Wahrheit. -_state_lock = threading.Lock() -_cpt: dict = {} # modell -> gelerntes Zeichen-pro-Token -_cpt_n: dict = {} # modell -> Anzahl Messungen -_recent: deque = deque(maxlen=50) # letzte Laeufe fuer /governor/status -_counters = {"chat": 0, "soft": 0, "hard": 0, "passthrough": 0, - "tokens_prompt": 0, "tokens_completion": 0, "started": time.time()} - -CPT_MIN, CPT_MAX = 0.8, 8.0 # Schutz gegen Ausreisser - - -def log(line: str) -> None: - """Eine Zeile nach stdout UND in die Logdatei (thread-sicher).""" - stamp = time.strftime("%Y-%m-%dT%H:%M:%S") - msg = f"{stamp} {line}" - with _log_lock: - print(msg, flush=True) - try: - with open(LOG_PATH, "a", encoding="utf-8") as fh: - fh.write(msg + "\n") - except OSError: - pass - - -def cpt_for(model: str) -> float: - """Aktuelles Zeichen-pro-Token-Verhaeltnis fuer ein Modell (gelernt oder Startwert).""" - with _state_lock: - return _cpt.get(model, CHARS_PER_TOKEN) - - -def calibrate(model: str, chars: int, exact: int) -> None: - """Aus einer echten Antwort lernen. Gleitender Mittelwert mit sanftem Gewicht — - ein einzelner Ausreisser (z. B. ein riesiges Bild) verbiegt nichts.""" - if not CALIBRATE or not exact or exact <= 0 or chars <= 0: - return - ratio = chars / exact - if not (CPT_MIN <= ratio <= CPT_MAX): - return - with _state_lock: - n = _cpt_n.get(model, 0) - old = _cpt.get(model, CHARS_PER_TOKEN) - # Gewicht faellt mit der Anzahl Messungen: schnell einschwingen, dann stabil. - w = max(0.08, 1.0 / (n + 2)) - _cpt[model] = old * (1 - w) + ratio * w - _cpt_n[model] = n + 1 - - -def body_chars(data: dict) -> int: - """Zeichen des GESAMTEN Anfragekoerpers — der Kern der Ehrlichkeit. - - v0.2 zaehlte nur Text in `messages` und lag bei werkzeugdichten Agenten um Faktor 3 - daneben, weil Werkzeug-Schemata (`tools`), Werkzeug-Aufrufe (`tool_calls`) und - Werkzeug-Ergebnisse mitgeschickt werden und im Kontext genauso Platz fressen. - Wir serialisieren einfach alles, was ans Modell geht. - """ - payload = {k: v for k, v in data.items() - if k in ("messages", "tools", "tool_choice", "system", "functions")} - try: - return len(json.dumps(payload, ensure_ascii=False)) - except (TypeError, ValueError): - # Fallback: nur Nachrichtentext (nie schlechter als v0.2) - chars = 0 - for m in data.get("messages") or []: - c = m.get("content") if isinstance(m, dict) else None - if isinstance(c, str): - chars += len(c) + 4 - elif isinstance(c, list): - for p in c: - if isinstance(p, dict) and isinstance(p.get("text"), str): - chars += len(p["text"]) - return chars - - -def safe_to_append(messages) -> bool: - """Darf der Soft-Einschub JETZT als user-Nachricht ans Ende? - - Nein, wenn die Kette gerade mitten in einem Werkzeug-Austausch steckt: nach einem - Assistant mit offenen `tool_calls` MUSS eine `tool`-Antwort folgen — schiebt man da - eine user-Nachricht dazwischen, lehnt das Modell (bzw. das Template) die Anfrage ab - oder halluziniert. Dann warten wir einfach auf die naechste Runde; die Schwelle ist - ohnehin ueberschritten, es kommt in Sekunden ein neuer Zug. - """ - if not isinstance(messages, list) or not messages: - return False - last = messages[-1] - if not isinstance(last, dict): - return False - role = last.get("role") - if role == "tool": - return False - return not (role == "assistant" and last.get("tool_calls")) - - -def _post_announce(est) -> None: - """POSTet die Meldung an die MC2-Announce-Pipeline (Lucy spricht sie).""" - 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: - log(f"ANNOUNCE fehlgeschlagen: {exc!r}") - - -def maybe_announce(est) -> None: - """Sprach-Signal an Lucy — gedrosselt (eine Aeusserung je Episode).""" - 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() - - -def status_payload() -> dict: - """Momentaufnahme fuer /governor/status (MC2-Oberflaeche, Plugin, Lucy).""" - with _state_lock: - return { - "ok": True, - "upstream": UPSTREAM, - "soft": THRESHOLD, - "hard": HARD_CEILING if HARD_CEILING > 0 else None, - "uptime_s": int(time.time() - _counters["started"]), - "counters": {k: v for k, v in _counters.items() if k != "started"}, - "calibration": {m: {"chars_per_token": round(v, 3), "samples": _cpt_n.get(m, 0)} - for m, v in _cpt.items()}, - "calibration_default": CHARS_PER_TOKEN, - "exempt_models": sorted(EXEMPT_MODELS), - "recent": list(_recent), - } - - -class Handler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - server_version = "Governor/2.0" - - def log_message(self, *args): - pass - - def do_GET(self): - if self.path.split("?", 1)[0].rstrip("/") in ("/governor/status", "/governor"): - self._send_json(200, status_payload()) - return - self._proxy() - - def do_POST(self): - self._proxy() - - def do_PUT(self): - self._proxy() - - def do_DELETE(self): - self._proxy() - - def do_OPTIONS(self): - self._proxy() - - # -- Kern --------------------------------------------------------------- - def _send_json(self, status: int, obj) -> None: - try: - data = json.dumps(obj).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Content-Length", str(len(data))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(data) - except OSError: - pass - - def _read_body(self) -> bytes: - length = self.headers.get("Content-Length") - if length is None: - return b"" - try: - return self.rfile.read(int(length)) - except (ValueError, OSError): - return b"" - - def _proxy(self) -> None: - body = self._read_body() - path = self.path - clean_path = path.split("?", 1)[0] - is_chat = clean_path.rstrip("/").endswith("/chat/completions") - - action = "passthrough" - est = None - streaming = False - model = "" - chars = 0 - if is_chat and body: - action, body, est, streaming, model, chars = self._decide(body) - if action in ("soft", "hard"): - maybe_announce(est) - - if action == "hard": - self._send_canned_stop(model, streaming, est) - with _state_lock: - _counters["chat"] += 1 - _counters["hard"] += 1 - _recent.appendleft({"t": int(time.time()), "model": model, "est": est, - "exact": None, "action": "hard"}) - log(f"chat model={model} est={est} thr={THRESHOLD} hard={HARD_CEILING} " - f"HARDSTOP stream={streaming} status=200") - return - - out_headers = {} - for k, v in self.headers.items(): - kl = k.lower() - if kl in HOP_BY_HOP or kl in ("host", "content-length", "accept-encoding"): - continue - out_headers[k] = v - out_headers["Host"] = f"{UP_HOST}:{UP_PORT}" - out_headers["Accept-Encoding"] = "identity" - if body: - out_headers["Content-Length"] = str(len(body)) - out_headers["Connection"] = "close" - - conn = None - try: - conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=900) - conn.request(self.command, path, body=body or None, headers=out_headers) - resp = conn.getresponse() - except (OSError, http.client.HTTPException) as exc: - log(f"ERROR upstream {self.command} {path}: {exc!r}") - if conn is not None: - conn.close() - self._safe_error(502, f"governor upstream: {exc}") - return - - self.send_response(resp.status) - for k, v in resp.getheaders(): - kl = k.lower() - if kl in HOP_BY_HOP or kl in ("content-length", "date", "server"): - continue - self.send_header(k, v) - self.send_header("Connection", "close") - self.end_headers() - - tail = bytearray() - try: - while True: - # read1() gibt jedes Upstream-Stueck sofort zurueck (echtes SSE- - # Durchreichen); read() wuerde puffern und Streaming haengen lassen. - chunk = resp.read1(65536) - if not chunk: - break - self.wfile.write(chunk) - self.wfile.flush() - tail.extend(chunk) - if len(tail) > 16384: - del tail[:-16384] - except OSError: - pass - finally: - conn.close() - - exact = self._scan_prompt_tokens(tail) - if is_chat: - if exact: - calibrate(model, chars, exact) - with _state_lock: - _counters["chat"] += 1 - _counters["soft" if action == "soft" else "passthrough"] += 1 - if exact: - _counters["tokens_prompt"] += exact - _recent.appendleft({"t": int(time.time()), "model": model, "est": est, - "exact": exact, "action": action}) - exact_s = str(exact) if exact is not None else "-" - flag = "FIRED" if action == "soft" else ("skip" if action == "defer" else "ok") - log(f"chat model={model} est={est} exact={exact_s} cpt={cpt_for(model):.2f} " - f"thr={THRESHOLD} {flag} stream={streaming} status={resp.status}") - - def _decide(self, body: bytes): - """Aktion bestimmen. Rueckgabe: (action, body, est, streaming, model, chars).""" - try: - data = json.loads(body) - except (ValueError, UnicodeDecodeError): - return "passthrough", body, None, False, "", 0 - if not isinstance(data, dict): - return "passthrough", body, None, False, "", 0 - - messages = data.get("messages") - streaming = bool(data.get("stream")) - model = (data.get("model") or "").strip() - chars = body_chars(data) - est = int(chars / max(cpt_for(model), 0.1)) - - # Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist keine - # Bau-Sitzung. Wir zaehlen sie trotzdem mit (Kalibrierung + Telemetrie). - base = model.split("/")[-1].lower() - if base in EXEMPT_MODELS: - return "passthrough", body, est, streaming, model, chars - - if not isinstance(messages, list): - return "passthrough", body, est, streaming, model, chars - - has_warned = False - marker = "[GOVERNOR — SITZUNGS-LIMIT ERREICHT]" - for m in reversed(messages): - if m.get("role") == "user" and isinstance(m.get("content"), str) and marker in m["content"]: - has_warned = True - break - - is_safe = safe_to_append(messages) - - # 1. Absolutes Not-Aus bei komplettem Amoklauf - if HARD_CEILING > 0 and est >= HARD_CEILING + 10000: - return "hard", body, est, streaming, model, chars - - # 2. Sind wir am Limit? - if est >= THRESHOLD: - if not is_safe: - # Agent arbeitet gerade an einer Tool-Kette. Auf keinen Fall abbrechen! - return "defer", body, est, streaming, model, chars - - # Es ist sicher (Tool-Kette beendet oder Agent wartet auf Input). - if HARD_CEILING > 0 and est >= HARD_CEILING: - if has_warned: - # Wir haben ihn schon gewarnt, er macht trotzdem weiter -> harter Schnitt. - return "hard", body, est, streaming, model, chars - else: - # Er hat wegen einer langen Tool-Kette direkt das Hard-Limit ueberschritten. - # Gib ihm trotzdem noch den einen Finalisier-Zug (Soft). - pass - - # Soft-Limit greift - if not has_warned: - directive = (DIRECTIVE.replace("{est}", str(est)) - .replace("{threshold}", str(THRESHOLD))) - messages.append({"role": "user", "content": directive}) - data["messages"] = messages - return "soft", json.dumps(data).encode("utf-8"), est, streaming, model, chars - - return "passthrough", body, est, streaming, model, chars - - def _send_canned_stop(self, model: str, streaming: bool, est) -> None: - """OpenAI-kompatible Stopp-Antwort selbst erzeugen (kein Upstream-Call).""" - created = int(time.time()) - usage = {"prompt_tokens": est or 0, "completion_tokens": 0, - "total_tokens": est or 0} - try: - if streaming: - self.send_response(200) - self.send_header("Content-Type", "text/event-stream") - self.send_header("Cache-Control", "no-cache") - self.send_header("Connection", "close") - self.end_headers() - - def sse(obj): - self.wfile.write(b"data: " + json.dumps(obj).encode() + b"\n\n") - self.wfile.flush() - - base = {"id": "governor-hardstop", "object": "chat.completion.chunk", - "created": created, "model": model} - sse({**base, "choices": [{"index": 0, "delta": {"role": "assistant"}, - "finish_reason": None}]}) - sse({**base, "choices": [{"index": 0, "delta": {"content": HARDSTOP_MSG}, - "finish_reason": None}]}) - sse({**base, "choices": [{"index": 0, "delta": {}, - "finish_reason": "stop"}], "usage": usage}) - self.wfile.write(b"data: [DONE]\n\n") - self.wfile.flush() - else: - payload = { - "id": "governor-hardstop", "object": "chat.completion", - "created": created, "model": model, - "choices": [{"index": 0, "finish_reason": "stop", - "message": {"role": "assistant", "content": HARDSTOP_MSG}}], - "usage": usage, - } - data = json.dumps(payload).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(data) - self.wfile.flush() - except OSError: - pass - - def _safe_error(self, status: int, msg: str) -> None: - self._send_json(status, {"error": msg}) - - @staticmethod - def _scan_prompt_tokens(tail: bytearray): - if not tail: - return None - try: - text = tail.decode("utf-8", errors="ignore") - except Exception: - return None - matches = _PROMPT_TOKENS_RE.findall(text) - if not matches: - return None - try: - return int(matches[-1]) - except ValueError: - return None - - -def main() -> int: - log_dir = os.path.dirname(LOG_PATH) - if log_dir and not os.path.isdir(log_dir): - try: - os.makedirs(log_dir, exist_ok=True) - except OSError: - pass - - server = ThreadingHTTPServer((HOST, PORT), Handler) - server.daemon_threads = True - hard = HARD_CEILING if HARD_CEILING > 0 else "aus" - log(f"Governor v2 startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} " - f"Hart={hard} | CPT-Start={CHARS_PER_TOKEN} kalibrierend={CALIBRATE} | " - f"ausgenommen={sorted(EXEMPT_MODELS)} | Log={LOG_PATH}") - try: - server.serve_forever() - except KeyboardInterrupt: - log("Governor beendet (SIGINT).") - finally: - server.server_close() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/deploy/governor/governor.service b/deploy/governor/governor.service deleted file mode 100644 index 6eaea34..0000000 --- a/deploy/governor/governor.service +++ /dev/null @@ -1,27 +0,0 @@ -[Unit] -# Governor v2 — Token-Waechter vor dem MC2-Gateway. -# Nutzer-Dienst (systemctl --user), weil er unter hitonabi laeuft und keine -# Root-Rechte braucht. Startet nach MC2, weil er dorthin weiterreicht. -Description=Governor v2 — Token-Waechter-Proxy (:8100 -> MC2 :9001) -After=network-online.target mission-control-2.service -Wants=network-online.target - -[Service] -Type=simple -WorkingDirectory=%h/governor -ExecStart=/usr/bin/python3 %h/governor/governor.py -Restart=always -RestartSec=3 - -# --- Schwellen ------------------------------------------------------------- -# Soft 45k: OpenCode startet mit ~10-15k allein fuer Systemprompt + Werkzeug- -# Schemata; 25k (der alte Aider-Wert) haette schon nach wenigen Zuegen gefeuert. -# Hart = Soft+5000 (ein Finalisier-Zug Luft), Default des Programms. -Environment=GOV_THRESHOLD=45000 -Environment=GOV_UPSTREAM=http://127.0.0.1:9001 -Environment=GOV_LOG=%h/governor/governor.log -# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist kein Bau. -Environment=GOV_EXEMPT_MODELS=hermes,fast,embed,reranker,vision,scout - -[Install] -WantedBy=default.target diff --git a/deploy/governor/hardstop-test.sh b/deploy/governor/hardstop-test.sh deleted file mode 100644 index d75d7a2..0000000 --- a/deploy/governor/hardstop-test.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# hardstop-test.sh — prueft die drei Governor-Pfade mit direkten curls: -# klein -> passthrough (Modell antwortet normal) -# mittel -> soft-Einschub (Modell bekommt die Stopp-Anweisung, antwortet) -# gross -> HART-STOPP (Governor antwortet selbst, KEIN Modell-Call) -set -u -GOV="http://127.0.0.1:8100/v1/chat/completions" - -python3 - <<'PY' > /tmp/gov_small.json -import json -print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"Reply with exactly: SMALL"}],"max_tokens":8,"stream":False})) -PY -python3 - <<'PY' > /tmp/gov_mid.json -import json -print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"BEGIN "+"lorem ipsum "*2500+" END"}],"max_tokens":40,"stream":False})) -PY -python3 - <<'PY' > /tmp/gov_big.json -import json -print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"x"*40000}],"max_tokens":16,"stream":False})) -PY - -echo "=== TEST A: klein (passthrough) ===" -curl -s -m 60 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_small.json | jq -r '.choices[0].message.content' - -echo "=== TEST B: mittel ~30k Zeichen (soft-Einschub, geht ans Modell) ===" -curl -s -m 120 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_mid.json | jq '{id, content: (.choices[0].message.content|.[0:80])}' - -echo "=== TEST C: gross ~40k Zeichen (HART-STOPP, kein Modell-Call) ===" -curl -s -m 30 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_big.json | jq '{id, finish: .choices[0].finish_reason, content: (.choices[0].message.content|.[0:70]), usage}' diff --git a/deploy/governor/msgs-todo.txt b/deploy/governor/msgs-todo.txt deleted file mode 100644 index c09e00f..0000000 --- a/deploy/governor/msgs-todo.txt +++ /dev/null @@ -1,13 +0,0 @@ -# Wegwerf-Aufgabe: kleine Todo-App, schrittweise. Jede Zeile = eine Runde. -Create todo.py with a TodoList class: add(text) appends a dict {"text": text, "done": False} to an internal list, and items() returns that list. Then update SAVEPOINT.md following our conventions. -Add complete(index) and remove(index) to TodoList, each with a bounds check that raises IndexError with a clear message when the index is out of range. Update SAVEPOINT.md. -Add save(path) and load(path) to TodoList that persist the items to and from a JSON file. Update SAVEPOINT.md. -Add pending_count() and completed_count() methods to TodoList. Update SAVEPOINT.md. -Create cli.py with an argparse command line interface exposing subcommands add, list, done, and rm that operate on a todos.json file via TodoList. Update SAVEPOINT.md. -Add a clear subcommand to cli.py that removes all completed items from todos.json. Update SAVEPOINT.md. -Create test_todo.py with unittest tests covering add, complete, remove, save, load, and the IndexError bounds checks. Update SAVEPOINT.md. -Add tests for pending_count and completed_count to test_todo.py. Update SAVEPOINT.md. -Create README.md documenting the CLI usage with a short example for each subcommand. Update SAVEPOINT.md. -Add type hints throughout todo.py and cli.py. Update SAVEPOINT.md. -Add an optional due date field (ISO date string) to each item and a due argument to TodoList.add and to the CLI add subcommand. Update SAVEPOINT.md. -Add an overdue subcommand to cli.py that lists items whose due date is before today. Update SAVEPOINT.md. diff --git a/deploy/governor/run-aider-msg.sh b/deploy/governor/run-aider-msg.sh deleted file mode 100644 index 66051a0..0000000 --- a/deploy/governor/run-aider-msg.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# run-aider-msg.sh — EINE Aider-Runde via --message (sauberer, -# unterstuetzter Einzel-Schuss ohne Scripting-Reflexions-Haenger), durch den Governor. -# todo.py/cli.py als Lesekontext, damit das Modell die echte API sieht (kein Erfinden) -# und keine "Datei hinzufuegen?"-Reflexion ausloest. -set -u -REPO="${1:?repo dir}" -MSG="${2:?message}" -export PATH="$HOME/.local/bin:$PATH" -export OPENAI_API_BASE="http://127.0.0.1:8100/v1" -export OPENAI_API_KEY="dummy" -cd "$REPO" -aider \ - --model openai/Qwen3-Coder-Next \ - --no-check-update --no-show-model-warnings --no-analytics --yes-always \ - --map-tokens 512 \ - --read CONVENTIONS.md --read todo.py --read cli.py \ - SAVEPOINT.md \ - --message "$MSG" diff --git a/deploy/governor/run-aider.sh b/deploy/governor/run-aider.sh deleted file mode 100644 index 2121019..0000000 --- a/deploy/governor/run-aider.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# run-aider.sh — treibt EINE Aider-Sitzung durch den Governor. -# Jede Zeile der Nachrichtendatei = eine User-Runde; die Historie akkumuliert, -# sodass die Anfrage jede Runde wächst (genau das prüft der Governor). -set -u -MSGS="${1:?Nachrichtendatei angeben}" -export PATH="$HOME/.local/bin:$PATH" -export OPENAI_API_BASE="http://127.0.0.1:8100/v1" -export OPENAI_API_KEY="dummy" -cd "$HOME/governor-p0/testrepo" -aider \ - --model openai/Qwen3-Coder-Next \ - --no-check-update \ - --no-show-model-warnings \ - --no-analytics \ - --yes-always \ - --map-tokens 512 \ - --read CONVENTIONS.md \ - SAVEPOINT.md \ - < "$MSGS" diff --git a/deploy/governor/run-driver.sh b/deploy/governor/run-driver.sh deleted file mode 100644 index fcf2c58..0000000 --- a/deploy/governor/run-driver.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -# run-driver.sh — Aider-Scripting-Sitzung durch den Governor. -set -u -MSGS="${1:?Nachrichtendatei angeben}" -REPO="${2:-$HOME/governor-p0/testrepo}" -VENV_PY="$HOME/.local/share/uv/tools/aider-chat/bin/python" -cd "$REPO" -exec "$VENV_PY" "$HOME/governor-p0/driver.py" "$MSGS" diff --git a/deploy/mc2-morgen-digest.service b/deploy/mc2-morgen-digest.service deleted file mode 100644 index 2e182d3..0000000 --- a/deploy/mc2-morgen-digest.service +++ /dev/null @@ -1,7 +0,0 @@ -[Unit] -Description=MC2 Morgen-Digest (Nächtliche Telegram-Zusammenfassung) - -[Service] -Type=oneshot -ExecStart=/bin/bash %h/mission-control-v2/deploy/morgen-digest.sh -TimeoutStartSec=300 diff --git a/deploy/mc2-morgen-digest.timer b/deploy/mc2-morgen-digest.timer deleted file mode 100644 index 60c22b5..0000000 --- a/deploy/mc2-morgen-digest.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Timer für MC2 Morgen-Digest (07:00 Uhr) - -[Timer] -OnCalendar=*-*-* 07:00:00 -Persistent=true -RandomizedDelaySec=5m - -[Install] -WantedBy=timers.target diff --git a/deploy/morgen-digest.sh b/deploy/morgen-digest.sh deleted file mode 100644 index b2fce33..0000000 --- a/deploy/morgen-digest.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# Morgen-Digest: Sammelt die zurückgehaltenen nächtlichen Meldungen (00:00 - 06:59) -# und lässt Lucy (LLM) eine Telegram-Zusammenfassung daraus bauen. -set -uo pipefail - -NIGHT_QUEUE="$HOME/.hermes/night-queue.txt" -if [ ! -f "$NIGHT_QUEUE" ]; then - # Keine nächtlichen Meldungen - exit 0 -fi - -# Wenn die Datei existiert, aber leer ist, löschen und beenden. -if [ ! -s "$NIGHT_QUEUE" ]; then - rm -f "$NIGHT_QUEUE" - exit 0 -fi - -EVIDENCE="$(cat "$NIGHT_QUEUE")" - -PROMPT="Du bist Lucy, die KI-Assistenz der Mission-Control-Box. -Hier sind die Systemmeldungen der vergangenen Nacht. -Der Commander möchte ordentlich Futter und Details zum Frühstück! Fasse die Meldungen zu einem gehaltvollen Morgen-Digest zusammen. -Beachte dabei: -- Nenne konkrete Fakten, Namen und Versionen. -- Welche Modelle wurden z.B. vom Radar entdeckt oder evaluiert? Was waren die Ergebnisse? -- Welche Updates liefen genau und was haben sie Neues gebracht? Was muss sich der Commander noch anschauen? -- Strukturiere die Nachricht übersichtlich (z.B. mit kleinen Emojis oder Bulletpoints), aber bleibe in deiner Rolle als Lucy. - -Nächtliche Meldungen: -$EVIDENCE" - -# LLM aufrufen (llama-swap / gpt-oss-120b) -ENDPOINT="${CHEF_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}" -MODEL="${CHEF_MODEL:-gpt-oss-120b}" - -REQ="$(jq -n --arg m "$MODEL" --arg sys "Du bist Lucy." --arg usr "$PROMPT" --argjson mt 1200 \ - '{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}], - max_tokens:$mt, temperature:0.4}')" - -DIGEST="$(curl -s -m 180 "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$REQ" \ - | python3 -c ' -import json, sys -try: - d = json.load(sys.stdin) - msg = d["choices"][0]["message"] - print((msg.get("content") or msg.get("reasoning_content") or "").strip()) -except Exception: - pass' -)" - -if [ -n "$DIGEST" ]; then - # Sende den Digest via hermes send (Telegram) - bash -lc 'hermes send --to telegram --subject "[🌅 Morgen-Digest]" -- "$1"' _ "$DIGEST" - if [ $? -eq 0 ]; then - # Erfolgreich gesendet, Queue leeren - rm -f "$NIGHT_QUEUE" - else - echo "Fehler beim Senden des Morgen-Digests an Telegram. Queue bleibt erhalten." >&2 - fi -else - echo "Leerer Digest vom LLM. Queue bleibt erhalten." >&2 -fi diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8a8fb6e..ea0ceaf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -491,6 +491,7 @@ export interface AuftragItem { verwaist?: boolean // kein gemeinsamer Ursprung mit main (kaputt aufgesetzt) → Merge unmöglich leer?: boolean // Diff gegen main ist leer → bringt nichts (schon in main / am Repo vorbei) status: AuftragStatus | null + ci_status?: "success" | "failure" | "pending" | "skipped" | null // Empfehlungs-Stempel des nächtlichen Karten-Gutachters (Meinung, kein Gate). empfehlung: { empfehlung: "ANNEHMEN" | "ABLEHNEN" | "UNKLAR"; satz: string; richter: string } | null } diff --git a/frontend/src/views/AuftragsbuchView.tsx b/frontend/src/views/AuftragsbuchView.tsx index 32b4c33..55c13a2 100644 --- a/frontend/src/views/AuftragsbuchView.tsx +++ b/frontend/src/views/AuftragsbuchView.tsx @@ -155,6 +155,22 @@ function StateBadge({ item }: { item: AuftragItem }) { ) } +function CIAmpelBadge({ item }: { item: AuftragItem }) { + const ci = item.ci_status + if (!ci) return null + + if (ci === "success") { + return CI Grün + } + if (ci === "failure") { + return CI Rot + } + if (ci === "pending") { + return CI Läuft + } + return null +} + function ProposalCard({ item, busy, onAct, showConfirm, }: { @@ -209,6 +225,7 @@ function ProposalCard({ )} {item.branch} {fmtWhen(item.ts)} +

{item.subject || "(ohne Titel)"}