285 lines
12 KiB
Python
285 lines
12 KiB
Python
"""Budget-Stopp (hart) und Infra-Fail-closed (Lauf-Pause statt Teilergebnis)."""
|
|
|
|
import asyncio
|
|
|
|
import agents
|
|
import ledger
|
|
import llm
|
|
import pytest
|
|
from conftest import run_anlegen, topic_anlegen
|
|
|
|
|
|
async def test_budget_stoppt_hart(monkeypatch):
|
|
topic = topic_anlegen()
|
|
run = run_anlegen(topic, budget=120) # Fake-Call kostet 150 Tokens
|
|
ctx = llm.Kontext(run, topic, "minimax")
|
|
ctx.ebene = "test"
|
|
with pytest.raises(ledger.BudgetErschoepft):
|
|
await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
|
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
|
assert ledger.verbraucht(run) >= 120 # der Verbrauch bleibt sichtbar
|
|
|
|
|
|
async def test_infra_pause_statt_fail_open(monkeypatch):
|
|
topic = topic_anlegen()
|
|
run = run_anlegen(topic)
|
|
ctx = llm.Kontext(run, topic, "minimax")
|
|
ctx.ebene = "test"
|
|
|
|
async def immer_429(key, prompt, timeout, **kw):
|
|
return agents.AgentErgebnis(1, "", "HTTP 429: rate limited")
|
|
|
|
monkeypatch.setattr(agents, "run_agent", immer_429)
|
|
monkeypatch.setattr(llm, "INFRA_BACKOFF_BASE", 0.01)
|
|
with pytest.raises(llm.LaufPause):
|
|
await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
|
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
|
stati = [e["status"] for e in
|
|
__import__("db").query("SELECT status FROM events WHERE run_id=?", (run,))]
|
|
assert stati and all(s == "infra" for s in stati) # jeder Versuch im Ledger
|
|
|
|
|
|
async def test_budget_vorab_stoppt_ohne_neuen_call(monkeypatch):
|
|
"""Erschöpftes Budget → nächster call() wirft, BEVOR ein Agent-Call startet."""
|
|
import db
|
|
topic = topic_anlegen()
|
|
run = run_anlegen(topic, budget=100)
|
|
ctx = llm.Kontext(run, topic, "minimax")
|
|
ctx.ebene = "test"
|
|
# Budget künstlich überschreiten (ohne Agent-Call), Event-Zahl merken
|
|
ledger.log_call(run, ebene="test", stage="seed", status="ok",
|
|
tokens={"input": 200, "output": 0})
|
|
vorher = db.one("SELECT COUNT(*) AS n FROM events WHERE run_id=?", (run,))["n"]
|
|
|
|
async def darf_nicht_laufen(key, prompt, timeout, **kw):
|
|
raise AssertionError("Agent-Call trotz erschöpftem Budget gestartet")
|
|
|
|
monkeypatch.setattr(agents, "run_agent", darf_nicht_laufen)
|
|
with pytest.raises(ledger.BudgetErschoepft):
|
|
await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
|
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
|
nachher = db.one("SELECT COUNT(*) AS n FROM events WHERE run_id=?", (run,))["n"]
|
|
assert nachher == vorher # keine neue Ledger-Zeile = kein Call
|
|
|
|
|
|
async def test_hedge_gewinner_trotz_haupt_timeout(monkeypatch):
|
|
"""Haupt-Call läuft in Timeout, Zwilling (-h) liefert → Ergebnis statt Pause."""
|
|
topic = topic_anlegen()
|
|
run = run_anlegen(topic)
|
|
ctx = llm.Kontext(run, topic, "minimax")
|
|
ctx.ebene = "test"
|
|
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0.05)
|
|
|
|
async def haupt_stallt(key, prompt, timeout, **kw):
|
|
if key.endswith("-h"): # Zwilling liefert gültiges JSON
|
|
return agents.AgentErgebnis(0, "[]", "")
|
|
await asyncio.sleep(10) # Haupt hängt → wird nach Timeout gecancelt
|
|
return agents.AgentErgebnis(0, "[]", "")
|
|
|
|
monkeypatch.setattr(agents, "run_agent", haupt_stallt)
|
|
# timeout klein halten, damit der Haupt-Task zügig als Timeout endet
|
|
monkeypatch.setattr(llm, "timeout_fuer", lambda *a, **k: 0.2)
|
|
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
|
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
|
assert res == [] # Zwilling hat geliefert, kein LaufPause
|
|
|
|
|
|
async def test_hedge_verlierer_zaehlt_im_budget(monkeypatch):
|
|
"""Beide Hedge-Calls liefern → zwei Ledger-Zeilen, Verlierer-Tokens im Budget."""
|
|
import db
|
|
topic = topic_anlegen()
|
|
run = run_anlegen(topic)
|
|
ctx = llm.Kontext(run, topic, "minimax")
|
|
ctx.ebene = "test"
|
|
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0.05)
|
|
monkeypatch.setattr(llm, "timeout_fuer", lambda *a, **k: 0.4) # schwelle=0.2
|
|
|
|
async def haupt_leer_zwilling_ok(key, prompt, timeout, **kw):
|
|
if key.endswith("-h"): # Zwilling startet bei 0.2, liefert bei ~0.35
|
|
await asyncio.sleep(0.15)
|
|
return agents.AgentErgebnis(0, "[]", "")
|
|
await asyncio.sleep(0.25) # Haupt: fertig, aber leer (nicht ok) → Verlierer
|
|
return agents.AgentErgebnis(1, "", "leer", {"input": 40, "output": 20})
|
|
|
|
monkeypatch.setattr(agents, "run_agent", haupt_leer_zwilling_ok)
|
|
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
|
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
|
assert res == []
|
|
zeilen = db.query("SELECT status FROM events WHERE run_id=?", (run,))
|
|
assert any(z["status"] == "hedge" for z in zeilen) # Verlierer geloggt
|
|
assert ledger.verbraucht(run) >= 60 # Verlierer-Tokens (40+20) sichtbar
|
|
|
|
|
|
async def test_spawn_kill_bei_cancel(monkeypatch):
|
|
"""Task-Cancel (Hedge-Verlierer/Pause) killt den CLI-Prozess, kein Zombie."""
|
|
import os
|
|
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
|
|
monkeypatch.setattr(agents, "_ram_gate", lambda key: _true())
|
|
task = asyncio.ensure_future(
|
|
agents._spawn("t-cancel", ["sleep", "30"], None, 30))
|
|
for _ in range(200): # warten bis der Prozess registriert ist
|
|
if agents._prozesse:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
proc = next(iter(agents._prozesse.values()))
|
|
pid = proc.pid
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
for _ in range(200): # Kill ist async
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
with pytest.raises(ProcessLookupError):
|
|
os.kill(pid, 0) # Prozess ist tot
|
|
assert not agents._prozesse # aus der Registry entfernt
|
|
|
|
|
|
async def _true():
|
|
return True
|
|
|
|
|
|
async def test_opencode_timeout_loggt_tokens(monkeypatch):
|
|
"""opencode-Timeout: Tokens der Session werden geloggt, nicht als 0 verworfen."""
|
|
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
|
|
|
|
async def spawn_timeout(*a, **kw):
|
|
raise asyncio.TimeoutError
|
|
|
|
monkeypatch.setattr(agents, "_spawn", spawn_timeout)
|
|
monkeypatch.setattr(agents, "_opencode_tokens",
|
|
lambda key: {"input": 5000, "output": 3000})
|
|
res = await agents._opencode("t-oc", "prompt", 10, "minimax/M", "none")
|
|
assert res.err == "timeout" and not res.ok
|
|
assert res.tokens == {"input": 5000, "output": 3000} # nicht 0
|
|
|
|
|
|
def test_gather_nur_in_llm():
|
|
"""Rückfall-Guard: bare asyncio.gather darf NUR in llm.py stehen (sonst
|
|
laufen Geschwister-Tasks bei LaufPause/Budget weiter — nutze llm.alle)."""
|
|
from pathlib import Path
|
|
backend = Path(__file__).resolve().parent.parent / "backend"
|
|
treffer = []
|
|
for p in backend.glob("*.py"):
|
|
if p.name == "llm.py":
|
|
continue
|
|
for i, zeile in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
|
|
if "asyncio.gather" in zeile:
|
|
treffer.append(f"{p.name}:{i}")
|
|
assert not treffer, f"bare asyncio.gather außerhalb llm.py: {treffer}"
|
|
|
|
|
|
async def test_inhaltsfehler_kein_laufabbruch(monkeypatch):
|
|
topic = topic_anlegen()
|
|
run = run_anlegen(topic)
|
|
ctx = llm.Kontext(run, topic, "minimax")
|
|
ctx.ebene = "test"
|
|
|
|
async def unsinn(key, prompt, timeout, **kw):
|
|
return agents.AgentErgebnis(0, "kein json", "")
|
|
|
|
monkeypatch.setattr(agents, "run_agent", unsinn)
|
|
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
|
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
|
assert res is None # begrenzte Restarts, dann None — kein Absturz, keine Pause
|
|
|
|
|
|
async def test_stop_cancelt_verwaisten_haupt_task(monkeypatch):
|
|
"""Stop cancelt den Wartenden — der ensure_future-Haupt-Task muss MIT
|
|
sterben, sonst hält er Slot + Tokens bis zum Timeout (aak: 10 Waisen)."""
|
|
import asyncio
|
|
import agents
|
|
import llm
|
|
from conftest import run_anlegen, topic_anlegen
|
|
topic = topic_anlegen("orphan")
|
|
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
|
ctx.ebene = "inventar"
|
|
gestartet = asyncio.Event()
|
|
gecancelt = asyncio.Event()
|
|
|
|
async def langsamer_agent(key, prompt, timeout, **kw):
|
|
gestartet.set()
|
|
try:
|
|
await asyncio.sleep(60)
|
|
except asyncio.CancelledError:
|
|
gecancelt.set()
|
|
raise
|
|
return agents.AgentErgebnis(0, "nie", "")
|
|
|
|
monkeypatch.setattr(agents, "run_agent", langsamer_agent)
|
|
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0) # Pfad: return await haupt
|
|
task = asyncio.ensure_future(llm._roher_call("k", "p", 10, ctx, "judge", "none"))
|
|
await gestartet.wait()
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
await asyncio.wait_for(gecancelt.wait(), timeout=2) # Waise wurde mitgecancelt
|
|
|
|
|
|
async def test_pause_drainiert_statt_zu_canceln(monkeypatch):
|
|
"""ManuellePause in llm.alle: Laufende laufen zu Ende (Ergebnis zählt),
|
|
nichts wird gecancelt."""
|
|
import asyncio
|
|
import llm
|
|
fertig = {"langsam": False}
|
|
|
|
async def schnell_pausiert():
|
|
raise llm.ManuellePause("manuell pausiert")
|
|
|
|
async def langsam():
|
|
await asyncio.sleep(0.05)
|
|
fertig["langsam"] = True
|
|
|
|
import pytest
|
|
with pytest.raises(llm.ManuellePause):
|
|
await llm.alle([schnell_pausiert(), langsam()])
|
|
assert fertig["langsam"] is True # NICHT gecancelt — ausgelaufen
|
|
|
|
|
|
async def test_pause_beendet_lauf_als_paused():
|
|
"""Pause-Flag → nächster Agent liefert „pausiert" → Lauf endet 'paused';
|
|
Start setzt normal fort (Flag wird aufgehoben)."""
|
|
import db
|
|
import pipeline
|
|
from conftest import topic_anlegen
|
|
topic = topic_anlegen("pausetest")
|
|
run_id = pipeline.lauf_starten(topic)
|
|
pipeline.lauf_pausieren(topic) # vor dem ersten Call — deterministisch
|
|
await pipeline._laeufe[topic]
|
|
run = db.one("SELECT * FROM runs WHERE id=?", (run_id,))
|
|
assert run["status"] == "paused" and "pausiert" in run["grund"]
|
|
# Resume: Start hebt die Pause auf, Lauf läuft durch
|
|
pipeline.lauf_starten(topic)
|
|
await pipeline._laeufe[topic]
|
|
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
|
|
|
|
|
|
async def test_output_cap_ohne_neuversuch(monkeypatch):
|
|
"""stop=max_tokens ist deterministisch — sofort None statt 2 Neuversuche
|
|
(der Aufrufer halbiert den Chunk)."""
|
|
import agents
|
|
import db
|
|
import llm
|
|
from conftest import run_anlegen, topic_anlegen
|
|
topic = topic_anlegen("cap")
|
|
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
|
ctx.ebene = "inventar"
|
|
aufrufe = {"n": 0}
|
|
|
|
async def cap_agent(key, prompt, timeout, **kw):
|
|
aufrufe["n"] += 1
|
|
return agents.AgentErgebnis(1, "", "leere Antwort (stop=max_tokens)",
|
|
{"input": 100, "output": 32000})
|
|
monkeypatch.setattr(agents, "run_agent", cap_agent)
|
|
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0)
|
|
res = await llm.call(ctx, stage="extraktion", template="Atom-Extraktion",
|
|
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
|
assert res is None and aufrufe["n"] == 1 # genau EIN Versuch
|
|
ev = db.one("SELECT status, tok_out FROM events WHERE run_id=? AND stage='extraktion'",
|
|
(ctx.run_id,))
|
|
assert ev["status"] == "cap" and ev["tok_out"] == 32000
|