feat(brain): Multi-Threading via per-request project_id + per-project queue
Erster Schritt zum echten Multi-Threading fuer ARIA-Projekte. Kein globaler active_project-State mehr — jeder /chat-Request sagt selbst welche Buehne (project_id im Body). Verschiedene Projekte laufen parallel, gleiches Projekt queued via asyncio.Lock. Backend: - ChatIn.project_id: Client bestimmt pro Request wohin. Bridge routet. - /chat: async, holt per-Projekt asyncio.Lock. Requests fuers gleiche Projekt reihen sich in _project_pending ein, warten am Lock. Requests fuer verschiedene Projekte laufen echt parallel. - Neuer /projects/queue-status endpoint: pro Kontext (inkl. Hauptchat unter __main__): busy True/False + queue_size. Fuers UI-Status-Dots. - Agent.chat() nimmt project_id + pending_queue Params. Kein projects_mod.get_active() mehr im Hot-Path. Queue-Aware Prompting: - Wenn nach dem aktuellen Turn weitere Nachrichten in der Queue liegen, wird der System-Prompt um ein QUEUE-Segment erweitert mit Instruktion: „Bevor Du den aktuellen Task loesst, pruef die Queue — widerspricht/ annuliert eine spaetere Nachricht? Dann Skip-Antwort statt Doppelarbeit." - Beispiel: Task 'titelleiste rot' + Queue-Tail 'doch nicht, blau' → ARIA skipt rot, blau kommt als naechste Anfrage sauber durch. - Kein extra LLM-Call — reine Prompt-Injection. Project-Tools: - project_enter/exit sind jetzt UI-Signale (App wechselt Ansicht via project_changed event), aendern KEINEN Brain-State mehr. Der aktuelle Turn bleibt in seinem Chat-Kontext. - project_list zeigt keinen "AKTIV"-Marker mehr (nicht mehr sinnvoll). - projects_mod.set_active/get_active bleiben als Legacy-Helpers (kein Aufruf mehr aus dem Hot-Path). Bridge: - send_to_core packt project_id in den /chat-Body. - User-Backup-Eintrag tag't project_id sauber, keine Brain-Query mehr. Naechste Schritte (kommende Commits): - App: Focus-One-View mit Drawer + Status-Dots + OS-Push - Diagnostic: Dashboard-Stack mit Karten - Voice-Router: 30s-Sticky + Meta-Command-Interception im wakeword.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+119
-26
@@ -607,6 +607,11 @@ def memory_import_bootstrap(body: BootstrapBundle):
|
||||
class ChatIn(BaseModel):
|
||||
message: str
|
||||
source: str = "" # "app" / "diagnostic" / "stt" — optional
|
||||
# Multi-Threading: Client bestimmt pro Request welches Projekt (leer = Hauptchat).
|
||||
# Kein globaler active_project-State mehr im Brain — parallele Requests fuer
|
||||
# verschiedene Projekte laufen echt parallel, nur Requests fuers gleiche
|
||||
# Projekt queuen (per-Projekt-Lock).
|
||||
project_id: str = ""
|
||||
|
||||
|
||||
class ChatOut(BaseModel):
|
||||
@@ -614,36 +619,124 @@ class ChatOut(BaseModel):
|
||||
turns: int
|
||||
distilling: bool
|
||||
events: list = Field(default_factory=list)
|
||||
# Aktive Projekt-ID NACH dem Turn (kann durch project_enter/exit-Tools
|
||||
# waehrend des Turns gewechselt haben). Bridge gibt das an die Chat-
|
||||
# Bubble-Broadcasts weiter damit App + Diagnostic die Nachricht zum
|
||||
# richtigen Projekt-Block sortieren koennen.
|
||||
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
|
||||
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
|
||||
# UI landet.
|
||||
project_id: str = ""
|
||||
|
||||
|
||||
@app.post("/chat", response_model=ChatOut)
|
||||
def chat(body: ChatIn, background: BackgroundTasks):
|
||||
"""Hauptpfad. Antwort kommt synchron. Memory-Destillat laeuft
|
||||
im Hintergrund nachdem die Response rausging."""
|
||||
a = agent()
|
||||
try:
|
||||
reply = a.chat(body.message, source=body.source)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
except RuntimeError as exc:
|
||||
logger.error("chat fehlgeschlagen: %s", exc)
|
||||
raise HTTPException(502, str(exc))
|
||||
# Per-Projekt async-Locks fuer Queue-Behavior: Requests fuers gleiche Projekt
|
||||
# warten aufeinander (queue), Requests fuer verschiedene Projekte laufen echt
|
||||
# parallel. Hauptchat = Lock unter key "" (leerer String).
|
||||
_project_locks: dict[str, asyncio.Lock] = {}
|
||||
_project_locks_meta_lock = asyncio.Lock()
|
||||
# Pro Projekt eine Liste noch-nicht-verarbeiteter Requests. Wird beim Enqueue
|
||||
# ergaenzt, beim Fertig-Werden gepoppt. Ermoeglicht Queue-Aware-Prompting:
|
||||
# waehrend ARIA an Task N arbeitet, sieht sie N+1..N+k als System-Prompt-Hinweis
|
||||
# und kann entscheiden ob eine spaetere Nachricht die aktuelle korrigiert/
|
||||
# annuliert → dann Skip-Antwort statt Ausfuehren.
|
||||
_project_pending: dict[str, list[dict]] = {}
|
||||
|
||||
needs_distill = a.conversation.needs_distill()
|
||||
if needs_distill:
|
||||
background.add_task(a.distill_old_turns)
|
||||
return ChatOut(
|
||||
reply=reply,
|
||||
turns=len(a.conversation.turns),
|
||||
distilling=needs_distill,
|
||||
events=a.pop_events(),
|
||||
project_id=projects_mod.get_active(),
|
||||
)
|
||||
|
||||
async def _get_project_lock(project_id: str) -> asyncio.Lock:
|
||||
"""Holt (oder erzeugt) den asyncio.Lock fuer ein bestimmtes Projekt.
|
||||
Nutzt _project_locks_meta_lock zur Vermeidung von Race Conditions
|
||||
beim ersten-Zugriff pro Projekt."""
|
||||
async with _project_locks_meta_lock:
|
||||
lock = _project_locks.get(project_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_project_locks[project_id] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def _project_queue_snapshot() -> dict:
|
||||
"""Snapshot fuer /projects/queue-status: welche Projekte arbeiten gerade,
|
||||
wieviele wait-in-queue haben, welche sind idle."""
|
||||
out = {}
|
||||
# Zeige nur Kontexte mit Aktivitaet — locked oder pending
|
||||
seen: set = set()
|
||||
for pid, lock in _project_locks.items():
|
||||
pending = len(_project_pending.get(pid, []))
|
||||
is_busy = lock.locked()
|
||||
# busy: gerade in Verarbeitung. queue: N weitere warten dahinter.
|
||||
# Der Busy-Request zaehlt NICHT in queue (er ist ja aus pending schon "raus").
|
||||
out[pid or "__main__"] = {
|
||||
"busy": is_busy,
|
||||
"queue_size": max(0, pending - (1 if is_busy else 0)),
|
||||
}
|
||||
seen.add(pid)
|
||||
for pid, pend in _project_pending.items():
|
||||
if pid in seen:
|
||||
continue
|
||||
out[pid or "__main__"] = {"busy": False, "queue_size": len(pend)}
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/chat", response_model=ChatOut)
|
||||
async def chat(body: ChatIn, background: BackgroundTasks):
|
||||
"""Hauptpfad. Antwort kommt synchron. Memory-Destillat laeuft
|
||||
im Hintergrund nachdem die Response rausging.
|
||||
|
||||
Multi-Threading: Requests fuers gleiche Projekt (project_id gleich)
|
||||
laufen serialisiert durch den per-Projekt-Lock — Queue-Behavior.
|
||||
Verschiedene Projekte laufen parallel."""
|
||||
pid = (body.project_id or "").strip()
|
||||
lock = await _get_project_lock(pid)
|
||||
# Vor dem Lock in die Pending-Liste, damit die verlaufende Task sehen kann
|
||||
# was NACH ihr in der Warteschlange steht (Queue-Aware Prompting).
|
||||
import uuid as _uuid
|
||||
req_id = _uuid.uuid4().hex
|
||||
_project_pending.setdefault(pid, []).append({
|
||||
"id": req_id, "message": body.message, "source": body.source,
|
||||
})
|
||||
try:
|
||||
async with lock:
|
||||
# Snapshot: was liegt NACH mir in der Queue?
|
||||
after_me = [
|
||||
e["message"] for e in _project_pending.get(pid, [])
|
||||
if e["id"] != req_id
|
||||
]
|
||||
a = agent()
|
||||
try:
|
||||
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
|
||||
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
|
||||
loop = asyncio.get_running_loop()
|
||||
reply = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: a.chat(
|
||||
body.message, source=body.source, project_id=pid,
|
||||
pending_queue=after_me,
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
except RuntimeError as exc:
|
||||
logger.error("chat fehlgeschlagen: %s", exc)
|
||||
raise HTTPException(502, str(exc))
|
||||
|
||||
needs_distill = a.conversation.needs_distill()
|
||||
if needs_distill:
|
||||
background.add_task(a.distill_old_turns)
|
||||
return ChatOut(
|
||||
reply=reply,
|
||||
turns=len(a.conversation.turns),
|
||||
distilling=needs_distill,
|
||||
events=a.pop_events(),
|
||||
project_id=pid,
|
||||
)
|
||||
finally:
|
||||
_project_pending[pid] = [
|
||||
e for e in _project_pending.get(pid, []) if e["id"] != req_id
|
||||
]
|
||||
|
||||
|
||||
@app.get("/projects/queue-status")
|
||||
def projects_queue_status():
|
||||
"""Snapshot: fuer jeden Projekt-Kontext (inkl. Hauptchat unter __main__)
|
||||
- busy: True wenn gerade ein Request in Verarbeitung
|
||||
- queue_size: wieviele weitere warten dahinter"""
|
||||
return {"contexts": _project_queue_snapshot()}
|
||||
|
||||
|
||||
# ── Projekte ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user