feat: ide-lane hardening (ci ampel in auftragsbuch, hermes auto-bugfix, cleanup)
Ampel / ampel (push) Failing after 22s
Ampel / ampel (push) Failing after 22s
This commit is contained in:
+2
-8
@@ -34,13 +34,12 @@ from routers import (
|
|||||||
models,
|
models,
|
||||||
routing,
|
routing,
|
||||||
system,
|
system,
|
||||||
sysadmin,
|
|
||||||
voice,
|
voice,
|
||||||
wissen,
|
wissen,
|
||||||
zeitmaschine,
|
zeitmaschine,
|
||||||
)
|
)
|
||||||
from routers import reminders as reminders_router
|
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
|
from starlette.requests import Request
|
||||||
|
|
||||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
# 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:
|
if sentry.ENABLED:
|
||||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
||||||
tasks.append(asyncio.create_task(reminders.reminders_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.
|
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
||||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
||||||
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
# 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(models.router)
|
||||||
app.include_router(routing.router)
|
app.include_router(routing.router)
|
||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
app.include_router(sysadmin.router)
|
|
||||||
app.include_router(connect.router)
|
app.include_router(connect.router)
|
||||||
app.include_router(agent.router)
|
app.include_router(agent.router)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
|
|||||||
@@ -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."}
|
|
||||||
@@ -26,6 +26,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from config import MODELS_DIR
|
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_cache: dict = {}
|
||||||
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
|
_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:
|
def _available() -> bool:
|
||||||
@@ -176,6 +217,10 @@ def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
|
|||||||
stempel = gutachten.get(f"{repo}:{branch}")
|
stempel = gutachten.get(f"{repo}:{branch}")
|
||||||
if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
|
if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
|
||||||
stempel = None
|
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()
|
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_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
|
||||||
files = []
|
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).
|
# 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,
|
"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,
|
"status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
|
||||||
|
"ci_status": ci_status,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
|
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
|
||||||
|
|||||||
@@ -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()]
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -12,7 +12,7 @@ TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null |
|
|||||||
[ -n "$TOKEN" ] || exit 0
|
[ -n "$TOKEN" ] || exit 0
|
||||||
|
|
||||||
python3 - "$TOKEN" "$STATE" <<'PY'
|
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]
|
token, state_pfad = sys.argv[1], sys.argv[2]
|
||||||
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
|
# 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:
|
for full, sha, name in neu:
|
||||||
print(f"• {full} @ {sha} ({name})")
|
print(f"• {full} @ {sha} ({name})")
|
||||||
print(f" {WEB}/{full}/actions")
|
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.")
|
print("Nichts aus diesen Ständen ist ‚fertig', solange die Ampel rot ist.")
|
||||||
|
|
||||||
with open(state_pfad, "w") as f:
|
with open(state_pfad, "w") as f:
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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 <msgs> [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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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")'
|
|
||||||
@@ -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 <messages-file>
|
|
||||||
"""
|
|
||||||
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 <messages-file>", 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())
|
|
||||||
@@ -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" </dev/null >>"$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
|
|
||||||
@@ -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())
|
|
||||||
@@ -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
|
|
||||||
@@ -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}'
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# run-aider-msg.sh <repo> <message> — 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"
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# run-aider.sh <messages-file> — 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"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# run-driver.sh <messages-file> — 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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -491,6 +491,7 @@ export interface AuftragItem {
|
|||||||
verwaist?: boolean // kein gemeinsamer Ursprung mit main (kaputt aufgesetzt) → Merge unmöglich
|
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)
|
leer?: boolean // Diff gegen main ist leer → bringt nichts (schon in main / am Repo vorbei)
|
||||||
status: AuftragStatus | null
|
status: AuftragStatus | null
|
||||||
|
ci_status?: "success" | "failure" | "pending" | "skipped" | null
|
||||||
// Empfehlungs-Stempel des nächtlichen Karten-Gutachters (Meinung, kein Gate).
|
// Empfehlungs-Stempel des nächtlichen Karten-Gutachters (Meinung, kein Gate).
|
||||||
empfehlung: { empfehlung: "ANNEHMEN" | "ABLEHNEN" | "UNKLAR"; satz: string; richter: string } | null
|
empfehlung: { empfehlung: "ANNEHMEN" | "ABLEHNEN" | "UNKLAR"; satz: string; richter: string } | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <span className="flex items-center gap-1 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-bold text-emerald-400" title="CI-Ampel: Grün (Baut & läuft)"><Check className="h-3 w-3" /> CI Grün</span>
|
||||||
|
}
|
||||||
|
if (ci === "failure") {
|
||||||
|
return <span className="flex items-center gap-1 rounded-lg border border-red-500/30 bg-red-500/10 px-1.5 py-0.5 text-[10px] font-bold text-red-400" title="CI-Ampel: Rot (Fehler)"><X className="h-3 w-3" /> CI Rot</span>
|
||||||
|
}
|
||||||
|
if (ci === "pending") {
|
||||||
|
return <span className="flex items-center gap-1 rounded-lg border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-bold text-amber-400" title="CI-Ampel: Läuft"><Loader2 className="h-3 w-3 animate-spin" /> CI Läuft</span>
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function ProposalCard({
|
function ProposalCard({
|
||||||
item, busy, onAct, showConfirm,
|
item, busy, onAct, showConfirm,
|
||||||
}: {
|
}: {
|
||||||
@@ -209,6 +225,7 @@ function ProposalCard({
|
|||||||
)}
|
)}
|
||||||
<span className="font-mono text-[11px] text-muted-foreground truncate" title={item.branch}>{item.branch}</span>
|
<span className="font-mono text-[11px] text-muted-foreground truncate" title={item.branch}>{item.branch}</span>
|
||||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(item.ts)}</span>
|
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(item.ts)}</span>
|
||||||
|
<CIAmpelBadge item={item} />
|
||||||
<StateBadge item={item} />
|
<StateBadge item={item} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-sm font-semibold text-foreground">{item.subject || "(ohne Titel)"}</p>
|
<p className="mt-1.5 text-sm font-semibold text-foreground">{item.subject || "(ohne Titel)"}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user