feat(compute): Worker-Selbstanmeldung ueber RVS + Flotten-Anzeige (Stage 2)

Jeder GPU-Dienst meldet sich beim Connect mit worker_hello {instanceId,
service, node, gpus, model} und haelt die Registry per periodischem
worker_ping {instanceId, busy} (~10s) frisch. So weiss ARIA, was wo laeuft.

- xtts/{voxtral,whisper,f5tts}/bridge.py + llm-adapter/adapter.py:
  INSTANCE_ID=service@NODE_NAME, _worker_register()-Coroutine (hello + ping),
  busy-Quelle je Worker (aktive STT-Sessions / TTS-Render / in-flight LLM);
  Task sauber gecancelt bei Reconnect.
- bridge/aria_bridge.py: self._workers-Registry + Handler worker_hello/
  worker_ping (spiegelt sat_hello), _worker_list() (35s-Offline-TTL),
  _pick_worker() (Round-Robin freie Instanz, fuer Stage-3-Routing),
  /internal/worker-list-Endpoint.
- diagnostic/server.js: workers-Map, worker_hello/worker_ping-Tracking,
  worker_update-Broadcast + worker_list-Action + on-connect-Snapshot.
- diagnostic/index.html: "Compute-Flotte"-Panel im Satelliten-Tab — pro Node
  gruppiert, mit Dienst/Modell/GPU und frei/beschaeftigt/offline-Status.

Stage 2 von 3. Reine Sichtbarkeit, kein Routing-Verhalten geaendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 12:22:47 +02:00
co-authored by Claude Opus 4.8
parent e75f1eeb6a
commit f2ead1242f
7 changed files with 330 additions and 0 deletions
+80
View File
@@ -766,6 +766,11 @@ class ARIABridge:
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
self._satellites: dict[str, dict] = {}
self._pending_sat: dict[str, asyncio.Future] = {}
# Compute-Fleet: GPU-Worker (voxtral/whisper/f5tts/llm) melden sich per
# worker_hello, halten sich per worker_ping frisch. instanceId →
# {service, node, gpus, model, busy, last_seen}. Genutzt fuer die
# Diagnostic-Flotten-Anzeige und (Stage 3) targetInstance-Routing.
self._workers: dict[str, dict] = {}
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
self._pending_flux: dict[str, asyncio.Future] = {}
@@ -3528,6 +3533,40 @@ class ARIABridge:
self._satellites[sid]["caps"], self._satellites[sid]["control"])
return
elif msg_type == "worker_hello":
iid = (payload.get("instanceId") or "").strip()
if iid:
prev = self._workers.get(iid, {})
self._workers[iid] = {
"instanceId": iid,
"service": payload.get("service") or "",
"node": payload.get("node") or "",
"gpus": payload.get("gpus") or "",
"model": payload.get("model") or "",
"busy": bool(prev.get("busy", False)),
"last_seen": time.time(),
}
logger.info("[worker] online: %s (service=%s node=%s gpus=%s model=%s)",
iid, self._workers[iid]["service"], self._workers[iid]["node"],
self._workers[iid]["gpus"] or "?", self._workers[iid]["model"] or "?")
return
elif msg_type == "worker_ping":
iid = (payload.get("instanceId") or "").strip()
if iid:
w = self._workers.get(iid)
if w is None:
# Ping ohne vorheriges hello (Bridge-Neustart) → Minimal-Eintrag,
# service aus der instanceId ableiten (Form: "service@node").
svc = iid.split("@", 1)[0]
w = self._workers[iid] = {
"instanceId": iid, "service": svc, "node": "", "gpus": "",
"model": "", "busy": False, "last_seen": 0.0,
}
w["busy"] = bool(payload.get("busy", False))
w["last_seen"] = time.time()
return
elif msg_type in ("sat_devices", "sat_result"):
req_id = payload.get("requestId", "")
future = self._pending_sat.get(req_id)
@@ -4435,6 +4474,9 @@ class ARIABridge:
elif method == "POST" and path == "/internal/satellite-list":
# Brain fragt: welche Satelliten/Netze sind online + Capabilities.
await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()})
elif method in ("GET", "POST") and path == "/internal/worker-list":
# Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte).
await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()})
elif method == "POST" and path == "/internal/satellite":
# Brain-Tool: Discovery oder Command an einen Satelliten.
# body: {op:'discover'|'command', satellite, device?, action?, params?}
@@ -4684,6 +4726,44 @@ class ARIABridge:
})
return out
# worker_ping kommt alle ~10s; nach 35s ohne Ping gilt ein Worker als offline.
WORKER_OFFLINE_S = 35
def _worker_list(self) -> list[dict]:
"""Bekannte Compute-Worker (Flotte). online = kuerzlich per Ping gesehen."""
now = time.time()
out = []
for w in self._workers.values():
out.append({
"instanceId": w["instanceId"], "service": w.get("service") or "",
"node": w.get("node") or "", "gpus": w.get("gpus") or "",
"model": w.get("model") or "", "busy": bool(w.get("busy")),
"online": (now - w.get("last_seen", 0)) < self.WORKER_OFFLINE_S,
})
return out
def _pick_worker(self, service: str) -> Optional[str]:
"""Waehlt eine online, moeglichst freie Instanz des Diensts (Round-Robin
ueber die freien). Gibt die instanceId oder None (keine online). Fuer
Stage-3-Routing (targetInstance)."""
now = time.time()
online = [w for w in self._workers.values()
if w.get("service") == service
and (now - w.get("last_seen", 0)) < self.WORKER_OFFLINE_S]
if not online:
return None
free = [w for w in online if not w.get("busy")]
pool = free or online # alle busy → trotzdem eine nehmen (least-bad)
# Round-Robin: rotierender Zeiger pro Dienst.
rr = getattr(self, "_worker_rr", None)
if rr is None:
rr = self._worker_rr = {}
idx = rr.get(service, 0) % len(pool)
rr[service] = idx + 1
chosen = pool[idx]
chosen["busy"] = True # optimistisch, bis der naechste Ping korrigiert
return chosen["instanceId"]
async def _satellite_request(self, op: str, satellite: str = "",
device: str = "", action: str = "",
params: Optional[dict] = None,