phase0: Nachtmeldungen kommen wieder an, Sicherung mit Lucys Gedaechtnis, Token-Datei raus
Ampel / ampel (push) Successful in 26s

- notify.sh sammelt nachts (00-07 Uhr) wie bisher, aber jetzt liefert mc2-morgenmeldung.timer
  um 07:00 alles als eine Telegram-Nachricht aus. Seit dem 21.08. las niemand die
  Warteschlange, alle Nachtmeldungen (auch die Sonntags-Updates) gingen verloren.
  Dringendes (-d, Betreff Alarm/Notfall, Text beginnt mit KRITISCH) geht sofort raus;
  autoupdate.sh markiert seine KRITISCH-Meldungen ausdruecklich.
- backup.sh sichert zusaetzlich ~/.hermes/memories, SOUL.md, skills/, scripts/, den
  Box-Wart-Zustand (/srv/models/mc2-*.json), User-Units, llama-swap-Drop-ins und Lucys
  Stimmreferenz. restore.sh spielt Gedaechtnis und Zustand nur zurueck, wenn sie fehlen
  oder --mit-gedaechtnis gesetzt ist.
- Update-Verlauf zaehlt die Morgenmeldung nicht als eigenen Lauf.
- Waechter und Flugplan kennen den neuen Timer; deploy.sh installiert ihn.
- .claude/settings.local.json aus dem Repo genommen (lokal behalten), .gitignore ergaenzt.
- ruff wieder gruen (4x RUF100), AGENTS.md: Deploy macht git pull, Remote ist SSH.

Tests: 86 gruen (neu: Nachtruhe, Dringendes, Morgenmeldung inkl. Telegram-Ausfall).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 14:42:19 +02:00
co-authored by Claude Opus 5.5
parent 4a05bc2a05
commit 648be33d21
17 changed files with 329 additions and 139 deletions
+5 -2
View File
@@ -46,6 +46,8 @@ _UPDATE_MELDUNG = re.compile(
r"^(KRITISCH: )?(Router \(llama-swap\)|Engine \(llama\.cpp\)|Hermes-Agent|Hermes)[ -](aktualisiert|Update)"
r"|^Auto-Update abgebrochen|^Sonntags-Update")
_NEUSTART = re.compile(r"^Die Box startet jetzt neu|^Neustart (NICHT|verschoben|FEHLGESCHLAGEN)")
# Die Morgenmeldung (07:00) wiederholt die Nachtmeldungen gesammelt — sie ist kein eigener Lauf.
_MORGENMELDUNG = re.compile(r"^Guten Morgen, Commander\. Heute Nacht")
# Einordnung einer Ergebniszeile. Reihenfolge zählt: „zurückgerollt + GEPINNT“ ist ein Rückbau,
# „Check fehlgeschlagen“ ist offen und kein Stack-Schaden.
@@ -151,8 +153,9 @@ def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
def laeufe_aus(text: str) -> list[dict]:
"""Alle Update-Läufe im Protokolltext, neueste zuerst."""
relevant = [(z, t) for z, t in eintraege(text)
if _ZUSAMMENFASSUNG.search(t) or _UPDATE_MELDUNG.search(t.splitlines()[0] if t else "")
or _NEUSTART.search(t.splitlines()[0] if t else "")]
if not _MORGENMELDUNG.match(t)
and (_ZUSAMMENFASSUNG.search(t) or _UPDATE_MELDUNG.search(t.splitlines()[0] if t else "")
or _NEUSTART.search(t.splitlines()[0] if t else ""))]
gruppen: list[list[tuple[datetime, str]]] = []
for eintrag in relevant:
if gruppen and (eintrag[0] - gruppen[-1][-1][0]).total_seconds() <= LAUF_LUECKE_S:
+2
View File
@@ -84,6 +84,8 @@ TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
"projekte-sync": ("Projekte-Abgleich", False),
"mc2-backup": ("Sicherung", True),
"mc2-radar": ("Modell-Radar", False),
# Rot: scheitert sie, erfährst du nichts von dem, was nachts passiert ist.
"mc2-morgenmeldung": ("Morgenmeldung", True),
}
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
+2 -1
View File
@@ -14,7 +14,8 @@ from zoneinfo import ZoneInfo
from services import modell_nutzung, waechter
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar"}
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar",
"mc2-morgenmeldung.timer": "Morgenmeldung"}
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
+120
View File
@@ -0,0 +1,120 @@
"""Nachtruhe und Morgenmeldung (deploy/notify.sh, deploy/morgenmeldung.sh, 24.09.2026).
Bis zum 24.09. parkte notify.sh nachts alles in einer Warteschlange, die niemand mehr auslieferte.
Die Tests fahren die echten Skripte mit einem Ersatz für `hermes send`, der nur mitschreibt."""
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
DEPLOY = Path(__file__).resolve().parents[2] / "deploy"
BASH = shutil.which("bash")
# Unter Windows kann „bash“ die WSL-Hülle sein, die Windows-Pfade nicht versteht — dann überspringen.
pytestmark = pytest.mark.skipif(
BASH is None or (sys.platform == "win32" and "system32" in BASH.lower()),
reason="braucht eine bash (Git-Bash oder Linux)",
)
@pytest.fixture
def umgebung(tmp_path):
protokoll = tmp_path / "gesendet.txt"
stub = tmp_path / "hermes-stub"
stub.write_text(
"#!/usr/bin/env bash\n"
'printf "%s\\n" "$*" >> "$STUB_PROTOKOLL"\n'
'exit "${STUB_EXIT:-0}"\n',
encoding="utf-8", newline="\n")
stub.chmod(0o755)
env = {
**os.environ,
"MC_NOTIFY_LOG": (tmp_path / "notify.log").as_posix(),
"MC_NIGHT_QUEUE": (tmp_path / "night-queue.txt").as_posix(),
"MC_NOTIFY_HERMES": stub.as_posix(),
"MC_NOTIFY_NO_ANNOUNCE": "1",
"STUB_PROTOKOLL": protokoll.as_posix(),
"MC_MORGENMELDUNG_PAUSE": "0",
}
return tmp_path, env, protokoll
def _notify(env, *args, stunde="03"):
return subprocess.run([BASH, (DEPLOY / "notify.sh").as_posix(), *args],
env={**env, "MC_NOTIFY_STUNDE": stunde}, capture_output=True, text=True, timeout=60)
def _gesendet(protokoll: Path) -> str:
return protokoll.read_text(encoding="utf-8") if protokoll.exists() else ""
def test_nachts_wird_gesammelt(umgebung):
tmp, env, protokoll = umgebung
assert _notify(env, "-s", "[Box-Update]", "Engine aktualisiert").returncode == 0
assert _gesendet(protokoll) == ""
assert (tmp / "night-queue.txt").read_text(encoding="utf-8") == "- [Box-Update]: Engine aktualisiert\n"
assert "QUEUED für Morgen-Digest: Engine aktualisiert" in (tmp / "notify.log").read_text(encoding="utf-8")
@pytest.mark.parametrize("args", [
("-d", "-s", "[Box-Update]", "Wichtig"),
("-s", "[Alarm]", "Lucy antwortet nicht"),
("-s", "[Box-Update]", "KRITISCH: Update UND Rollback fehlgeschlagen"),
])
def test_dringendes_geht_nachts_sofort_raus(umgebung, args):
tmp, env, protokoll = umgebung
assert _notify(env, *args).returncode == 0
assert "send --to telegram" in _gesendet(protokoll)
assert not (tmp / "night-queue.txt").exists()
@pytest.mark.parametrize("stunde", ["07", "08", "09", "23"])
def test_tagsueber_sofort(umgebung, stunde):
tmp, env, protokoll = umgebung
assert _notify(env, "-s", "[Test]", "Hallo", stunde=stunde).returncode == 0
assert "Hallo" in _gesendet(protokoll)
assert not (tmp / "night-queue.txt").exists()
def _morgenmeldung(env, **extra):
return subprocess.run([BASH, (DEPLOY / "morgenmeldung.sh").as_posix()], env={**env, **extra},
capture_output=True, text=True, timeout=60)
def test_morgenmeldung_schickt_alles_als_eine_nachricht(umgebung):
tmp, env, protokoll = umgebung
_notify(env, "-s", "[Box-Update]", "Router aktualisiert")
_notify(env, "-s", "[Modell-Radar]", "Kandidat bestanden")
assert _morgenmeldung(env).returncode == 0
gesendet = _gesendet(protokoll)
assert gesendet.count("send --to telegram") == 1
assert "[Morgenmeldung]" in gesendet and "2 Meldungen" in gesendet
assert "Router aktualisiert" in gesendet and "Kandidat bestanden" in gesendet
assert not (tmp / "night-queue.txt").exists()
assert (tmp / "night-queue.txt.zuletzt-gesendet").exists()
def test_morgenmeldung_ohne_nachtmeldungen_schweigt(umgebung):
_, env, protokoll = umgebung
assert _morgenmeldung(env).returncode == 0
assert _gesendet(protokoll) == ""
def test_morgenmeldung_verliert_nichts_wenn_telegram_klemmt(umgebung):
tmp, env, protokoll = umgebung
_notify(env, "-s", "[Box-Update]", "Erste Nacht")
fehl = _morgenmeldung(env, STUB_EXIT="1", MC_MORGENMELDUNG_VERSUCHE="2")
assert fehl.returncode == 1
assert (tmp / "night-queue.txt.senden").exists()
_notify(env, "-s", "[Box-Update]", "Zweite Nacht")
protokoll.unlink()
assert _morgenmeldung(env).returncode == 0
gesendet = _gesendet(protokoll)
assert "2 Meldungen" in gesendet
assert gesendet.index("Erste Nacht") < gesendet.index("Zweite Nacht")
assert not (tmp / "night-queue.txt.senden").exists()
+14
View File
@@ -84,6 +84,20 @@ def test_lauf_von_hand_mit_neustart_danach():
assert lauf["zeilen"][2]["text"] == "1 Änderung eingespielt, Gehirn-Check grün."
def test_morgenmeldung_ist_kein_eigener_lauf():
"""Die Morgenmeldung um 07:00 wiederholt die Nachtmeldungen samt Wochen-Zusammenfassung — sie darf
keinen zweiten Lauf „Update von Hand“ erzeugen."""
morgen = (
"2026-09-20 07:00:02 OK telegram: Guten Morgen, Commander. Heute Nacht gab es 2 Meldungen:\n"
"- [Box-Update]: Router (llama-swap) aktualisiert: 255 → 256. Stack-Check grün, alles läuft.\n"
"- [Box-Update]: Commander, die Wochenpflege der Box ist durch — kurz für dich:\n"
"• Router (llama-swap): 255 → 256 eingespielt, Stack-Check grün.\n"
)
ohne = update_verlauf.laeufe_aus(PROTOKOLL)
mit = update_verlauf.laeufe_aus(PROTOKOLL.replace("2026-09-27 04:31:00", morgen + "2026-09-27 04:31:00"))
assert [lauf["start"] for lauf in mit] == [lauf["start"] for lauf in ohne]
def test_alles_aktuell_ist_grau():
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[0]
assert (lauf["stufe"], lauf["titel"]) == ("grau", "Alles aktuell")