"""RunBroker:pub/sub,把 agent run 产生的 event fan-out 给所有 SSE 订阅者。 DESIGN §7 简化(0004 一并)—— 单活 run 形态下 run_id 是冗余的(同 task 同时 最多 1 个活 run);broker 内部全用 task_id 索引,客户端只需要 task_id 即可 订阅 / cancel,不再需要先拿 run_id。 两个实现,env `ZCBOT_REDIS_URL` 决定(import 时定,进程生命周期不变): - **LocalRunBroker**(默认,env 不设):进程内 asyncio queue,零依赖 —— 本地 dev / 单实例部署用。多进程不共享:蓝绿并存期跨实例订阅/cancel 送达不到(RUN.md B 档边缘)。 - **RedisRunBroker**(env 设了):event 走 Redis PUB/SUB(`zcbot:ev:`), done/cancel 走带 TTL 的 key —— 蓝绿双实例跨进程可达,也是稳态双实例分流的前提 (DESIGN §7.0)。启动 ping 不通 fail-fast(同 sandbox init 纪律);运行中 publish 失败 log + 丢帧(直播降级,run 不受影响)。 公共语义(两实现一致): - emit() 从工作线程调(agent.run 在 to_thread 跑);SSE generator await queue.get()。 - 同一 task 多个订阅者(刷新页面 / 多 tab)— 每个订阅 1 个独立 queue。 - run 结束 → close(task_id) 给所有订阅者派 done;晚到的订阅者立即收 done 断流 (local 用 _done set;redis 用 `zcbot:done:` 60s TTL key —— 主保护是 SSE 端点先查 DB run_status,key 只兜"查完 DB 到订阅完成之间 run 恰好收尾"的窗口)。 - 同 task 起新 run → start(task_id) 清 done 标记。 - cancel:request_cancel 置位,AgentLoop 在 chunk 间 poll is_cancelled() 即退; redis 版是 `zcbot:cancel:` key(GET ~0.1ms/次,localhost 可忽略),跨实例可达。 - 不持久化 event — messages 已落 PG,刷新走 GET /messages 看历史;订阅建立前 emit 的帧两个实现都会丢(设计选择)。 """ from __future__ import annotations import asyncio import json import os import threading import time from collections import defaultdict from typing import Any, Optional from uuid import UUID class LocalRunBroker: """进程内实现(原 RunBroker)。 线程模型: - bind_loop(loop) 在 FastAPI startup 调一次,记录 asyncio loop 引用。 - emit() 调用方可能在任意线程;put_nowait 是 thread-unsafe(asyncio.Queue 设计 前提是单 loop),所以走 call_soon_threadsafe 跨回 loop 线程再 put。 """ def __init__(self) -> None: self._subs: dict[UUID, set[asyncio.Queue]] = defaultdict(set) # 已经发完 done 的 task — 后来订阅者直接收到 done,避免无限等 self._done: set[UUID] = set() # cancel signal per-task。AgentLoop 在 BG 线程里 poll is_cancelled() 决定是否退; # request_cancel 可在 BG 还没 register 时调用(setdefault),BG 启动后第一次 # check 即看到。run 完成在 finally 里 clear_cancel 回收。 self._cancel_flags: dict[UUID, threading.Event] = {} self._loop: Optional[asyncio.AbstractEventLoop] = None def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None: """FastAPI startup 调一次。""" self._loop = loop async def shutdown(self) -> None: """lifespan finally 调;local 无外部资源,no-op。""" def start(self, task_id: UUID) -> None: """同 task 起新 run 时调:清 _done 标记,让新订阅者能看到流式。 cancel flag 在 finally 里 clear_cancel 清,这里不动(避免擦掉刚刚 request_cancel 的请求)。 """ self._done.discard(task_id) def subscribe(self, task_id: UUID) -> asyncio.Queue: """订阅 task 当前 run 的 event 流。已 done 的 task 立刻在 queue 放一条 done。 调用方:SSE handler(在 asyncio loop 线程内)。 """ q: asyncio.Queue = asyncio.Queue() if task_id in self._done: q.put_nowait({"type": "done"}) else: self._subs[task_id].add(q) return q def unsubscribe(self, task_id: UUID, q: asyncio.Queue) -> None: """SSE generator finally 清理。""" self._subs.get(task_id, set()).discard(q) if task_id in self._subs and not self._subs[task_id]: del self._subs[task_id] def emit(self, task_id: UUID, event: dict[str, Any]) -> None: """从工作线程调:把 event 推给所有订阅者。 如果没人订阅(run 在跑但没浏览器连上),event 丢弃 — 这是设计选择 (event 不持久化,messages 走 PG)。 """ loop = self._loop if loop is None: return # 还没 bind,丢弃(测试 / 启动竞态) for q in list(self._subs.get(task_id, [])): loop.call_soon_threadsafe(q.put_nowait, event) def close(self, task_id: UUID) -> None: """run 结束:派 done 给所有订阅者,标记 task 为已完成。 从工作线程调(agent.run 完成 / 抛异常 finally 清理)。 """ self.emit(task_id, {"type": "done"}) self._done.add(task_id) # subs 不在这里立即删 — SSE generator 会先收到 done、yield 它、走到 # finally unsubscribe;此处 emit 后立即删会让那次 emit 之后的清理无的放矢。 def n_subscribers(self, task_id: UUID) -> int: """供测试 / 监控用。""" return len(self._subs.get(task_id, set())) def total_subscribers(self) -> int: """本进程 SSE 订阅者总数(所有 task 累加),供 §8.4 并发监控用。""" return sum(len(s) for s in self._subs.values()) def is_done(self, task_id: UUID) -> bool: return task_id in self._done # ─────────────── cancel signaling ─────────────── def request_cancel(self, task_id: UUID) -> None: """主线程(HTTP handler)发的 cancel 信号 — BG 线程 poll is_cancelled() 看见即退。 setdefault:即便 BG 还没注册 flag 也能 set,BG 启动后第一次 check 立刻看见。 """ self._cancel_flags.setdefault(task_id, threading.Event()).set() def is_cancelled(self, task_id: UUID) -> bool: ev = self._cancel_flags.get(task_id) return bool(ev and ev.is_set()) def clear_cancel(self, task_id: UUID) -> None: """run 真正退出(BG finally)清掉 flag,避免 dict 无限增长。""" self._cancel_flags.pop(task_id, None) class RedisRunBroker: """Redis pub/sub 实现(蓝绿双实例跨进程,DESIGN §7.0)。 - event:`PUBLISH zcbot:ev:`(JSON);单条 async pubsub reader 任务 把消息路由到本进程各订阅 queue(订阅表 _subs 仍在本地 —— fan-out 的"最后 一公里"还是进程内,Redis 只负责跨进程那一跳)。 - done:close 先 `SETEX zcbot:done: 60`再 publish done;subscribe 先挂 channel 再查 key,晚到订阅者不漏。 - cancel:`SETEX zcbot:cancel: 3600` / GET / DEL,跨实例可达。 - 容错:bind_loop ping 不通直接 raise(fail-fast);运行中 redis 操作失败 log(10s 节流)+ 降级(emit 丢帧、is_cancelled 返 False),run 不受影响; reader 断线 1s 退避重连并重挂全部活跃 channel。 """ _EV_PREFIX = "zcbot:ev:" _DONE_PREFIX = "zcbot:done:" _CANCEL_PREFIX = "zcbot:cancel:" _CTL_CHANNEL = "zcbot:ev:__ctl__" # 常挂空 channel,保 pubsub 始终有订阅可 get_message _DONE_TTL = 60 _CANCEL_TTL = 3600 def __init__(self, url: str, *, sync_client=None, async_client=None) -> None: import redis as _redis import redis.asyncio as _aredis self._url = url # sync client:emit/close/cancel 从工作线程调(redis-py 自带连接池,线程安全); # start/request_cancel 也会在 loop 线程上调 —— localhost 单次 ~0.1ms,超时收紧 # 到 2s 防 redis 挂掉时把 event loop 卡死太久。 self._redis = sync_client if sync_client is not None else _redis.Redis.from_url( url, decode_responses=True, socket_timeout=2.0, socket_connect_timeout=2.0, ) self._aredis = async_client if async_client is not None else _aredis.from_url( url, decode_responses=True, ) self._pubsub = None self._reader_task: Optional[asyncio.Task] = None self._subs: dict[UUID, set[asyncio.Queue]] = defaultdict(set) self._loop: Optional[asyncio.AbstractEventLoop] = None self._last_err_log = 0.0 # ─────────────── helpers ─────────────── def _chan(self, task_id: UUID) -> str: return f"{self._EV_PREFIX}{task_id}" def _log_err(self, what: str, e: Exception) -> None: """redis 故障时 emit/is_cancelled 每 chunk 都会撞,10s 节流防刷屏。""" now = time.monotonic() if now - self._last_err_log >= 10: self._last_err_log = now print(f"[broker] redis {what} failed: {type(e).__name__}: {e} (降级继续,10s 内同类不再报)") # ─────────────── lifecycle ─────────────── def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None: """FastAPI startup 调一次:ping 验活(不通 fail-fast)+ 起 pubsub reader。""" self._loop = loop try: self._redis.ping() except Exception as e: raise RuntimeError( f"ZCBOT_REDIS_URL 已设但 Redis 连不上({type(e).__name__}: {e})——" f"确认 redis-server 在跑 / URL 对,或删掉该 env 回退进程内 broker" ) self._reader_task = loop.create_task(self._reader(), name="redis-broker-reader") host = self._url.rsplit("@", 1)[-1] # 不把密码打进日志 print(f"[broker] redis pub/sub enabled ({host})") async def shutdown(self) -> None: """lifespan finally 调:停 reader、断两个客户端。""" if self._reader_task is not None: self._reader_task.cancel() try: await self._reader_task except (asyncio.CancelledError, Exception): pass if self._pubsub is not None: try: await self._pubsub.aclose() except Exception: pass try: await self._aredis.aclose() except Exception: pass try: self._redis.close() except Exception: pass async def _reader(self) -> None: """常驻:单条 pubsub 连接收所有活跃 channel 的消息,路由到本地订阅 queue。 断线 1s 退避重连,并把 _subs 里所有活跃 channel 重挂上(订阅状态不丢)。""" while True: try: self._pubsub = self._aredis.pubsub() channels = [self._CTL_CHANNEL] + [self._chan(t) for t in self._subs] await self._pubsub.subscribe(*channels) while True: msg = await self._pubsub.get_message( ignore_subscribe_messages=True, timeout=5.0 ) if msg is None or msg.get("type") != "message": continue ch = msg.get("channel") or "" if ch == self._CTL_CHANNEL: continue try: tid = UUID(ch[len(self._EV_PREFIX):]) event = json.loads(msg["data"]) except (ValueError, TypeError): continue # 不认识的 channel / 坏 payload,丢 for q in list(self._subs.get(tid, ())): q.put_nowait(event) except asyncio.CancelledError: raise except Exception as e: self._log_err("reader", e) try: if self._pubsub is not None: await self._pubsub.aclose() except Exception: pass await asyncio.sleep(1) # ─────────────── pub/sub ─────────────── def start(self, task_id: UUID) -> None: try: self._redis.delete(f"{self._DONE_PREFIX}{task_id}") except Exception as e: self._log_err("start/del-done", e) def subscribe(self, task_id: UUID) -> asyncio.Queue: """调用方:SSE handler(loop 线程)。queue 立即返回;channel 挂载与 done-key 补查异步完成 —— 挂载完成前 publish 的帧会丢(与 local 版"订阅前的帧丢"同类, SSE 端点已先查过 DB run_status 兜大头)。""" q: asyncio.Queue = asyncio.Queue() first = not self._subs[task_id] self._subs[task_id].add(q) async def _setup() -> None: try: if first and self._pubsub is not None: await self._pubsub.subscribe(self._chan(task_id)) # 先挂 channel 再查 done key:close() 是先 SETEX 再 publish, # 两边一交叉,晚到订阅者要么收到 publish、要么查到 key,不漏 if await self._aredis.exists(f"{self._DONE_PREFIX}{task_id}"): q.put_nowait({"type": "done"}) except Exception as e: self._log_err("subscribe-setup", e) q.put_nowait({"type": "done"}) # 保守断流,客户端自动重连 if self._loop is not None: self._loop.create_task(_setup()) return q def unsubscribe(self, task_id: UUID, q: asyncio.Queue) -> None: subs = self._subs.get(task_id) if subs is None: return subs.discard(q) if not subs: del self._subs[task_id] ch = self._chan(task_id) async def _unsub() -> None: try: if self._pubsub is not None: await self._pubsub.unsubscribe(ch) except Exception: pass # reader 重连时按 _subs 重挂,残留订阅无害 if self._loop is not None: self._loop.create_task(_unsub()) def emit(self, task_id: UUID, event: dict[str, Any]) -> None: try: self._redis.publish( self._chan(task_id), json.dumps(event, ensure_ascii=False, default=str) ) except Exception as e: self._log_err("publish", e) # 丢帧降级,run 不受影响 def close(self, task_id: UUID) -> None: try: self._redis.set(f"{self._DONE_PREFIX}{task_id}", "1", ex=self._DONE_TTL) except Exception as e: self._log_err("close/set-done", e) self.emit(task_id, {"type": "done"}) def n_subscribers(self, task_id: UUID) -> int: return len(self._subs.get(task_id, set())) def total_subscribers(self) -> int: """本进程的订阅数(蓝绿双实例各报各的,admin 面板看单实例)。""" return sum(len(s) for s in self._subs.values()) def is_done(self, task_id: UUID) -> bool: try: return bool(self._redis.exists(f"{self._DONE_PREFIX}{task_id}")) except Exception as e: self._log_err("is_done", e) return False # ─────────────── cancel signaling ─────────────── def request_cancel(self, task_id: UUID) -> None: try: self._redis.set(f"{self._CANCEL_PREFIX}{task_id}", "1", ex=self._CANCEL_TTL) except Exception as e: self._log_err("request_cancel", e) def is_cancelled(self, task_id: UUID) -> bool: """AgentLoop 在 stream chunk 间 poll(工作线程);redis 挂了返 False 降级 (cancel 暂不可达,等价于 redis 前的跨实例行为,run 不受影响)。""" try: return self._redis.get(f"{self._CANCEL_PREFIX}{task_id}") is not None except Exception as e: self._log_err("is_cancelled", e) return False def clear_cancel(self, task_id: UUID) -> None: try: self._redis.delete(f"{self._CANCEL_PREFIX}{task_id}") except Exception as e: self._log_err("clear_cancel", e) def _make_broker(): """env `ZCBOT_REDIS_URL` 在 → Redis 版(蓝绿跨进程);不在 → 进程内版(dev/单实例)。 import 时定一次,与 ZCBOT_INSTANCE 同款读法。""" url = os.getenv("ZCBOT_REDIS_URL", "").strip() if url: return RedisRunBroker(url) return LocalRunBroker() # 进程内单例 — FastAPI lifespan 里 bind_loop;agent / sink / SSE handler 共享。 broker = _make_broker()