feat(multitask): kontext-getaggte Activity + kontext-scoped Cancel (Restpunkte 1+2)

Beide Restpunkte teilen eine Verkabelung: die projectId fliesst jetzt bis zum
Proxy und zurueck in die agent_activity-Events.

Restpunkt 1 — per-Kontext Activity-Indikator:
- Brain: proxy_client.chat_full(project_id) → payload.aria_project_id;
  agent.py gibt active_project_id mit.
- Proxy routes.js: liest aria_project_id, taggt Tool-/Stream-Hooks damit,
  trackt Subprozesse pro Kontext.
- Bridge: _emit_activity(project_id) + payload.projectId; /internal/agent-activity
  reicht projectId durch; send_to_core/_process_core_response taggen thinking/idle
  mit dem Turn-Kontext.
- App: agentActivityByCtx-Map; „ARIA denkt"-Indikator zeigt nur den
  fokussierten Kontext statt global zu flackern.

Restpunkt 2 — kontext-scoped Cancel (Barge-In):
- Proxy: neuer /cancel {projectId} killt NUR die Subprozesse eines Kontexts
  (_cancelByProject); /cancel-all bleibt fuers NOT-AUS.
- Bridge: soft cancel_request → _cancel_proxy_for_project(projectId) statt des
  toten Diagnostic /api/cancel. Hard bleibt /cancel-all.
- App: cancel_request + Abbrechen-Button tragen die fokussierte projectId.

Damit laufen Kontexte in der App echt parallel: Arbeit in Projekt A wird durch
Senden/Abbrechen in Hauptchat oder Projekt B nicht mehr abgewuergt, und der
Indikator gehoert zum sichtbaren Chat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 21:29:59 +02:00
co-authored by Claude Opus 4.8
parent fa0eb13e0c
commit 3fbd7eb9fb
5 changed files with 133 additions and 35 deletions
+48 -14
View File
@@ -1357,7 +1357,7 @@ class ARIABridge:
# _last_chat_final_at bewusst NICHT setzen: die 3s-Cooldown war fuer
# trailing OpenClaw-Activity-Events; bei Voice-Chat wuerde sie die
# naechste thinking-Welle unterdruecken.
await self._emit_activity("idle", "")
await self._emit_activity("idle", "", project_id=turn_pid)
# ── Mode Persistence (global, nicht pro Geraet) ──────
_MODE_FILE = "/shared/config/mode.json"
@@ -1572,7 +1572,7 @@ class ARIABridge:
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
# damit der State-Cache fuer die spaetere idle-Dedup richtig steht.
await self._emit_activity("thinking", "")
await self._emit_activity("thinking", "", project_id=project_id)
def _do_call():
try:
@@ -1591,7 +1591,7 @@ class ARIABridge:
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_call)
if status != 200:
logger.error("[brain] /chat fehlgeschlagen: status=%s body=%s", status, body[:200])
await self._emit_activity("idle", "")
await self._emit_activity("idle", "", project_id=project_id)
await self._send_to_rvs({
"type": "chat",
"payload": {
@@ -1606,13 +1606,13 @@ class ARIABridge:
data = json.loads(body)
except Exception:
logger.error("[brain] /chat lieferte ungueltiges JSON: %s", body[:200])
await self._emit_activity("idle", "")
await self._emit_activity("idle", "", project_id=project_id)
return
reply = (data.get("reply") or "").strip()
if not reply:
logger.warning("[brain] /chat: leerer Reply")
await self._emit_activity("idle", "")
await self._emit_activity("idle", "", project_id=project_id)
return
# Projekt-Kontext des Turns — wird an _process_core_response weiter-
@@ -1688,7 +1688,7 @@ class ARIABridge:
await self._process_core_response(reply, {"projectId": turn_project_id})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "")
await self._emit_activity("idle", "", project_id=project_id)
# Originaler Fallback-Send (toter Code, _emit_activity uebernimmt jetzt)
await self._send_to_rvs({
"type": "agent_activity",
@@ -2008,10 +2008,14 @@ class ARIABridge:
logger.warning("[rvs] NOT-AUS — hard cancel: Diagnostic /api/cancel + Proxy /cancel-all")
await self._cancel_via_diagnostic()
await self._cancel_proxy_subprocesses()
await self._emit_activity("idle", "")
else:
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
await self._cancel_via_diagnostic()
await self._emit_activity("idle", "")
# Barge-In: nur den fokussierten Kontext abbrechen (projectId von
# der App), damit parallele Arbeit in anderen Kontexten weiterlaeuft.
cancel_pid = str(payload.get("projectId") or "")
logger.info("[rvs] Cancel-Request (kontext-scoped) project=%s", cancel_pid or "(main)")
await self._cancel_proxy_for_project(cancel_pid)
await self._emit_activity("idle", "", project_id=cancel_pid)
return
elif msg_type == "audio_pcm":
@@ -3518,7 +3522,30 @@ class ARIABridge:
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request)
logger.warning("[NOT-AUS] proxy /cancel-all: %s %s", status, body)
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False) -> None:
async def _cancel_proxy_for_project(self, project_id: str) -> None:
"""Kontext-scoped Barge-In: killt NUR die Subprozesse EINES Kontexts
(leer = Hauptchat) ueber den proxy-internen /cancel-Endpoint. So bricht
eine Nachricht in Kontext A nicht die parallele Arbeit in Kontext B ab."""
url = os.environ.get("PROXY_INTERNAL_URL", "http://aria-proxy:3457") + "/cancel"
data = json.dumps({"projectId": project_id or ""}).encode("utf-8")
def _do_request():
try:
req = urllib.request.Request(
url, method="POST", data=data,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=3) as resp:
return resp.status, resp.read().decode("utf-8", "ignore")[:200]
except Exception as e:
return f"error: {e}", ""
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request)
logger.info("[cancel] proxy /cancel project=%s: %s %s",
project_id or "(main)", status, body)
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False,
project_id: str = "") -> None:
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
Trailing Agent-Events nach chat:final werden 3s lang unterdrueckt
@@ -3527,18 +3554,23 @@ class ARIABridge:
force=True: kein State-Dedup — wird vom Proxy-Tool-Hook genutzt
damit auch wiederholte gleiche Tool-Aufrufe (z.B. 3x Bash
hintereinander) im Gedanken-Stream als eigene Eintraege sichtbar
bleiben."""
bleiben.
project_id: welcher Kontext arbeitet (leer = Hauptchat). App/Diagnostic
zeigen den Indikator damit pro Kontext statt global (Multi-Threading)."""
if activity != "idle" and self._last_chat_final_at > 0:
since_final = asyncio.get_event_loop().time() - self._last_chat_final_at
if since_final < 3.0:
return
state = (activity, tool)
# Dedup schliesst project_id ein — sonst wuerde ein Kontext-Wechsel bei
# gleichem (activity, tool) verschluckt.
state = (activity, tool, project_id)
if not force and state == self._last_activity_state:
return
self._last_activity_state = state
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": activity, "tool": tool},
"payload": {"activity": activity, "tool": tool, "projectId": project_id or ""},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
@@ -3695,9 +3727,11 @@ class ARIABridge:
if not tool:
await _send_response(writer, 400, {"error": "tool erforderlich"})
return
tool_pid = str(data.get("projectId") or "")
# Force-emit (kein Dedup): User soll JEDEN Tool-Call sehen
# selbst wenn derselbe Name zweimal in Folge kommt.
asyncio.create_task(self._emit_activity("tool", tool, force=True))
asyncio.create_task(self._emit_activity("tool", tool, force=True,
project_id=tool_pid))
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/agent-stream":
# Vom Proxy gefeuert: voller Live-Stream der Claude-Code-