auto-sync: 2026-05-17 05:52:28
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""Inbox JSONL Watcher — 秒级事件推送
|
||||
|
||||
Agent 完成任务后写 JSONL 到 inbox/daemon.jsonl,Daemon 秒级轮询消费。
|
||||
|
||||
设计要点:
|
||||
- JSONL 格式(每行一个 JSON 对象)
|
||||
- truncate 清空(不删除文件),避免并发写入时文件不存在
|
||||
- 损坏行跳过不崩溃
|
||||
- 空文件不触发处理
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Coroutine, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.inbox")
|
||||
|
||||
|
||||
class InboxWatcher:
|
||||
"""Inbox JSONL 文件监控器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inbox_path: Path,
|
||||
watch_interval: float = 1.0,
|
||||
on_events: Optional[Callable[[List[Dict[str, Any]]], Coroutine[Any, Any, None]]] = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
inbox_path: JSONL 文件路径(如 inbox/daemon.jsonl)
|
||||
watch_interval: 轮询间隔秒数
|
||||
on_events: 消费到事件后的回调
|
||||
"""
|
||||
self.inbox_path = inbox_path
|
||||
self.watch_interval = watch_interval
|
||||
self.on_events = on_events
|
||||
|
||||
self._running: bool = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._total_consumed: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 生命周期
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动 watcher"""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
import asyncio
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
logger.info("Inbox watcher started (path=%s, interval=%.1fs)",
|
||||
self.inbox_path, self.watch_interval)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""停止 watcher"""
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Inbox watcher stopped (consumed=%d)", self._total_consumed)
|
||||
|
||||
@property
|
||||
def total_consumed(self) -> int:
|
||||
return self._total_consumed
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 主循环
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _loop(self) -> None:
|
||||
import asyncio
|
||||
while self._running:
|
||||
try:
|
||||
events = self.consume()
|
||||
if events and self.on_events:
|
||||
await self.on_events(events)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Inbox watcher error")
|
||||
|
||||
try:
|
||||
await asyncio.sleep(self.watch_interval)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 消费逻辑
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def consume(self) -> List[Dict[str, Any]]:
|
||||
"""消费 JSONL 文件中的所有事件,然后 truncate
|
||||
|
||||
Returns:
|
||||
解析成功的事件列表
|
||||
"""
|
||||
if not self.inbox_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
file_size = self.inbox_path.stat().st_size
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
if file_size == 0:
|
||||
return []
|
||||
|
||||
# 读取全部内容
|
||||
try:
|
||||
raw = self.inbox_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
if not raw.strip():
|
||||
return []
|
||||
|
||||
# 解析每一行
|
||||
events: List[Dict[str, Any]] = []
|
||||
bad_lines: int = 0
|
||||
for line_num, line in enumerate(raw.splitlines(), 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
if isinstance(event, dict):
|
||||
events.append(event)
|
||||
else:
|
||||
bad_lines += 1
|
||||
logger.warning("Inbox line %d: not a JSON object, skipping", line_num)
|
||||
except json.JSONDecodeError as e:
|
||||
bad_lines += 1
|
||||
logger.warning("Inbox line %d: invalid JSON (%s), skipping", line_num, e)
|
||||
|
||||
# Truncate(清空不删除)
|
||||
try:
|
||||
with open(self.inbox_path, "w", encoding="utf-8") as f:
|
||||
f.truncate(0)
|
||||
except OSError as e:
|
||||
logger.error("Failed to truncate inbox: %s", e)
|
||||
|
||||
if bad_lines > 0:
|
||||
logger.warning("Inbox: %d bad lines skipped", bad_lines)
|
||||
|
||||
self._total_consumed += len(events)
|
||||
if events:
|
||||
logger.debug("Inbox consumed %d events", len(events))
|
||||
|
||||
return events
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 写入辅助(Agent 端使用)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def write_event(self, event: Dict[str, Any]) -> None:
|
||||
"""追加一条事件到 JSONL 文件
|
||||
|
||||
Agent 端调用此方法写入事件。
|
||||
使用 O_APPEND 模式确保并发写入安全。
|
||||
"""
|
||||
self.inbox_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(event, ensure_ascii=False) + "\n"
|
||||
# O_APPEND 保证并发写入时各行不交错
|
||||
with open(self.inbox_path, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
|
||||
@classmethod
|
||||
def write_event_to(cls, inbox_path: Path, event: Dict[str, Any]) -> None:
|
||||
"""静态方法:写入事件到指定路径"""
|
||||
inbox_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(event, ensure_ascii=False) + "\n"
|
||||
with open(inbox_path, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
Reference in New Issue
Block a user