fix: Windows兼容性修复 + P1-24弱模型优化 + 3.2.9b禁止降级方案 + AirRvr三层审查放行标准

- lock.py: 跨平台进程锁(Unix fcntl / Windows msvcrt / O_CREAT|O_EXCL降级)
- eng_mode.py/eng_orchestrator.py: hasattr(os, "getloadavg") Windows防护
- arc_mode.py: 路径分隔符 replace("\\", "/") Windows兼容
- deploy_runtime.py: 修复语法错误(清理 import tempfile 残留)
- P1-24(3.2.18): AMBIGUOUS_VERBS歧义词检测 + SAFE_VERBS安全动词 + validate_task_description()
- TaskNode.keep_constraints 保留约束字段 + JSON序列化
- _build_graph_from_todo 返回歧义警告 + Arc自检集成
- 3.2.9b: FORBIDDEN_DEGRADATION_PATTERNS + check_forbidden_degradation()
- AirRvr三层审查放行标准: ReviewVerdict + evaluate_review_pass() + is_forbidden_pass_reason()
- commands/arc.md: 弱模型安全重写(操作类型拆分+保留约束+自检)
- commands/do.md/eng.md/rvr.md: 禁止降级方案 + 三层审查标准
- 测试: 7个新测试 + 74全量通过

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirPlan
2026-06-15 09:58:29 +08:00
parent 6130478c96
commit a60d1a0c04
13 changed files with 486 additions and 42 deletions

View File

@@ -7,7 +7,8 @@ from __future__ import annotations
import hashlib
import subprocess
from dataclasses import dataclass
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from air_runtime.contracts import DeploymentRecord, now_iso
@@ -20,7 +21,7 @@ class DeployTarget:
host: str
user: str = "root"
port: int = 22
build_dir: str = "/tmp/airdep-build"
build_dir: str = tempfile.gettempdir() + "/airdep-build"
deploy_dir: str = "/opt/app"

View File

@@ -1,43 +1,123 @@
"""
文件级并发控制 — 解决 V1 P0-4 零并发控制问题
基于 fcntl.flock 的进程级文件锁,超时自动释放。
文件级并发控制 — 跨平台进程级文件锁
Unix: fcntl.flock (POSIX)
Windows: msvcrt.locking (Win32)
均不支持时: 原子文件创建 (O_CREAT | O_EXCL)
"""
from __future__ import annotations
import fcntl
import os
import time
import sys
from pathlib import Path
def _lock_file_unix(fd: int) -> None:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
def _unlock_file_unix(fd: int) -> None:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
def _lock_file_windows(fd: int) -> None:
import msvcrt
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
def _unlock_file_windows(fd: int) -> None:
import msvcrt
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
if sys.platform == "win32":
try:
import msvcrt
_lock_fn = _lock_file_windows
_unlock_fn = _unlock_file_windows
except ImportError:
_lock_fn = None
_unlock_fn = None
else:
try:
import fcntl # noqa: F401
_lock_fn = _lock_file_unix
_unlock_fn = _unlock_file_unix
except ImportError:
_lock_fn = None
_unlock_fn = None
class FileLock:
"""基于 fcntl.flock(LOCK_EX | LOCK_NB) 的进程级文件锁"""
"""跨平台进程级文件锁
Unix: fcntl.flock(LOCK_EX | LOCK_NB)
Windows: msvcrt.locking(LK_NBLCK)
降级方案: 原子文件创建 (O_CREAT | O_EXCL)
"""
def __init__(self, path: Path, timeout: float = 10.0):
self._path = path.with_suffix(path.suffix + ".lock") if not path.suffix.endswith(".lock") else path
lock_suffix = ".lock" if not path.suffix.endswith(".lock") else ""
self._path = path.with_suffix(path.suffix + lock_suffix) if lock_suffix else path
self._timeout = timeout
self._fd: int | None = None
self._fallback = _lock_fn is None
def __enter__(self) -> FileLock:
self._fd = os.open(self._path, os.O_CREAT | os.O_RDWR)
def __enter__(self) -> "FileLock":
if self._fallback:
self._acquire_fallback()
else:
self._acquire_native()
return self
def __exit__(self, *exc) -> None:
if self._fallback:
self._release_fallback()
elif self._fd is not None:
_unlock_fn(self._fd)
os.close(self._fd)
self._fd = None
def _acquire_native(self) -> None:
self._fd = os.open(str(self._path), os.O_CREAT | os.O_RDWR)
deadline = time.monotonic() + self._timeout
while True:
try:
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return self
except OSError:
_lock_fn(self._fd)
return
except (OSError, IOError):
if time.monotonic() >= deadline:
os.close(self._fd)
self._fd = None
raise TimeoutError(f"lock timeout after {self._timeout}s: {self._path}")
time.sleep(0.1)
def __exit__(self, *exc) -> None:
def _acquire_fallback(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
deadline = time.monotonic() + self._timeout
while True:
try:
self._fd = os.open(
str(self._path),
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
)
return
except FileExistsError:
if time.monotonic() >= deadline:
raise TimeoutError(f"lock timeout after {self._timeout}s: {self._path}")
time.sleep(0.1)
def _release_fallback(self) -> None:
if self._fd is not None:
fcntl.flock(self._fd, fcntl.LOCK_UN)
os.close(self._fd)
self._fd = None
try:
self._path.unlink(missing_ok=True)
except OSError:
pass
@property
def path(self) -> Path:

View File

@@ -107,19 +107,22 @@ def _export_task_graph_json(graph: TaskGraph, path: Path) -> None:
"filesDirs": n.files_dirs, "doneWhen": n.done_when,
"inDegree": n.in_degree, "outEdges": n.out_edges,
"writeSet": n.write_set, "testRequired": n.test_required,
"adrRefs": n.adr_refs} # P1-21
"adrRefs": n.adr_refs, # P1-21
"keepConstraints": n.keep_constraints} # P1-24
for nid, n in graph.nodes.items()},
"edges": [{"source": e.source, "target": e.target, "kind": e.kind} for e in graph.edges],
}
atomic_json_write(path, data)
def _build_graph_from_todo(todo_path: Path) -> tuple[TaskGraph, list[str]]:
"""从 todo.md 构建初始 DAG返回图和未满足 Done When 条件的任务列表。"""
def _build_graph_from_todo(todo_path: Path) -> tuple[TaskGraph, list[str], list[str]]:
"""从 todo.md 构建初始 DAG返回图Done When 违规列表、歧义词警告列表。"""
from air_runtime.todo_parser import parse_tasks
from air_runtime.review import validate_task_description
tasks = parse_tasks(todo_path)
graph = TaskGraph()
violations = [] # P1-19.1: 记录 Done When 不含"测试通过"的任务
ambiguity_warnings = [] # P1-24: 弱模型优化 — 歧义词警告
for t in tasks:
# P1-21: 从 todo.md ADR 列提取 adr_refs
@@ -137,10 +140,13 @@ def _build_graph_from_todo(todo_path: Path) -> tuple[TaskGraph, list[str]]:
if t.done_when and "测试通过" not in t.done_when:
violations.append(t.task_id)
# P1-24: 弱模型优化 — 检测歧义词
ambiguity_warnings.extend(validate_task_description(t.task_id, t.task))
# P1-19.1: 注入边界测试任务
_inject_boundary_tests(graph, tasks)
return graph, violations
return graph, violations, ambiguity_warnings
def _inject_boundary_tests(graph: TaskGraph, tasks: list) -> None:
@@ -159,8 +165,8 @@ def _inject_boundary_tests(graph: TaskGraph, tasks: list) -> None:
for fd in t.files_dirs.split(","):
fd = fd.strip()
if fd:
# 取第一级目录作为模块名
parts = fd.split("/")
# 取第一级目录作为模块名 (跨平台路径解析)
parts = fd.replace("\\", "/").split("/")
if len(parts) > 1:
module = parts[0]
else:
@@ -238,7 +244,7 @@ def parallel_review_mode(project_root: Path, todo_path: Path) -> dict:
review_md_path.write_text(render_review_markdown(review), encoding="utf-8")
# 构建 DAG包含边界测试任务注入
graph, done_when_violations = _build_graph_from_todo(todo_path)
graph, done_when_violations, ambiguity_warnings = _build_graph_from_todo(todo_path)
for edge_info in review.to_dict().get("edges", []):
graph.add_edge(Edge(source=edge_info["source"], target=edge_info["target"],
kind=edge_info.get("kind", "dependency")))
@@ -264,6 +270,7 @@ def parallel_review_mode(project_root: Path, todo_path: Path) -> dict:
"conflicts": [c.to_dict() for c in review.conflicts],
"serializationPoints": review.serialization_points,
"doneWhenViolations": done_when_violations, # P1-19.1: Done When 不含"测试通过"的任务
"ambiguityWarnings": ambiguity_warnings, # P1-24: 弱模型优化 — 歧义词警告
"boundaryTestTasks": [n.id for n in graph.nodes.values() if n.test_required],
"safetyWarnings": safety_warnings, # INV-16: 弱模型安全警告
}
@@ -292,6 +299,7 @@ def parallel_review_mode(project_root: Path, todo_path: Path) -> dict:
"parallel_group_count": len(review.parallel_groups),
"conflict_count": len(review.conflicts),
"done_when_violations": done_when_violations,
"ambiguity_warnings": ambiguity_warnings,
"boundary_test_task_count": len([n for n in graph.nodes.values() if n.test_required])}
@@ -301,7 +309,7 @@ def incremental_replan_mode(project_root: Path, todo_path: Path, previous_graph_
_ensure_dirs(paths)
review = build_parallel_review(todo_path)
new_graph, _ = _build_graph_from_todo(todo_path)
new_graph, _, _ = _build_graph_from_todo(todo_path)
for edge_info in review.to_dict().get("edges", []):
new_graph.add_edge(Edge(source=edge_info["source"], target=edge_info["target"],
kind=edge_info.get("kind", "dependency")))

View File

@@ -370,13 +370,15 @@ def monitor_engine(project_root: Path) -> dict:
else:
ready_to_merge += 1 if worker.get("status") == "done" else 0
# 资源压力检测
try:
load = os.getloadavg()[0]
cpu_count = os.cpu_count() or 4
resource_pressure = load > cpu_count * 2
except OSError:
resource_pressure = False
# 资源压力检测 (Unix only; Windows 上不可用)
resource_pressure = False
if hasattr(os, "getloadavg"):
try:
load = os.getloadavg()[0]
cpu_count = os.cpu_count() or 4
resource_pressure = load > cpu_count * 2
except OSError:
pass
# P1-21: ADR 变更自动检测
adr_changes = _detect_adr_changes(project_root, state)

View File

@@ -51,6 +51,8 @@ class AdaptivePoller:
def _resource_pressure() -> bool:
if not hasattr(os, "getloadavg"):
return False
try:
load = os.getloadavg()[0]
cpu = os.cpu_count() or 4

View File

@@ -1,6 +1,7 @@
"""
并行审查模块 — V2 从 V1 迁移。
分析任务依赖、写集冲突、产出并行组和串行点。
包含三层审查放行标准 (P1-24/P1-25) 和弱模型任务描述优化 (3.2.18)。
"""
from __future__ import annotations
@@ -10,6 +11,41 @@ from dataclasses import dataclass, field
from pathlib import Path
from air_runtime.todo_parser import parse_tasks
# 3.2.18: 弱模型优化 — 歧义动词检测
AMBIGUOUS_VERBS = {
"清理": "歧义——可能是删除、重构、或移除依赖",
"优化": "歧义——可能是性能优化、代码重构、或简化逻辑",
"整理": "歧义——可能是格式化、重命名、或删除",
"更新": "歧义——可能是修改现有代码、或替换为新实现",
}
SAFE_VERBS = {
"重构": "修改实现但保持外部接口不变",
"新增": "添加新功能,不修改现有代码",
"删除": "移除指定文件或函数(必须列出具体目标)",
"修改": "修改指定文件的具体部分(必须指明改什么)",
"保留": "明确标记为不可修改的文件/目录",
}
# 3.2.9b: 禁止使用的降级语言模式
FORBIDDEN_DEGRADATION_PATTERNS = [
"兜底方案", "fallback",
"先这样做", "先这样跑通", "先这样实现",
"以后再删", "以后再补", "以后再改", "以后再优化",
"先回退",
"临时方案", "temporary workaround",
"hack 一下", "quick fix",
"MVP 先上", "先 ship 再迭代",
]
# AirRvr 审查放行标准 — 禁止的表面理由
FORBIDDEN_PASS_REASONS = [
"测试 pass", "测试全绿", "all tests passed",
"实现存在", "函数存在", "文件已创建",
"编译通过", "无报错",
"能跑通", "功能可用",
]
@dataclass
class ParallelGroup:
@@ -123,4 +159,126 @@ def render_review_markdown(review: ReviewResult) -> str:
lines.append("## Conflicts")
for c in review.conflicts:
lines.append(f"- {c.task_a} <-> {c.task_b}: {c.reason}")
return "\n".join(lines)
return "\n".join(lines)
# ── 3.2.18: 弱模型任务描述优化 ──
def validate_task_description(task_id: str, task_text: str) -> list[str]:
"""检测任务描述中的歧义词并建议替换。返回警告列表。"""
warnings = []
for verb, explanation in AMBIGUOUS_VERBS.items():
if verb in task_text:
safe_suggestions = "".join(SAFE_VERBS.keys())
warnings.append(
f"[{task_id}] 任务描述包含歧义词「{verb}」({explanation})"
f"请拆分为具体操作({safe_suggestions}"
)
return warnings
def check_forbidden_degradation(text: str) -> list[str]:
"""检测文本中的降级语言模式,返回匹配到的模式列表。
检测时归一化空白字符以处理中文/英文间距变化。"""
import re
normalized = re.sub(r'\s+', '', text.lower())
found = []
for pattern in FORBIDDEN_DEGRADATION_PATTERNS:
normalized_pattern = re.sub(r'\s+', '', pattern.lower())
if normalized_pattern in normalized:
found.append(pattern)
return found
# ── AirRvr 三层审查放行标准 ──
@dataclass
class ReviewVerdict:
"""三层审查结果。"""
verdict: str # pass | fail | conditional-pass
layer1_pass: bool = False # Code-to-Design
layer2_pass: bool = False # 静态分析
layer3_pass: bool = False # 测试
divergent_entries: list[str] = field(default_factory=list)
missing_entries: list[str] = field(default_factory=list)
static_findings: list[str] = field(default_factory=list)
test_failures: list[str] = field(default_factory=list)
pass_reason: str = ""
def evaluate_review_pass(
code_to_design_table: list[dict],
sdb_report: dict | None = None,
test_results: dict | None = None,
xdb_evidence: dict | None = None,
design_spec: dict | None = None,
) -> ReviewVerdict:
"""三层审查放行评估(不可降级)。
Layer 1 (最高优先级): Code-to-Design 逐行对照
Layer 2: 静态分析通过
Layer 3: 测试通过
"""
divergent = []
missing = []
for entry in code_to_design_table:
status = entry.get("status", "")
if status == "divergent":
divergent.append(entry.get("designPoint", entry.get("requirement", "?")))
elif status == "missing":
missing.append(entry.get("designPoint", entry.get("requirement", "?")))
layer1_pass = len(divergent) == 0 and len(missing) == 0
# Layer 2: 静态分析
static_findings = []
if sdb_report:
for finding in sdb_report.get("findings", []):
if finding.get("severity") in ("critical", "high"):
static_findings.append(finding.get("description", str(finding)))
layer2_pass = len(static_findings) == 0
# Layer 3: 测试
test_failures = []
if test_results:
for suite in test_results.get("suites", []):
if suite.get("status") != "pass":
test_failures.append(suite.get("name", "unknown"))
layer3_pass = len(test_failures) == 0
# GUI 任务额外检查
if xdb_evidence and design_spec:
if not xdb_evidence.get("matches_design", True):
layer3_pass = False
test_failures.append("xdb screenshot does not match design spec")
# 判定
if not layer1_pass:
verdict = "fail"
elif layer2_pass and layer3_pass:
verdict = "pass"
else:
verdict = "conditional-pass"
return ReviewVerdict(
verdict=verdict,
layer1_pass=layer1_pass,
layer2_pass=layer2_pass,
layer3_pass=layer3_pass,
divergent_entries=divergent,
missing_entries=missing,
static_findings=static_findings,
test_failures=test_failures,
)
def is_forbidden_pass_reason(reason: str) -> bool:
"""检查审查通过理由是否使用了禁止的表面原因。
检测时归一化空白字符以处理间距变化。"""
import re
normalized_reason = re.sub(r'\s+', '', reason.lower())
for forbidden in FORBIDDEN_PASS_REASONS:
normalized_forbidden = re.sub(r'\s+', '', forbidden.lower())
if normalized_forbidden in normalized_reason:
return True
return False

View File

@@ -22,6 +22,7 @@ class TaskNode:
meta: dict[str, Any] = field(default_factory=dict)
test_required: bool = False # P1-19.1: 边界测试强制标记
adr_refs: list[str] = field(default_factory=list) # P1-21: ADR→任务溯源链
keep_constraints: list[str] = field(default_factory=list) # P1-24: 保留约束(不可修改的文件/目录)
@dataclass
@@ -168,6 +169,7 @@ class TaskGraph:
write_set=list(nd.get("writeSet", [])),
test_required=nd.get("testRequired", False),
adr_refs=list(nd.get("adrRefs", [])),
keep_constraints=list(nd.get("keepConstraints", [])),
)
for ed in data.get("edges", []):
graph.edges.append(Edge(