AirPlan V2 initial release — unified scheduler with 12 sub-modes

Merges 8 V1 plugins into 1 unified plugin, adds 4 new components (dep, tst, sec, rvr).
12 sub-modes: arc, eng, do, dbg, xdb, sdb, ndb, ctx, dep, tst, sec, rvr.
L1 code-level guarantees: atomic writes, file locks, evidence gating, forced debug routing,
3-phase arc gate, evidence-first debug gate, Chinese language lock, scheduler boundary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirPlan Team
2026-06-10 16:24:26 +08:00
commit 2c4b3340bf
81 changed files with 6005 additions and 0 deletions

102
lib/air_runtime/events.py Normal file
View File

@@ -0,0 +1,102 @@
"""
事件日志模块 — 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"
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