feat: AirPlan V2 — 全专家插件强制路由 + 事件系统规范化
P0-8 扩大: do_mode.py finish_worker 全专家插件强制路由 - GUI→XDB, network→NDB, C/C++→SDB, done→Rvr, blocked/failed→Dbg - 证据去重: 已有 xdbSessions/ndbSessions/sdbReports/rvrReviewed 则跳过 P1-GAP17: 事件 emit 规范化 - 新增 7 个事件常量 (TASK_ENTERED, TASK_FINISHED, ENGINE_ENTERED 等) - 全部 emit 调用替换字符串字面量为常量,零残留 - 30 个事件类型常量全部定义且唯一 P1-GAP18: 事件日志原子轮转 - emit 计数器每 128 次检查轮转,避免每次 emit 读文件 - 清除未使用的 _emit_with_completion/_pending_merge_complete - 原子轮转: tempfile+os.replace 保证不损坏 eng 极端接管: 强制调用全部专家插件 (Dbg/XDB/NDB/SDB/Rvr) commands/do.md: 更新为全专家插件路由文档 全量测试: 69 通过, 0 失败 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
108
lib/air_runtime/events.py
Executable file
108
lib/air_runtime/events.py
Executable file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
事件日志模块 — 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
|
||||
Reference in New Issue
Block a user