Files
AirCoding/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/events.py
AirCoding ae44be31d5 chore: push all design docs, V2 plan specs, and current working state
Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2,
AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code
changes across packages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 17:12:29 +08:00

109 lines
3.6 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
事件日志模块 — 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