Files
AirPlan-V2/lib/air_runtime/events.py
AirLongDian 90a0db005f feat: P1-21 补充 — ADRWatcher 自动检测 + PartialReplanner 局部重规划
- 新增 adr_watcher.py: SHA256 hash 监控 ADR 文件变更
  - snapshot() 初始快照, detect_changes() 增量检测
  - 识别 new/modified/superseded/deleted 四种变更类型
  - _parse_status() 解析 ADR Status 字段
- 新增 partial_replanner.py: 局部重规划器
  - replan() 仅生成受影响任务的替代计划
  - _extract_stable_interfaces() 提取未受影响 DONE 任务接口约束
  - 产出 replan-request.json 供 Arc 读取
- Eng monitor_engine 集成 ADR 检测: 每轮轮询调用 _detect_adr_changes()
- Eng handle_adr_invalidation 集成 PartialReplanner 输出
- events.py 新增 ADR 变更相关事件常量
- task_graph.py apply_full_replace 保留 DISPATCHED, 不保留 INVALIDATED
- 10 项补充测试全通过,含完整 ADR 流程场景

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:30:31 +08:00

106 lines
3.4 KiB
Python
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__)
# 事件类型常量
TASK_DISPATCHED = "task.dispatched"
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_CYCLE = "engine.cycle"
WORKER_TIMEOUT = "worker.timeout"
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 格式),支持轮转截断。"""
MAX_LINES = 10000
def __init__(self, path: Path, max_lines: int = MAX_LINES):
self._path = path
self._max_lines = max_lines
self._pending_merge_complete = None
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._maybe_rotate()
def _emit_with_completion(self, event_type: str, payload: dict) -> None:
"""emit 并确保 MERGE_STARTED / MERGE_COMPLETED 成对。"""
self.emit(event_type, payload)
# 自动补全配对事件
if event_type == "merge.started":
self._pending_merge_complete = payload.get("taskId")
elif event_type == "merge.completed":
self._pending_merge_complete = None
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