update
This commit is contained in:
@@ -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: 160–230 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)
|
||||
|
||||
Reference in New Issue
Block a user