This commit is contained in:
team3
2026-07-23 16:07:36 +02:00
commit cb68cd671b
88 changed files with 10029 additions and 0 deletions

360
backend/engine.py Normal file
View File

@@ -0,0 +1,360 @@
"""Task-Engine (R8): DB ist die einzige Wahrheit/Queue. Scheduler blockiert auf
einem Wecker-Event + 2-s-Fallback-Poll. Claim per UPDATE…WHERE status='offen'.
Resume = derselbe Codepfad (Loop starten, DB lesen); Zombie-Reset beim Start.
Ergebnis + status='fertig' + Folgetasks entstehen in EINER Transaktion."""
import asyncio
import hashlib
import importlib
from contextlib import suppress
from dataclasses import dataclass, field
from . import config, db, graph
from .llm import BudgetErschoepft, LaufPause
WORKER: dict[str, object] = {}
_laeufe: dict[str, asyncio.Task] = {}
_wecker: dict[int, asyncio.Event] = {}
def worker(name: str, buendel: bool = False):
def deco(fn):
fn.buendel = buendel # opt-in: Worker verarbeitet t["_buendel"]-Listen
WORKER[name] = fn
return fn
return deco
@dataclass
class Ergebnis:
"""Rückgabe jedes Workers. neue_tasks: {knoten, item, payload?, art?, runde?}
— Ziel muss über eine Kante vom eigenen Knoten erreichbar sein."""
daten: dict = field(default_factory=dict)
neue_tasks: list = field(default_factory=list)
befunde: list = field(default_factory=list) # nur Gates: {art, item, detail}
gate_status: str = "" # nur Gates: gruen | rot
teil_status: dict = field(default_factory=dict) # Bündel: task_id → fertig | neu
def module_laden() -> None:
"""Worker-Module importieren → Registrierung; danach Graph validieren."""
for mod in ("suche", "laden", "korpus", "inventar", "dedup",
"struktur", "guide", "redundanz", "montage"):
importlib.import_module(f"backend.{mod}")
graph.laden(set(WORKER))
def wecken(run_id: int) -> None:
ev = _wecker.get(run_id)
if ev:
ev.set()
# ── Lauf-Verwaltung ──
def lauf_starten(topic: str, budget: int | None = None) -> int:
if topic in _laeufe and not _laeufe[topic].done():
raise RuntimeError("Lauf läuft bereits")
run = db.one("SELECT * FROM runs WHERE topic=? AND status IN "
"('paused','budget') ORDER BY id DESC LIMIT 1", topic)
if run: # Resume: gleicher Run, gleiche Tasks
run_id = run["id"]
felder = {"status": "running", "grund": ""}
if budget is not None: # Budget-Nachschlag beim Fortsetzen
felder["budget_tokens"] = budget
db.update("runs", "id=?", (run_id,), **felder)
else:
run_id = db.insert("runs", topic=topic, status="running",
gestartet=db.now(),
budget_tokens=budget if budget is not None
else config.RUN_BUDGET_TOKENS)
_laeufe[topic] = asyncio.ensure_future(_lauf(topic, run_id))
return run_id
def lauf_pausieren(topic: str) -> None:
run = _aktiver_run(topic)
if run:
db.update("runs", "id=?", (run["id"],), status="paused", grund="manuell")
wecken(run["id"])
def lauf_stoppen(topic: str) -> None:
run = _aktiver_run(topic)
if run:
db.update("runs", "id=?", (run["id"],), status="stopped", beendet=db.now())
wecken(run["id"])
task = _laeufe.get(topic)
if task and not task.done():
task.cancel()
def _aktiver_run(topic: str) -> dict | None:
return db.one("SELECT * FROM runs WHERE topic=? AND status='running' "
"ORDER BY id DESC LIMIT 1", topic)
# ── Kern-Loop ──
async def _lauf(topic: str, run_id: int) -> None:
g = graph.get()
ev = _wecker[run_id] = asyncio.Event()
sem_global = asyncio.Semaphore(config.MAX_PARALLEL_GLOBAL)
sems = {k.id: asyncio.Semaphore(k.max_parallel) for k in g.knoten.values()}
laufende: set[asyncio.Task] = set()
# Zombie-Reset: ein Prozess ⇒ beim Start ist garantiert nichts in Arbeit
db.execute("UPDATE tasks SET status='offen', fehler='zombie' "
"WHERE topic=? AND status='laufend'", topic)
_start_tasks(topic, run_id)
try:
while True:
run = db.one("SELECT * FROM runs WHERE id=?", run_id)
if run["status"] != "running":
break
stufe_i = _aktuelle_stufe(g, topic)
db.update("runs", "id=? AND stufe!=?", (run_id, g.stufen[stufe_i]),
stufe=g.stufen[stufe_i])
_gate_pruefen(g, topic, run_id, stufe_i)
gestartet = _dispatch(g, topic, run_id, stufe_i, sems, sem_global,
laufende, ev)
if not gestartet and not laufende and _leer(topic):
_abschliessen(g, topic, run_id)
break
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(ev.wait(), timeout=config.POLL_SEKUNDEN)
ev.clear()
laufende = {t for t in laufende if not t.done()}
# Pause/Stop: Laufende sanft auslaufen lassen
if laufende:
await asyncio.gather(*laufende, return_exceptions=True)
finally:
_wecker.pop(run_id, None)
def _leer(topic: str) -> bool:
return not db.one("SELECT id FROM tasks WHERE topic=? AND "
"status IN ('offen','laufend') LIMIT 1", topic)
def _aktuelle_stufe(g: graph.Graph, topic: str) -> int:
for i, stufe in enumerate(g.stufen):
gate = g.gate_der_stufe(stufe)
if gate is None:
return i
gruen = db.one("SELECT id FROM gate_laeufe WHERE topic=? AND knoten=? "
"AND status='gruen'", topic, gate.id)
if not gruen:
return i
return len(g.stufen) - 1
def _dispatch(g, topic, run_id, stufe_i, sems, sem_global, laufende, ev) -> int:
offen = db.query(
"SELECT * FROM tasks WHERE topic=? AND status='offen' ORDER BY runde, id",
topic)
n = 0
for t in offen:
kn = g.knoten.get(t["knoten"])
if kn is None or g.stufen_index(kn.stufe) > stufe_i:
continue
if sems[kn.id].locked() or sem_global.locked():
continue
if kn.barriere and _vorarbeit_offen(g, topic, kn, ausser=t["id"]):
continue
if kn.typ == "gate" and _vorarbeit_offen(g, topic, kn, ausser=t["id"]):
continue
if db.execute("UPDATE tasks SET status='laufend', gestartet=? "
"WHERE id=? AND status='offen'", db.now(), t["id"]) != 1:
continue
t = dict(t)
t["_buendel"] = _buendel_claimen(g, t, kn)
task = asyncio.create_task(
_ausfuehren(g, t, kn, sems[kn.id], sem_global, ev))
laufende.add(task)
n += 1
return n
def _buendel_claimen(g, t: dict, kn: graph.Knoten) -> list[dict]:
"""Bis zu buendel1 weitere offene Tasks gleicher (knoten, art) mit-claimen.
Nur für Worker mit buendel-Opt-in — sonst blieben Mitglieder 'laufend' hängen."""
fn = WORKER[kn.worker]
if kn.buendel <= 1 or not getattr(fn, "buendel", False):
return []
mitglieder = []
kandidaten = db.query(
"SELECT * FROM tasks WHERE topic=? AND knoten=? AND art=? AND "
"status='offen' AND id!=? ORDER BY runde, id LIMIT ?",
t["topic"], t["knoten"], t["art"], t["id"], kn.buendel - 1)
for k in kandidaten:
if db.execute("UPDATE tasks SET status='laufend', gestartet=? "
"WHERE id=? AND status='offen'", db.now(), k["id"]) == 1:
mitglieder.append(dict(k))
return mitglieder
def _vorarbeit_offen(g: graph.Graph, topic: str, kn: graph.Knoten,
ausser: int) -> bool:
"""Barriere/Gate: wartet, bis KEIN offener/laufender Task an Knoten der
eigenen oder früherer Stufen existiert (außer sich selbst)."""
max_i = g.stufen_index(kn.stufe)
ids = [k.id for k in g.knoten.values()
if g.stufen_index(k.stufe) <= max_i and k.id != kn.id]
qs = ",".join("?" for _ in ids)
return bool(db.one(
f"SELECT id FROM tasks WHERE topic=? AND status IN ('offen','laufend') "
f"AND knoten IN ({qs}) AND id!=? LIMIT 1", topic, *ids, ausser))
def _gate_pruefen(g: graph.Graph, topic: str, run_id: int, stufe_i: int) -> None:
"""Stufe leer gelaufen → Gate-Task für Runde N anlegen (idempotent)."""
gate = g.gate_der_stufe(g.stufen[stufe_i])
if gate is None:
return
vorhanden = db.one("SELECT id FROM tasks WHERE topic=? AND knoten=? AND "
"status IN ('offen','laufend')", topic, gate.id)
if vorhanden:
return
if _vorarbeit_offen(g, topic, gate, ausser=-1):
return
runde = (db.one("SELECT COUNT(*) c FROM gate_laeufe WHERE topic=? AND knoten=?",
topic, gate.id) or {"c": 0})["c"] + 1
if runde > config.GATE_RUNDEN_MAX.get(gate.id, 3):
_pausieren(run_id, f"gate_cap:{gate.id}:{runde}")
return
db.insert("tasks", ignore=True, run_id=run_id, topic=topic, knoten=gate.id,
item=f"runde:{runde}", runde=runde, erzeugt_von="engine")
async def _ausfuehren(g, t: dict, kn: graph.Knoten, sem, sem_global, ev) -> None:
fn = WORKER[kn.worker]
alle = [t] + t.get("_buendel", [])
try:
async with sem_global, sem:
erg: Ergebnis = await fn(t)
except (LaufPause, BudgetErschoepft) as e:
status = "budget" if isinstance(e, BudgetErschoepft) else "paused"
for m in alle:
db.update("tasks", "id=?", (m["id"],), status="offen",
fehler=str(e)[:300])
_pausieren(t["run_id"], f"{status}:{e}", status=status)
ev.set()
return
except asyncio.CancelledError:
for m in alle:
db.update("tasks", "id=?", (m["id"],), status="offen", fehler="abbruch")
raise
except Exception as e: # Inhaltsfehler: begrenzte Neuversuche, dann sichtbar
for m in alle:
_task_neu(m, kn, f"{type(e).__name__}: {e}"[:300])
ev.set()
return
_abschluss_schreiben(g, t, kn, erg)
ev.set()
def _task_neu(t: dict, kn: graph.Knoten, fehler: str) -> None:
versuch = t["versuch"] + 1
status = "offen" if versuch < kn.max_versuche else "fehler"
db.update("tasks", "id=?", (t["id"],), status=status, versuch=versuch,
fehler=fehler)
def _abschluss_schreiben(g, t: dict, kn: graph.Knoten, erg: Ergebnis) -> None:
erlaubt = g.erlaubte_ziele(kn.id)
# Bündel-Mitglieder: kaputte Teil-Antworten → neuer Versuch, nie verwerfen
for m in t.get("_buendel", []):
if erg.teil_status.get(m["id"]) == "neu":
_task_neu(m, kn, "buendel_teil_kaputt")
else:
db.update("tasks", "id=?", (m["id"],), status="fertig",
ergebnis=db.j({"buendel_mit": t["id"]}), beendet=db.now())
with db.tx() as c:
if erg.teil_status.get(t["id"]) == "neu":
c.execute("UPDATE tasks SET status=?, versuch=?, fehler=? WHERE id=?",
("offen" if t["versuch"] + 1 < kn.max_versuche else "fehler",
t["versuch"] + 1, "buendel_teil_kaputt", t["id"]))
else:
c.execute(
"UPDATE tasks SET status='fertig', ergebnis=?, beendet=? WHERE id=?",
(db.j(erg.daten), db.now(), t["id"]))
eingefuegt = 0
for nt in erg.neue_tasks:
if nt["knoten"] not in erlaubt:
raise graph.GraphFehler(
f"{kn.id} darf keine Tasks für {nt['knoten']} erzeugen")
cur = c.execute(
"INSERT OR IGNORE INTO tasks(run_id, topic, knoten, item, art, "
"runde, payload, erzeugt_von) VALUES(?,?,?,?,?,?,?,?)",
(t["run_id"], t["topic"], nt["knoten"], nt["item"],
nt.get("art", ""), nt.get("runde", t["runde"]),
db.j(nt.get("payload", {})), kn.id))
eingefuegt += cur.rowcount
if kn.typ == "gate":
_gate_abschluss(c, t, kn, erg, eingefuegt)
def _gate_abschluss(c, t: dict, kn: graph.Knoten, erg: Ergebnis,
neu_eingefuegt: int = 0) -> None:
befunde = sorted(f"{b['art']}|{b['item']}|{b.get('detail', '')[:200]}"
for b in erg.befunde)
fingerprint = hashlib.sha256("\n".join(befunde).encode()).hexdigest()[:16]
c.execute("INSERT OR REPLACE INTO gate_laeufe(run_id, topic, knoten, runde, "
"status, fingerprint, befunde, ts) VALUES(?,?,?,?,?,?,?,?)",
(t["run_id"], t["topic"], kn.id, t["runde"], erg.gate_status,
fingerprint, db.j(erg.befunde), db.now()))
for b in erg.befunde:
# Spiegel-Zeile nur, wenn kein identischer Befund schon offen ist
schon = c.execute(
"SELECT id FROM befunde WHERE art=? AND item=? AND "
"substr(detail,1,200)=? AND status='offen'",
(b["art"], b["item"], b.get("detail", "")[:200])).fetchone()
if not schon:
c.execute("INSERT INTO befunde(run_id, stufe, knoten, art, item, "
"detail, runde) VALUES(?,?,?,?,?,?,?)",
(t["run_id"], kn.stufe, kn.id, b["art"], b["item"],
b.get("detail", "")[:500], t["runde"]))
if erg.gate_status == "gruen":
# Stufe bestanden → offene Befunde dieser Stufe sind Geschichte
c.execute("UPDATE befunde SET status='behoben' WHERE status='offen' AND "
"stufe=? AND run_id IN (SELECT id FROM runs WHERE topic=?)",
(kn.stufe, t["topic"]))
if erg.gate_status == "rot":
# Stillstand: identischer Fingerprint n-mal hintereinander UND keine
# NEUEN Reparatur-Tasks entstanden (die Eskalations-Kette kann bei
# gleicher Befundmenge trotzdem vorankommen: fix → klaerung → neu)
alte = [r["fingerprint"] for r in c.execute(
"SELECT fingerprint FROM gate_laeufe WHERE topic=? AND knoten=? "
"ORDER BY runde DESC LIMIT ?",
(t["topic"], kn.id, config.STILLSTAND_N)).fetchall()]
if neu_eingefuegt == 0:
grund = (f"stillstand:{kn.id}:r{t['runde']}"
if len(alte) >= config.STILLSTAND_N and len(set(alte)) == 1
else f"gate_rot_ohne_reparatur:{kn.id}")
c.execute("UPDATE runs SET status='paused', grund=? WHERE id=?",
(grund, t["run_id"]))
def _pausieren(run_id: int, grund: str, status: str = "paused") -> None:
db.update("runs", "id=? AND status='running'", (run_id,),
status=status, grund=grund[:200])
def _abschliessen(g: graph.Graph, topic: str, run_id: int) -> None:
"""Kein Task mehr offen: done, wenn alle Gates grün + Montage fertig."""
fertig = db.one("SELECT id FROM tasks WHERE topic=? AND knoten='montage' "
"AND status='fertig'", topic)
offen_fehler = db.one("SELECT id FROM tasks WHERE topic=? AND status='fehler' "
"LIMIT 1", topic)
if fertig and not offen_fehler:
db.update("runs", "id=?", (run_id,), status="done", beendet=db.now())
else:
grund = "tasks_mit_fehler" if offen_fehler else "leerlauf_ohne_montage"
_pausieren(run_id, grund)
def _start_tasks(topic: str, run_id: int) -> None:
"""Initiale Tasks der Stufe 1 (idempotent — Resume ist ein No-Op)."""
for lens in config.RECHERCHE_LENSES:
db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
knoten="recherche_plan", item=f"r1:lens:{lens}", runde=1,
payload=db.j({"lens": lens}), erzeugt_von="start")