Auftragsbuch: Ketten-Sicht (Projekt-Gruppen) + Telegram-Ketten-Digest
Queue-UI gruppiert verkettete Karten zu Projekten: Fortschritt, laufende Karte mit Heartbeat-Notiz, 'gleich dran', Rest eingeklappt mit 'wartet auf'. Backend reichert die CLI-Sicht read-only aus kanban.db an (task_links + Heartbeats). Neuer Digest-Waechter meldet per Telegram: Fragen sofort, Projekt-Abschluss sofort, sonst 60-min-Puls (User-Wahl 20.07.). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+116
-1
@@ -17,6 +17,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -24,6 +25,12 @@ from pathlib import Path
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HERMES = Path(os.environ.get("MC_HERMES_BIN", "~/.local/bin/hermes")).expanduser()
|
||||
# Ketten-Sicht (20.07.): Eltern-Links + Heartbeat-Notizen stehen NICHT im `list --json`
|
||||
# der CLI — dafür (und NUR dafür) ein read-only-Blick in die kanban.db. Das bricht die
|
||||
# „alles über die CLI"-Regel bewusst minimal: zwei stabile Tabellen (task_links 2 Spalten,
|
||||
# task_events), Verbindung strikt mode=ro, jeder Fehler fällt lautlos auf die flache
|
||||
# Sicht zurück. Schreiben läuft weiterhin ausschließlich über die CLI.
|
||||
KANBAN_DB = Path(os.environ.get("MC_KANBAN_DB", "~/.hermes/kanban.db")).expanduser()
|
||||
|
||||
_TASK_ID_RX = re.compile(r"^t_[0-9a-f]{4,16}$")
|
||||
|
||||
@@ -92,6 +99,113 @@ def _blocker_frage(task_id: str) -> dict | None:
|
||||
return {"frage": frage[:800], "kind": kind}
|
||||
|
||||
|
||||
def _projekt_titel(titles: list[str]) -> str:
|
||||
"""Anzeigename einer Karten-Familie: gemeinsamer Titel-Präfix (z. B. „Homelab-Dashboard 2.0"),
|
||||
sonst der Titel der ältesten Karte. Reine Anzeige-Heuristik, nichts hängt davon ab."""
|
||||
if not titles:
|
||||
return "Projekt"
|
||||
prefix = titles[0]
|
||||
for t in titles[1:]:
|
||||
while prefix and not t.startswith(prefix):
|
||||
prefix = prefix[:-1]
|
||||
prefix = prefix.strip(" –-:·")
|
||||
return prefix if len(prefix) >= 10 else titles[0][:80]
|
||||
|
||||
|
||||
def _ketten_anreichern(items: list[dict]) -> list[dict]:
|
||||
"""Ketten-Sicht über die Queue legen (read-only, best-effort).
|
||||
|
||||
Ergänzt pro Karte: `eltern` (offene Vorgänger-IDs), `wartet_auf` (deren Titel),
|
||||
`notiz` (jüngste Heartbeat-Notiz des Workers bei running) und `projekt`
|
||||
(Familien-Schlüssel). Liefert die Projekt-Gruppen für die UI: zusammenhängende
|
||||
Karten (task_links) = ein Projekt; Einzelkarten bleiben ohne `projekt`.
|
||||
Jeder Fehler (Windows-Dev, Schema-Drift, Lock) → unveränderte flache Sicht.
|
||||
"""
|
||||
for it in items:
|
||||
it.setdefault("eltern", [])
|
||||
it.setdefault("wartet_auf", [])
|
||||
it.setdefault("notiz", None)
|
||||
it.setdefault("projekt", None)
|
||||
if not items or not KANBAN_DB.is_file():
|
||||
return []
|
||||
by_id = {it["id"]: it for it in items if it.get("id")}
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{KANBAN_DB}?mode=ro", uri=True, timeout=3)
|
||||
try:
|
||||
con.execute("PRAGMA busy_timeout=2000")
|
||||
links = [(p, c) for p, c in con.execute(
|
||||
"SELECT parent_id, child_id FROM task_links")
|
||||
if p in by_id or c in by_id]
|
||||
for it in items:
|
||||
if it.get("status") != "running":
|
||||
continue
|
||||
for (payload,) in con.execute(
|
||||
"SELECT payload FROM task_events WHERE task_id=? AND kind='heartbeat' "
|
||||
"AND payload IS NOT NULL ORDER BY created_at DESC LIMIT 5", (it["id"],)):
|
||||
try:
|
||||
note = (json.loads(payload) or {}).get("note")
|
||||
except Exception:
|
||||
note = None
|
||||
if note:
|
||||
it["notiz"] = str(note)[:300]
|
||||
break
|
||||
finally:
|
||||
con.close()
|
||||
except Exception:
|
||||
log.debug("ideen: Ketten-Anreicherung fehlgeschlagen", exc_info=True)
|
||||
return []
|
||||
|
||||
# Eltern/wartet_auf: nur Vorgänger zählen, die selbst noch offen in der Liste stehen —
|
||||
# archivierte/fremde Parents gelten als erledigt.
|
||||
ERLEDIGT = ("done",)
|
||||
for parent, child in links:
|
||||
it = by_id.get(child)
|
||||
p = by_id.get(parent)
|
||||
if it and p and p.get("status") not in ERLEDIGT:
|
||||
it["eltern"].append(parent)
|
||||
if len(it["wartet_auf"]) < 3:
|
||||
it["wartet_auf"].append((p.get("titel") or parent)[:90])
|
||||
|
||||
# Familien = zusammenhängende Komponenten über die Links (Union-Find, klein genug).
|
||||
chef: dict[str, str] = {}
|
||||
|
||||
def boss(x: str) -> str:
|
||||
while chef.get(x, x) != x:
|
||||
chef[x] = chef.get(chef[x], chef[x])
|
||||
x = chef[x]
|
||||
return x
|
||||
|
||||
for parent, child in links:
|
||||
if parent in by_id and child in by_id:
|
||||
a, b = boss(parent), boss(child)
|
||||
if a != b:
|
||||
chef[a] = b
|
||||
|
||||
familien: dict[str, list[dict]] = {}
|
||||
for it in items:
|
||||
familien.setdefault(boss(it["id"]), []).append(it)
|
||||
projekte = []
|
||||
for wurzel, mitglieder in familien.items():
|
||||
if len(mitglieder) < 2:
|
||||
continue
|
||||
mitglieder.sort(key=lambda i: i.get("erstellt") or 0)
|
||||
key = mitglieder[0]["id"]
|
||||
for it in mitglieder:
|
||||
it["projekt"] = key
|
||||
fertig = sum(1 for i in mitglieder if i["status"] in ERLEDIGT)
|
||||
projekte.append({
|
||||
"key": key,
|
||||
"titel": _projekt_titel([i["titel"] for i in mitglieder]),
|
||||
"gesamt": len(mitglieder),
|
||||
"fertig": fertig,
|
||||
"laeuft": sum(1 for i in mitglieder if i["status"] == "running"),
|
||||
"haengt": sum(1 for i in mitglieder if i["status"] == "blocked"),
|
||||
})
|
||||
# Aktive Projekte zuerst (hängend > laufend > Rest), dann nach Größe.
|
||||
projekte.sort(key=lambda p: (-p["haengt"], -p["laeuft"], -p["gesamt"]))
|
||||
return projekte
|
||||
|
||||
|
||||
def list_queue() -> dict:
|
||||
"""Alle nicht archivierten Aufgaben des Boards, jüngste zuerst — die Queue-Sicht der UI."""
|
||||
if not _available():
|
||||
@@ -137,8 +251,9 @@ def list_queue() -> dict:
|
||||
it["frage"] = info["frage"]
|
||||
it["frage_kind"] = info["kind"]
|
||||
|
||||
projekte = _ketten_anreichern(items)
|
||||
offen = sum(1 for i in items if i["status"] not in ("done",))
|
||||
data = {"available": True, "items": items, "offen": offen}
|
||||
data = {"available": True, "items": items, "offen": offen, "projekte": projekte}
|
||||
_list_cache.update(ts=now, data=data)
|
||||
return data
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
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
|
||||
STATE_PATH = Path(os.environ.get("MC_KETTEN_DIGEST_STATE", str(MODELS_DIR / "mc2-ketten-digest.json")))
|
||||
|
||||
|
||||
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 []
|
||||
by_id = {i["id"]: i for i in items if i.get("id")}
|
||||
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
|
||||
|
||||
# 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)
|
||||
Reference in New Issue
Block a user