This commit is contained in:
Team3
2026-07-04 19:20:48 +02:00
parent 92c69c1561
commit c05421a8c1
14 changed files with 695 additions and 277 deletions

View File

@@ -164,10 +164,18 @@ _relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
from config import MAX_RESTARTS as _MAX_RESTARTS # noqa: E402 — zentral tunebar
from config import MAX_RESTARTS as _MAX_RESTARTS, HEDGE_NACH_S as _HEDGE_NACH_S # noqa: E402 — zentral tunebar
# Detached Nachzügler-Tasks (late-Fold): Referenz gegen GC, Aufräumen via done-callback.
_NACHZUEGLER: set[asyncio.Task] = set()
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None, min_runtime: int | None = None, max_runtime: int | None = None) -> list | None:
def _detached(task: asyncio.Task) -> None:
_NACHZUEGLER.add(task)
task.add_done_callback(_NACHZUEGLER.discard)
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None, min_runtime: int | None = None, max_runtime: int | None = None, late=None) -> list | None:
"""Starts all slots in parallel and collects `quorum` valid results.
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
@@ -185,24 +193,59 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
elapses while agents are still running — gives them time to search thoroughly.
`max_runtime` (wall-clock from start): hard cap — returns whatever is collected
(or None if nothing), killing the rest. Both default off; only Research sets them.
`late(value)` (async): Nachzügler werden beim Quorum-Return NICHT gekillt, sondern
laufen detached weiter; jedes noch eintreffende valide Ergebnis geht an `late`.
Ersetzt den grace-Timer der Finder-Runden — der hielt die Runde bis 300 s offen,
nur damit die dritte Stimme zählt (gemessen: 73 s Warten pro Runde).
"""
attempts = {i: 0 for i in range(len(slots))}
tasks: dict[asyncio.Task, int] = {}
keys: dict[asyncio.Task, str] = {}
born: dict[asyncio.Task, float] = {}
hedged: set[int] = set() # slot got its one twin — no hedge cascades
fertig: set[int] = set() # slot delivered a valid result (late twins are ignored)
loop = asyncio.get_running_loop()
start = loop.time()
min_deadline = start + min_runtime if min_runtime else None
max_deadline = start + max_runtime if max_runtime else None
deadline: float | None = None
def spawn(i: int) -> None:
def spawn(i: int, suffix: str = "") -> None:
slot = slots[i]
lbl = slot.get("label") or (label if len(slots) == 1 else f"{label} {i + 1}")
key = slot["key"] + suffix
task = asyncio.create_task(run_agent(
slot["key"], slot["prompt"], timeout,
key, slot["prompt"], timeout,
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
scope=topic, on_line=slot.get("on_line"), label=lbl,
))
tasks[task] = i
keys[task] = key
born[task] = loop.time()
spaet: set[int] = set() # je Slot zählt nur EIN spätes Ergebnis (Hedge-Zwilling = Echo)
def _detach_rest() -> None:
"""Quorum steht: Nachzügler an `late` übergeben statt killen (nur Erfolgs-Return)."""
if late is None:
return
for t, i in list(tasks.items()):
tasks.pop(t)
keys.pop(t, None)
born.pop(t, None)
async def _warte(t=t, i=i):
try:
r = await t
if i in spaet:
return
if r and r[0] == 0 and (val := slots[i]["payload"](r)) is not None:
spaet.add(i)
await late(val)
except (asyncio.CancelledError, Exception): # noqa: BLE001 — Nachzügler sind best-effort
pass
_detached(asyncio.create_task(_warte()))
for i in range(len(slots)):
spawn(i)
@@ -218,8 +261,20 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
return results or None
min_ok = min_deadline is None or loop.time() >= min_deadline
if deadline is not None and len(results) >= quorum and loop.time() >= deadline and min_ok:
_detach_rest()
return results
# Wake up for the earliest relevant deadline (grace, min, or max).
# Hedge: a slot running HEDGE_NACH_S without result gets ONE parallel twin
# (key -h) — first valid result wins. Stalled provider calls burned the full
# timeout cap before the restart even began (measured: 160230 s per stall).
if _HEDGE_NACH_S:
now = loop.time()
for t in [t for t in list(tasks) if tasks[t] not in hedged | fertig
and now - born[t] >= _HEDGE_NACH_S]:
i = tasks[t]
hedged.add(i)
spawn(i, suffix="-h")
_log(topic, f"{label} {i + 1}: {_HEDGE_NACH_S}s ohne Ergebnis — Hedge-Zwilling gestartet")
# Wake up for the earliest relevant deadline (grace, min, max, or next hedge).
waits = []
if deadline is not None and len(results) >= quorum:
waits.append(deadline - loop.time())
@@ -227,12 +282,21 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
waits.append(min_deadline - loop.time())
if max_deadline is not None:
waits.append(max_deadline - loop.time())
if _HEDGE_NACH_S:
naechste = [born[t] + _HEDGE_NACH_S - loop.time() for t in tasks
if tasks[t] not in hedged | fertig]
if naechste:
waits.append(max(0.0, min(naechste)))
wait_timeout = max(0.0, min(waits)) if waits else None
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout)
if not done:
continue
for task in done:
i = tasks.pop(task)
keys.pop(task, None)
born.pop(task, None)
if i in fertig:
continue # späte Zwillinge eines bereits gewerteten Slots
payload, err = None, None
try:
result = task.result()
@@ -249,6 +313,10 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
if payload is not None:
results.append(payload)
fertig.add(i)
for t2 in [t2 for t2, i2 in tasks.items() if i2 == i]: # Zwilling killen
kill_process(keys.get(t2, slots[i]["key"]))
t2.cancel()
if grace is not None and deadline is None:
deadline = loop.time() + grace
_log(topic, f"{label}: first result — grace {grace}s running")
@@ -256,23 +324,26 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
on_update(len(results))
if (len(results) >= quorum and (grace is None or loop.time() >= deadline)
and (min_deadline is None or loop.time() >= min_deadline)):
_detach_rest()
return results
continue
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}): {err}")
attempts[i] += 1
# If the minimum already stands, restarts are pointless — the restart
# would be killed at the grace end anyway.
# would be killed at the grace end anyway. A still-running twin IS the retry.
enough = grace is not None and len(results) >= quorum
if attempts[i] <= _MAX_RESTARTS and not enough and not (cancelled and cancelled()):
zwilling = any(i2 == i for i2 in tasks.values())
if attempts[i] <= _MAX_RESTARTS and not enough and not zwilling and not (cancelled and cancelled()):
spawn(i)
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
_detach_rest()
return results
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
return None
finally:
for task, i in tasks.items():
kill_process(slots[i]["key"])
kill_process(keys.get(task, slots[i]["key"]))
task.cancel()
if tasks:
await asyncio.gather(*tasks.keys(), return_exceptions=True)