fix(trigger): Antwort landet im Projekt des Timers — und der Zustand luegt nicht mehr

Zwei Fehler, die zusammen wie "der Timer hat nicht gefeuert" aussahen.

1) Trigger hatten KEIN Projekt. Beim Feuern ging die Antwort ohne projectId
   durch _process_core_response — also immer in den Hauptchat, auch wenn der
   Timer mitten in einem Projekt-Thread gestellt wurde. Im Projekt blieb es
   still, und es sah aus wie "nicht gefeuert". Jetzt wird das Projekt des
   Requests mit angelegt, der getriggerte Turn laeuft darin, und die Antwort
   kommt dort zurueck.

2) trigger_list zeigte nur "aktiv" / "DEAKTIVIERT" und verschwieg fire_count
   und last_fired_at. Ein One-Shot-Timer wird NACH dem Feuern automatisch
   deaktiviert — "hat sauber gefeuert" und "war nie an" sahen damit identisch
   aus. ARIA hat genau das falsch gelesen und berichtet, sie habe vergessen den
   Timer zu aktivieren, obwohl er puenktlich gefeuert hatte. Die Ausgabe nennt
   jetzt ausdruecklich "SCHON GEFEUERT (Nx, zuletzt <Zeit>)" plus den Kontext,
   in dem die Antwort landet.

Nachgewiesen am Fall flugwetter-whv-freitag-0900: gefeuert 07:00:05 UTC
(= 09:00 lokal), Antwort 46s spaeter, fire_count=1 — aber active=false im
Manifest und die Antwort im Hauptchat statt im Projekt-Thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:17:35 +02:00
co-authored by Claude Opus 5
parent e628170760
commit 12b8362d26
4 changed files with 75 additions and 18 deletions
+33 -7
View File
@@ -3246,8 +3246,13 @@ class Agent:
if name in _UI_ACTIONS: if name in _UI_ACTIONS:
action, keys = _UI_ACTIONS[name] action, keys = _UI_ACTIONS[name]
params = {k: arguments[k] for k in keys if arguments.get(k) is not None} params = {k: arguments[k] for k in keys if arguments.get(k) is not None}
# Sprachausgabe braucht laenger: der Agent meldet erst Erfolg,
# wenn wirklich Ton laeuft (TTS-Render + Uebertragung). Mit den
# 20s der UI-Aktionen lief die ehrliche Antwort in den Timeout.
_timeout = 40 if action in ("speak", "notify") else 20
result = _post("/internal/host", result = _post("/internal/host",
{"host": host, "action": action, "params": params}, 20) {"host": host, "action": action, "params": params},
_timeout)
if not result.get("ok"): if not result.get("ok"):
return f"FEHLER: {result.get('error')}" return f"FEHLER: {result.get('error')}"
r = result.get("result") or {} r = result.get("result") or {}
@@ -3512,6 +3517,10 @@ class Agent:
fires_at_iso=fires_at_iso, fires_at_iso=fires_at_iso,
message=arguments["message"], message=arguments["message"],
author="aria", author="aria",
# Projekt DIESES Requests mitnehmen: beim Feuern soll die
# Antwort dort landen, wo der Timer gestellt wurde — nicht
# im Hauptchat.
project_id=project_id or "",
) )
self._pending_events.append({ self._pending_events.append({
"type": "trigger_created", "type": "trigger_created",
@@ -3527,6 +3536,7 @@ class Agent:
check_interval_sec=int(arguments.get("check_interval_sec", 300)), check_interval_sec=int(arguments.get("check_interval_sec", 300)),
throttle_sec=int(arguments.get("throttle_sec", 3600)), throttle_sec=int(arguments.get("throttle_sec", 3600)),
author="aria", author="aria",
project_id=project_id or "",
) )
self._pending_events.append({ self._pending_events.append({
"type": "trigger_created", "type": "trigger_created",
@@ -3599,13 +3609,29 @@ class Agent:
return "(keine Trigger vorhanden)" return "(keine Trigger vorhanden)"
lines = [] lines = []
for t in items: for t in items:
state = "aktiv" if t.get("active", True) else "DEAKTIVIERT" # WICHTIG: ein One-Shot-Timer wird NACH dem Feuern automatisch
if t["type"] == "timer": # deaktiviert. Stand hier nur "DEAKTIVIERT", sahen "hat sauber
lines.append(f"- {t['name']} (timer, {state}): feuert {t.get('fires_at')} — \"{t.get('message','')[:50]}\"") # gefeuert" und "war nie an" identisch aus — und genau das hat
elif t["type"] == "watcher": # schon zu einer falschen Auskunft gefuehrt ("ich habe
lines.append(f"- {t['name']} (watcher, {state}): cond=\"{t.get('condition')}\", throttle={t.get('throttle_sec')}s") # vergessen ihn zu aktivieren", obwohl er gefeuert hatte).
# Darum: gefeuert IMMER explizit mit Zeitpunkt und Zaehler.
fired_n = int(t.get("fire_count", 0) or 0)
last_fired = t.get("last_fired_at") or ""
if fired_n > 0:
state = (f"SCHON GEFEUERT ({fired_n}x, zuletzt {last_fired})"
+ ("" if t.get("active", True) else ", danach automatisch deaktiviert"))
elif t.get("active", True):
state = "aktiv, noch nicht gefeuert"
else: else:
lines.append(f"- {t['name']} ({t['type']}, {state})") state = "DEAKTIVIERT, nie gefeuert"
# Wohin die Antwort beim Feuern geht — leer = Hauptchat.
where = f" [Kontext: {t.get('project_id') or 'Hauptchat'}]"
if t["type"] == "timer":
lines.append(f"- {t['name']} (timer, {state}){where}: feuert {t.get('fires_at')} — \"{t.get('message','')[:50]}\"")
elif t["type"] == "watcher":
lines.append(f"- {t['name']} (watcher, {state}){where}: cond=\"{t.get('condition')}\", throttle={t.get('throttle_sec')}s")
else:
lines.append(f"- {t['name']} ({t['type']}, {state}){where}")
return "\n".join(lines) return "\n".join(lines)
if name == "oauth_register_provider": if name == "oauth_register_provider":
svc = (arguments.get("service") or "").strip() svc = (arguments.get("service") or "").strip()
+15 -4
View File
@@ -36,7 +36,8 @@ TICK_SEC = 8
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090") BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list) -> None: def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list,
project_id: str = "") -> None:
"""POSTed eine Trigger-Antwort an die Bridge fuer RVS-Broadcast + TTS. """POSTed eine Trigger-Antwort an die Bridge fuer RVS-Broadcast + TTS.
Synchron via urllib — wird per run_in_executor aus dem async-Loop Synchron via urllib — wird per run_in_executor aus dem async-Loop
@@ -47,6 +48,10 @@ def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list) ->
"trigger_name": trigger_name, "trigger_name": trigger_name,
"type": ttype, "type": ttype,
"events": events or [], "events": events or [],
# Kontext, in dem der Trigger angelegt wurde. Leer = Hauptchat. Ohne das
# landete JEDE Trigger-Antwort im Hauptchat, auch wenn der Timer mitten
# in einem Projekt-Thread gestellt wurde.
"projectId": project_id or "",
}).encode("utf-8") }).encode("utf-8")
url = f"{BRIDGE_URL}/internal/trigger-fired" url = f"{BRIDGE_URL}/internal/trigger-fired"
try: try:
@@ -156,17 +161,23 @@ async def _fire(trigger: dict, agent_factory) -> None:
# Executor auslagern, damit der Event-Loop frei bleibt. # Executor auslagern, damit der Event-Loop frei bleibt.
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
# Projekt, in dem der Trigger angelegt wurde — der Turn laeuft darin, und
# die Antwort geht auch dorthin zurueck (statt immer in den Hauptchat).
trigger_pid = str(trigger.get("project_id") or "")
def _run_turn(): def _run_turn():
a = agent_factory() a = agent_factory()
rep, *_rest = a.chat(prompt, source="trigger") rep, *_rest = a.chat(prompt, source="trigger", project_id=trigger_pid)
return rep, a.pop_events() return rep, a.pop_events()
reply, events = await loop.run_in_executor(None, _run_turn) reply, events = await loop.run_in_executor(None, _run_turn)
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80]) logger.info("[trigger] %s gefeuert (project=%s) → ARIA-Reply: %s",
name, trigger_pid or "(main)", reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]}) triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
# Reply an die Bridge pushen, damit App + Diagnostic + TTS sie kriegen. # Reply an die Bridge pushen, damit App + Diagnostic + TTS sie kriegen.
# Ohne diesen Push wuerde die Antwort nur im Brain-Log landen. # Ohne diesen Push wuerde die Antwort nur im Brain-Log landen.
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype, events) await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype,
events, trigger_pid)
except Exception as e: except Exception as e:
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e) logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
triggers_mod.append_log(name, {"event": "error", "error": str(e)[:300]}) triggers_mod.append_log(name, {"event": "error", "error": str(e)[:300]})
+12 -2
View File
@@ -146,6 +146,7 @@ def create_timer(
fires_at_iso: str, fires_at_iso: str,
message: str, message: str,
author: str = "aria", author: str = "aria",
project_id: str = "",
) -> dict: ) -> dict:
_safe_name(name) _safe_name(name)
if _path(name).exists(): if _path(name).exists():
@@ -164,11 +165,16 @@ def create_timer(
"created_at": _now_iso(), "created_at": _now_iso(),
"fires_at": fires_at_iso, "fires_at": fires_at_iso,
"message": message, "message": message,
# Projekt, in dem der Trigger angelegt wurde. Ohne das landete die
# Antwort beim Feuern IMMER im Hauptchat — auch wenn der Timer mitten
# in einem Projekt-Thread gestellt wurde. Leer = Hauptchat.
"project_id": project_id or "",
"fire_count": 0, "fire_count": 0,
"last_fired_at": None, "last_fired_at": None,
} }
write(name, data) write(name, data)
logger.info("Trigger angelegt: %s (timer, fires_at=%s)", name, fires_at_iso) logger.info("Trigger angelegt: %s (timer, fires_at=%s, project=%s)",
name, fires_at_iso, project_id or "(main)")
return data return data
@@ -179,6 +185,7 @@ def create_watcher(
check_interval_sec: int = 300, check_interval_sec: int = 300,
throttle_sec: int = 3600, throttle_sec: int = 3600,
author: str = "aria", author: str = "aria",
project_id: str = "",
) -> dict: ) -> dict:
_safe_name(name) _safe_name(name)
if _path(name).exists(): if _path(name).exists():
@@ -200,12 +207,15 @@ def create_watcher(
"check_interval_sec": int(check_interval_sec), "check_interval_sec": int(check_interval_sec),
"throttle_sec": int(throttle_sec), "throttle_sec": int(throttle_sec),
"message": message, "message": message,
# Siehe create_timer: ohne Projekt landet die Antwort im Hauptchat.
"project_id": project_id or "",
"fire_count": 0, "fire_count": 0,
"last_fired_at": None, "last_fired_at": None,
"last_checked_at": None, "last_checked_at": None,
} }
write(name, data) write(name, data)
logger.info("Trigger angelegt: %s (watcher, cond='%s')", name, condition) logger.info("Trigger angelegt: %s (watcher, cond='%s', project=%s)",
name, condition, project_id or "(main)")
return data return data
+15 -5
View File
@@ -4618,11 +4618,15 @@ class ARIABridge:
trigger_name = data.get("trigger_name", "") trigger_name = data.get("trigger_name", "")
ttype = data.get("type", "trigger") ttype = data.get("type", "trigger")
events = data.get("events") or [] events = data.get("events") or []
logger.info("[bridge ← brain] Trigger '%s' (%s) gefeuert, reply=%d chars, events=%d", trigger_pid = str(data.get("projectId") or "")
trigger_name, ttype, len(reply), len(events)) logger.info("[bridge ← brain] Trigger '%s' (%s) gefeuert, reply=%d chars, "
"events=%d, project=%s",
trigger_name, ttype, len(reply), len(events),
trigger_pid or "(main)")
# Async-spawn — HTTP-Antwort nicht durch RVS-Broadcast blockieren # Async-spawn — HTTP-Antwort nicht durch RVS-Broadcast blockieren
asyncio.create_task( asyncio.create_task(
self._handle_trigger_fired(reply, trigger_name, ttype, events) self._handle_trigger_fired(reply, trigger_name, ttype, events,
trigger_pid)
) )
await _send_response(writer, 200, {"ok": True}) await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/agent-activity": elif method == "POST" and path == "/internal/agent-activity":
@@ -5233,7 +5237,8 @@ class ARIABridge:
return {"ok": True, "role": role, "content_preview": content[:80]} return {"ok": True, "role": role, "content_preview": content[:80]}
async def _handle_trigger_fired(self, reply: str, trigger_name: str, async def _handle_trigger_fired(self, reply: str, trigger_name: str,
ttype: str, events: list) -> None: ttype: str, events: list,
project_id: str = "") -> None:
"""Spiegelt eine Brain-Trigger-Antwort wie eine normale ARIA-Antwort. """Spiegelt eine Brain-Trigger-Antwort wie eine normale ARIA-Antwort.
Side-Channel-Events zuerst (trigger_created, location_tracking, ...), Side-Channel-Events zuerst (trigger_created, location_tracking, ...),
@@ -5285,7 +5290,12 @@ class ARIABridge:
try: try:
await self._process_core_response( await self._process_core_response(
reply, reply,
{"metadata": {"trigger_name": trigger_name, "trigger_type": ttype}}, {"metadata": {"trigger_name": trigger_name, "trigger_type": ttype},
# Ohne projectId landete jede Trigger-Antwort im Hauptchat —
# auch wenn der Timer in einem Projekt-Thread gestellt wurde.
# Stefan sah dann im Projekt nichts und hielt den Timer fuer
# nicht gefeuert.
"projectId": project_id or ""},
) )
except Exception: except Exception:
logger.exception("[trigger-fire] _process_core_response fehlgeschlagen") logger.exception("[trigger-fire] _process_core_response fehlgeschlagen")