""" 事件日志模块 — V2 可观测性基础设施。 JSONL 格式无限流式追加,与 state.json 互补:state.json 是当前快照,事件日志是完整时间线。 """ from __future__ import annotations import contextlib import json import logging import os import tempfile from datetime import datetime, timezone from pathlib import Path logger = logging.getLogger(__name__) # 事件类型常量 — 所有 emit 调用必须使用常量,禁止字符串字面量 TASK_DISPATCHED = "task.dispatched" TASK_ENTERED = "task.entered" TASK_FINISHED = "task.finished" TASK_COMPLETED = "task.completed" TASK_BLOCKED = "task.blocked" MERGE_STARTED = "merge.started" MERGE_COMPLETED = "merge.completed" REPAIR_CREATED = "repair.created" REPAIR_RESOLVED = "repair.resolved" INTERVENTION_STALL = "intervention.stall" XDB_CAPTURED = "xdb.captured" DEBUG_SESSION = "debug.session" CONTEXT_COMPACTED = "context.compacted" DEPLOY_COMPLETED = "deploy.completed" TEST_RUN = "test.run" SEC_SCAN = "sec.scan" REVIEW_SESSION = "review.session" ENGINE_ENTERED = "engine.entered" ENGINE_CYCLE = "engine.cycle" ENG_REPLAN_TRIGGERED = "eng.replan.triggered" ENG_BLOCKED = "eng.blocked" WORKER_TIMEOUT = "worker.timeout" WORKTREE_MERGE_CONFLICT = "worktree.merge.conflict" ARC_REPLANNED = "arc.replanned" ADR_CHANGE_DETECTED = "adr.change.detected" ADR_INVALIDATION = "adr.invalidation" ADR_UNFREEZED = "adr.unfreezed" LOCK_ACQUIRED = "lock.acquired" LOCK_RELEASED = "lock.released" STALE_LOCK_CLEANED = "stale_lock.cleaned" def now_iso() -> str: return datetime.now(timezone.utc).isoformat() class EventLog: """结构化事件日志(JSONL 格式),支持轮转截断。 P1-GAP18: 原子轮转 — open(tmp)+os.replace,每 N 次 emit 检查一次避免每次读文件。 """ MAX_LINES = 10000 ROTATE_CHECK_EVERY = 128 # 每 128 次 emit 检查一次轮转 def __init__(self, path: Path, max_lines: int = MAX_LINES): self._path = path self._max_lines = max_lines self._emit_count = 0 # P1-GAP18: 计数器,避免每次 emit 都读文件 def emit(self, event_type: str, payload: dict | None = None) -> None: entry = { "ts": now_iso(), "type": event_type, **(payload or {}), } self._path.parent.mkdir(parents=True, exist_ok=True) with open(self._path, "a") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") self._emit_count += 1 if self._emit_count % self.ROTATE_CHECK_EVERY == 0: self._maybe_rotate() def _maybe_rotate(self) -> None: """原子轮转:先写临时文件,再 os.replace。""" if not self._path.exists(): return try: with open(self._path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() if len(lines) <= self._max_lines: return keep_count = self._max_lines // 2 kept_lines = lines[-keep_count:] if len(lines) > keep_count else lines fd, tmp_path = tempfile.mkstemp(dir=self._path.parent, suffix=".tmp") os.close(fd) try: with open(tmp_path, "w", encoding="utf-8") as f: f.writelines(kept_lines) os.replace(tmp_path, self._path) logger.info("rotated event log %s, kept %d/%d lines", self._path, len(kept_lines), len(lines)) except Exception: with contextlib.suppress(Exception): os.unlink(tmp_path) raise except OSError: pass