phase2b: Auftraege ueberleben einen Neustart von MC2

- jobengine: jeder Auftrag laeuft als eigene systemd-Einheit mc2-job-<id> (systemd-run,
  RuntimeMaxSec als Zeitlimit); Akte, Protokoll und Exit-Code liegen unter
  <Datenordner>/mc2-jobs; MC2 nimmt laufende Auftraege beim Start wieder auf
- Geheimnisse (HF_TOKEN) ueber eine nur fuer den Nutzer lesbare Umgebungsdatei, die der
  Auftrag beim Start liest und loescht; nichts davon in Befehlszeile oder Einheit
- benannte Nacharbeiten (wartung:nach_update, modell:rolle) statt Closures, laufen auch
  nach einem Neustart
- ohne systemd (PC, Tests) weiter als Kindprozess
- deploy.sh wartet nur noch auf Update-Auftraege; Downloads laufen weiter
- 14 neue Tests; systemd-Weg auf der Box echt geprueft (Neustart, Abbruch, Zeitlimit,
  Geheimnis nicht sichtbar)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 17:41:26 +02:00
co-authored by Claude Opus 5.5
parent 75611be9a9
commit e1d7d499f8
7 changed files with 657 additions and 177 deletions
+10 -9
View File
@@ -246,6 +246,7 @@ def updates() -> dict:
return daten
@jobengine.nacharbeit("wartung:nach_update")
def _nach_update() -> None:
"""Nach einem abgeschlossenen Update: Zwischenspeicher leeren und den Stand hochzählen."""
global update_stand
@@ -566,12 +567,12 @@ def logs(service: str, lines: int = 200) -> dict:
return {"ok": r["ok"], "text": r["out"] or r["err"]}
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
def _starten(args: list[str], label: str, on_done=None, zeitlimit_s: int | None = None) -> dict:
def _starten(args: list[str], label: str, zeitlimit_s: int | None = None) -> dict:
"""Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Job gleichzeitig. Prüfen und Eintragen
passieren unter einer Sperre (jobengine.start_job_exklusiv) — vorher lag zwischen Prüfung und
Start ein langsamer Aufruf, und zwei Klicks konnten zwei Updates starten."""
job_id, laeuft = jobengine.start_job_exklusiv("maintenance", args, label, on_done=on_done,
zeitlimit_s=zeitlimit_s)
job_id, laeuft = jobengine.start_job_exklusiv("maintenance", args, label, zeitlimit_s=zeitlimit_s,
nacharbeit="wartung:nach_update")
if job_id is None:
return {"ok": False, "status": "busy", "running": (laeuft or {}).get("label"),
"detail": f"Es läuft schon: {(laeuft or {}).get('label', 'ein Update')}."}
@@ -584,7 +585,7 @@ def check_updates_job() -> dict:
# apt-get update gehört in den Wartungs-Riegel: parallel zu einem apt upgrade kollidiert es mit
# der dpkg-Sperre. In der Gruppe taucht der Job auch in der Oberfläche auf.
return _starten(["bash", "-c", "sudo apt-get update"], "Nach Updates suchen",
on_done=_nach_update, zeitlimit_s=15 * 60)
zeitlimit_s=15 * 60)
def os_update_job() -> dict:
@@ -596,7 +597,7 @@ def os_update_job() -> dict:
cmd = ("sudo apt-get update && "
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}")
return _starten(["bash", "-c", cmd], "OS-Update (apt)", on_done=_nach_update, zeitlimit_s=90 * 60)
return _starten(["bash", "-c", cmd], "OS-Update (apt)", zeitlimit_s=90 * 60)
def engine_update_job() -> dict | None:
@@ -606,7 +607,7 @@ def engine_update_job() -> dict | None:
# Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem
# neuen Build. KEIN sudo-Passwort-Gate: das Skript ist per sudoers NOPASSWD freigegeben.
return _starten(["bash", "-c", ENGINE_UPDATE_CMD], "Engine-Update (llama.cpp Vulkan)",
on_done=_nach_update, zeitlimit_s=60 * 60)
zeitlimit_s=60 * 60)
def swap_update_job() -> dict | None:
@@ -615,7 +616,7 @@ def swap_update_job() -> dict | None:
# update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack
# und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer Version.
return _starten(["bash", "-c", SWAP_UPDATE_CMD], "Router-Update (llama-swap)",
on_done=_nach_update, zeitlimit_s=30 * 60)
zeitlimit_s=30 * 60)
def _hermes_update_cmd() -> str:
@@ -666,7 +667,7 @@ def hermes_update_job() -> dict:
den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo
(alles im User-Space). Läuft als Hintergrund-Job (kann einige Minuten dauern)."""
return _starten(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
on_done=_nach_update, zeitlimit_s=45 * 60)
zeitlimit_s=45 * 60)
def update_all_job() -> dict:
@@ -711,7 +712,7 @@ def update_all_job() -> dict:
labels = " → ".join(label for label, _ in parts)
ergebnis = _starten(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
on_done=_nach_update, zeitlimit_s=3 * 3600)
zeitlimit_s=3 * 3600)
if ergebnis.get("ok"):
ergebnis["parts"] = [label for label, _ in parts]
return ergebnis