feat: P1-19 P1-20 实现 - 边界测试强制 + 高风险审计 + UI Skill 路由
P1-19.1: Arc 边界测试强制 - TaskNode 新增 test_required 字段 - _inject_boundary_tests() 为每个模块注入接口测试和单元测试任务 - Done When 验证必须包含"测试通过" P1-19.2: AirRvr 高风险审计 - 新增 HighRiskAudit, HighRiskFinding 数据类 - ReviewReport 新增 highRiskAudit 字段,含 lifecycle/nullPointer/danglingPointer/exceptionSafety/concurrency + overallRisk + deliveryVerdict - 序列化/反序列化支持 P1-19.3: block-release 集成 - dispatch_worker_group() 派发前扫描最新审查报告 - deliveryVerdict=block-release 时阻止所有后续派发 - 记录 eng.blocked 事件 P1-20: frontend-design Skill 集成 - is_ui_task() UI 任务检测 - ensure_frontend_design_skill() 自动安装 Skill - route_ui_task() UI 任务路由决策 - enter_worker() 集成 UI 检测,skill 不可用时阻止执行 - commands/do.md 更新 UI 处理说明 - SKILL.md 新增 INV-12/INV-13/INV-14 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
25
.claude-plugin/plugin.json
Normal file
25
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
||||||
|
"name": "airplan-v2",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "AirPlan V2 — 统一制品驱动开发调度器。12个子模式:arc,eng,do,dbg,xdb,sdb,ndb,ctx,dep,tst,sec,rvr",
|
||||||
|
"author": {
|
||||||
|
"name": "AirPlan Team",
|
||||||
|
"email": "noreply@airlongdian.fun",
|
||||||
|
"url": "https://airlongdian.fun"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"airplan",
|
||||||
|
"scheduler",
|
||||||
|
"orchestrator",
|
||||||
|
"debugger",
|
||||||
|
"v2"
|
||||||
|
],
|
||||||
|
"skills": [
|
||||||
|
"./skills/"
|
||||||
|
],
|
||||||
|
"commands": [
|
||||||
|
"./commands/"
|
||||||
|
]
|
||||||
|
}
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.bak
|
||||||
|
*.tmp
|
||||||
|
*.lock
|
||||||
|
.DS_Store
|
||||||
10
SKILL.md
Normal file
10
SKILL.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
name: airplan-v2
|
||||||
|
description: TODO — describe WHEN Claude should use this. Include trigger phrases users
|
||||||
|
might say ("do X", "set up Y", "review Z"). Be specific; this string is what Claude
|
||||||
|
matches the user's request against.
|
||||||
|
---
|
||||||
|
|
||||||
|
# airplan-v2
|
||||||
|
|
||||||
|
TODO: what this skill does, and the steps Claude should take.
|
||||||
46
commands/airplan.md
Normal file
46
commands/airplan.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan V2 - unified scheduler with 12 modes"
|
||||||
|
argument-hint: "[arc|eng|do|dbg|xdb|sdb|ndb|ctx|dep|tst|sec|rvr] [sub-command]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /airplan
|
||||||
|
|
||||||
|
AirPlan V2 - unified scheduler. V1 8 plugins merged into 1, adds 4 components, total 12 sub-modes.
|
||||||
|
|
||||||
|
## Sub-modes
|
||||||
|
|
||||||
|
| Mode | Role |
|
||||||
|
|------|------|
|
||||||
|
| arc | Architecture planner |
|
||||||
|
| eng | Scheduler engine |
|
||||||
|
| do | Task executor |
|
||||||
|
| dbg | Debugger |
|
||||||
|
| xdb | GUI validator |
|
||||||
|
| sdb | Static analyzer |
|
||||||
|
| ndb | Network debugger |
|
||||||
|
| ctx | Context manager |
|
||||||
|
| dep | Deployer |
|
||||||
|
| tst | Test runner |
|
||||||
|
| sec | Security scanner |
|
||||||
|
| rvr | Requirements reviewer |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Parse $ARGUMENTS; default to "status" when empty.
|
||||||
|
2. Run from project root - replace MODE with first arg:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode MODE --project . [OPTIONS]
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: /airplan arc -> runs with --mode arc
|
||||||
|
Example: /airplan eng monitor -> runs with --mode eng --sub monitor
|
||||||
|
|
||||||
|
3. Common patterns:
|
||||||
|
- /airplan arc parallel-review --todo AirPlan/todo.md
|
||||||
|
- /airplan eng dispatch
|
||||||
|
- /airplan eng monitor
|
||||||
|
- /airplan do finish --task-id T-001 --result /path/to/result.json
|
||||||
|
|
||||||
|
4. Runtime auto-bootstraps missing AirPlan/ files.
|
||||||
49
commands/arc.md
Normal file
49
commands/arc.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan arc - architecture planner, READ-ONLY, never writes code"
|
||||||
|
argument-hint: "[enter|status|parallel-review|incremental-replan]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep]"
|
||||||
|
deny-plan-mode: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# /arc
|
||||||
|
|
||||||
|
AirArc is a pure architecture planner. It analyzes dependencies, write-set conflicts, and produces execution plans. It NEVER writes code, modifies source files, or enters plan mode.
|
||||||
|
|
||||||
|
## Hard Rules (violated = bug)
|
||||||
|
|
||||||
|
1. **READ-ONLY** — you only Read, Glob, Grep. Never Write, Edit, Bash.
|
||||||
|
2. **No plan mode** — if the agent framework tries to enter plan mode, refuse: "I am AirArc, I produce execution-plan.json, not code changes."
|
||||||
|
3. **Three-phase flow** (cannot skip):
|
||||||
|
- Phase 1 (discussing): Discuss requirements with user, clarify ambiguities, analyze codebase structure, propose architecture alternatives. **Forbidden to write execution-plan.json.**
|
||||||
|
- Phase 2 (proposing): Present recommended architecture (modules, dependencies, tech choices). Wait for user confirmation: "Confirm this architecture before I generate the plan." User objections return to Phase 1. **Forbidden to write execution-plan.json.**
|
||||||
|
- Phase 3 (confirmed): Only after user explicit confirmation, generate execution-plan.json and task-graph.json.
|
||||||
|
|
||||||
|
## Sub-commands
|
||||||
|
|
||||||
|
### enter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub enter
|
||||||
|
```
|
||||||
|
|
||||||
|
### status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub status
|
||||||
|
```
|
||||||
|
|
||||||
|
### parallel-review
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub parallel-review --todo AirPlan/todo.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### incremental-replan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub incremental-replan --todo AirPlan/todo.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging Standard
|
||||||
|
|
||||||
|
When planning C++ projects, the first task MUST be "integrate spdlog" if not already present. All generated code must use spdlog, not std::cout/qDebug/printf.
|
||||||
11
commands/ctx.md
Normal file
11
commands/ctx.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan ctx mode"
|
||||||
|
argument-hint: "[enter|status|...]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /ctx
|
||||||
|
|
||||||
|
Run AirPlan ctx mode.
|
||||||
|
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode ctx --project . [OPTIONS]
|
||||||
11
commands/dbg.md
Normal file
11
commands/dbg.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan dbg mode"
|
||||||
|
argument-hint: "[enter|status|...]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /dbg
|
||||||
|
|
||||||
|
Run AirPlan dbg mode.
|
||||||
|
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode dbg --project . [OPTIONS]
|
||||||
11
commands/dep.md
Normal file
11
commands/dep.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan dep mode"
|
||||||
|
argument-hint: "[enter|status|...]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /dep
|
||||||
|
|
||||||
|
Run AirPlan dep mode.
|
||||||
|
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode dep --project . [OPTIONS]
|
||||||
54
commands/do.md
Normal file
54
commands/do.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan do mode - task executor with UI skill routing"
|
||||||
|
argument-hint: "[enter|status|finish]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /do
|
||||||
|
|
||||||
|
AirDo is the task executor. It runs individual tasks, enforces routing rules, and integrates with other AirPlan modes.
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
1. **UI Task Handling (P1-20)** — When task involves UI/frontend/interface design:
|
||||||
|
- Must detect UI tasks via keyword matching (gui, ui, render, widget, frontend, react, vue, etc.)
|
||||||
|
- Must ensure frontend-design Skill is available before executing UI code
|
||||||
|
- If frontend-design Skill is not installed, block the task
|
||||||
|
- Use `ensure_frontend_design_skill()` and `route_ui_task()` functions
|
||||||
|
|
||||||
|
2. **Evidence Gate (INV-4)** — Tasks with GUI/network indicators must have corresponding evidence before finishing:
|
||||||
|
- GUI tasks require screenshot/validation evidence
|
||||||
|
- Network tasks require packet capture/connectivity evidence
|
||||||
|
- Code tasks require static analysis/test results
|
||||||
|
|
||||||
|
3. **Force AirDbg Routing (INV-4)** — If task finishes without evidence, force route to AirDbg:
|
||||||
|
- `done` status + no validations/files_changed → route to airdbg
|
||||||
|
- `blocked`/`failed` status → always route to airdbg
|
||||||
|
|
||||||
|
## Sub-commands
|
||||||
|
|
||||||
|
### enter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode do --project . --sub enter --task-id T-001 --task-text "实现登录界面UI"
|
||||||
|
```
|
||||||
|
|
||||||
|
### status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode do --project . --sub status
|
||||||
|
```
|
||||||
|
|
||||||
|
### finish
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode do --project . --sub finish --task-id T-001 --result /path/to/result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Task Detection Keywords
|
||||||
|
|
||||||
|
UI task detection uses these keywords (case-insensitive):
|
||||||
|
- gui, ui, render, layout, dialog, osd, overlay, visual
|
||||||
|
- widget, pane, toolbar, settings_dialog, canvas, button
|
||||||
|
- window, popup, menu, drm, kms, opengl, vulkan
|
||||||
|
- frontend, react, vue, angular, web, css, html
|
||||||
87
commands/eng.md
Normal file
87
commands/eng.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan eng - scheduler engine, dispatches isolated workers, never codes directly"
|
||||||
|
argument-hint: "[run|status|plan|dispatch|monitor|merge|intervene]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /eng
|
||||||
|
|
||||||
|
AirEng 是调度引擎,不是执行器。它派发隔离 Worker、监控、合并结果。绝不直接实现代码。
|
||||||
|
|
||||||
|
## 硬规则(违反=bug)
|
||||||
|
|
||||||
|
1. **Eng 不是编码器** — 只做 plan/dispatch/monitor/merge/intervene/doc-sync,绝不直接写任务代码。
|
||||||
|
2. **必须通过 /do 派发** — 每个任务必须 spawn 隔离的 /do 子代理(fork_context=false)。
|
||||||
|
3. **保持父线程精简** — 父线程只做调度操作。
|
||||||
|
4. **直接写代码唯一例外** — Worker 硬阻塞无法自恢复时的紧急干预,干预后立即回到调度模式。
|
||||||
|
5. **必须使用中文** — 所有状态报告、进度通知、问题描述均使用中文。禁止英文输出。
|
||||||
|
6. **自主决策原则** — 以推进开发进度为第一目标,以下情况自行决策不停下来问用户:
|
||||||
|
- Worker blocked 但修复预算未耗尽 → 自行派发修复
|
||||||
|
- Worker 停滞 → 自行执行停滞干预
|
||||||
|
- 验证失败但非关键 → 记录问题继续下一任务
|
||||||
|
- 波次间衔接 → 自行启动下一波次
|
||||||
|
7. **仅以下情况才询问用户**:修复预算耗尽且任务仍 blocked;需求歧义无法继续;系统资源耗尽;用户显式暂停。
|
||||||
|
|
||||||
|
## 子命令
|
||||||
|
|
||||||
|
### run (默认) — 一次完整调度步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub status
|
||||||
|
```
|
||||||
|
|
||||||
|
然后:
|
||||||
|
- 无活跃波次 → 派发下一波次
|
||||||
|
- 有活跃 Worker → 监控,不要盲目重新派发
|
||||||
|
- 每个 dispatched 任务 spawn 一个 /do 子代理(fork_context=false)
|
||||||
|
- 只传递项目路径+任务 handoff 内容,不要 fork 完整父对话
|
||||||
|
- 最多 recommendedConcurrency 个 Worker 同时活跃
|
||||||
|
- 就绪结果出现时:merge through `--sub merge --result <path>`
|
||||||
|
|
||||||
|
### status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub status
|
||||||
|
```
|
||||||
|
|
||||||
|
### plan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub plan --todo AirPlan/todo.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### dispatch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub dispatch
|
||||||
|
```
|
||||||
|
|
||||||
|
读取 AirPlan/state/aireng/dispatch/ 中的派发清单,为每个任务 spawn 一个隔离的 /do Worker 子代理。
|
||||||
|
|
||||||
|
### monitor
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
合并就绪结果,检测停滞 Worker,更新 nextAction。
|
||||||
|
|
||||||
|
### merge
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub merge --result <result-json>
|
||||||
|
```
|
||||||
|
|
||||||
|
### intervene
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub intervene
|
||||||
|
```
|
||||||
|
|
||||||
|
仅用于无法通过常规监控或重新派发解决的硬阻塞。
|
||||||
|
|
||||||
|
## 无人值守模式
|
||||||
|
|
||||||
|
- 每 300 秒重新检查活跃 Worker
|
||||||
|
- 当前波次收敛后自动派发下一波次
|
||||||
|
- 仅在状态达到 completed 或用户决策阻塞时停止
|
||||||
11
commands/ndb.md
Normal file
11
commands/ndb.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan ndb mode"
|
||||||
|
argument-hint: "[enter|status|...]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /ndb
|
||||||
|
|
||||||
|
Run AirPlan ndb mode.
|
||||||
|
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode ndb --project . [OPTIONS]
|
||||||
11
commands/rvr.md
Normal file
11
commands/rvr.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan rvr mode"
|
||||||
|
argument-hint: "[enter|status|...]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /rvr
|
||||||
|
|
||||||
|
Run AirPlan rvr mode.
|
||||||
|
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode rvr --project . [OPTIONS]
|
||||||
11
commands/sdb.md
Normal file
11
commands/sdb.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan sdb mode"
|
||||||
|
argument-hint: "[enter|status|...]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /sdb
|
||||||
|
|
||||||
|
Run AirPlan sdb mode.
|
||||||
|
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode sdb --project . [OPTIONS]
|
||||||
11
commands/sec.md
Normal file
11
commands/sec.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan sec mode"
|
||||||
|
argument-hint: "[enter|status|...]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /sec
|
||||||
|
|
||||||
|
Run AirPlan sec mode.
|
||||||
|
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode sec --project . [OPTIONS]
|
||||||
11
commands/tst.md
Normal file
11
commands/tst.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan tst mode"
|
||||||
|
argument-hint: "[enter|status|...]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /tst
|
||||||
|
|
||||||
|
Run AirPlan tst mode.
|
||||||
|
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode tst --project . [OPTIONS]
|
||||||
11
commands/xdb.md
Normal file
11
commands/xdb.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: "AirPlan xdb mode"
|
||||||
|
argument-hint: "[enter|status|...]"
|
||||||
|
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /xdb
|
||||||
|
|
||||||
|
Run AirPlan xdb mode.
|
||||||
|
|
||||||
|
python "$HOME/plugins/airplan/scripts/airplan.py" --mode xdb --project . [OPTIONS]
|
||||||
20
lib/air_runtime/__init__.py
Normal file
20
lib/air_runtime/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# air_runtime — AirPlan V2 核心运行时库
|
||||||
|
|
||||||
|
from air_runtime.io import atomic_json_write, safe_json_load
|
||||||
|
from air_runtime.lock import FileLock
|
||||||
|
from air_runtime.utils import ordered_unique, session_stamp, normalize_policy, sanitize_task_id, sanitize_marker
|
||||||
|
from air_runtime.events import EventLog
|
||||||
|
from air_runtime.task_graph import TaskGraph, PlanDelta
|
||||||
|
from air_runtime.worktree import WorktreeIsolation, RegionConflictDetector, ConflictLevel
|
||||||
|
from air_runtime.evidence_gate import EvidenceGatePolicy, EvidenceClass
|
||||||
|
from air_runtime.contracts import now_iso, WorkerResult, TaskRecord, DeploymentRecord
|
||||||
|
from air_runtime.paths import (
|
||||||
|
airplan_root, todo_path, plan_path,
|
||||||
|
state_root, engine_state_path, worker_state_path,
|
||||||
|
arc_state_path, dbg_state_path, xdb_state_path,
|
||||||
|
sdb_state_path, ndb_state_path, ctx_state_path,
|
||||||
|
dep_state_path, tst_state_path, sec_state_path, rvr_state_path,
|
||||||
|
required_project_artifacts,
|
||||||
|
)
|
||||||
|
|
||||||
|
__version__ = "2.0.0"
|
||||||
109
lib/air_runtime/contracts.py
Normal file
109
lib/air_runtime/contracts.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
数据契约 — 保持 V1 契约完整性,新增 DeploymentRecord、AirRvr 审查报告等结构。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WorkerResult:
|
||||||
|
task_id: str
|
||||||
|
status: str # done | blocked | failed
|
||||||
|
summary: str = ""
|
||||||
|
files_changed: list[str] = field(default_factory=list)
|
||||||
|
validations: list[dict] = field(default_factory=list)
|
||||||
|
document_updates: list[dict] = field(default_factory=list)
|
||||||
|
evidence: list[dict] = field(default_factory=list)
|
||||||
|
risks: list[str] = field(default_factory=list)
|
||||||
|
blockers: list[str] = field(default_factory=list)
|
||||||
|
deploy_required: bool = False
|
||||||
|
deploy_info: dict | None = None
|
||||||
|
|
||||||
|
def validate_for_finalize(self, brief: dict | None = None) -> None:
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_\-\.]+", self.task_id):
|
||||||
|
raise ValidationError(f"invalid task_id: {self.task_id!r}")
|
||||||
|
|
||||||
|
if self.status not in ("done", "blocked", "failed"):
|
||||||
|
raise ValidationError(f"invalid status: {self.status}")
|
||||||
|
|
||||||
|
if self.status == "done" and not self.validations and not self.files_changed:
|
||||||
|
raise ValidationError("done without validations or file changes")
|
||||||
|
|
||||||
|
if self.deploy_required:
|
||||||
|
deploy_validations = [
|
||||||
|
v for v in self.validations
|
||||||
|
if v.get("kind") in ("remote-deploy-verify", "remote-binary-md5")
|
||||||
|
]
|
||||||
|
if not deploy_validations:
|
||||||
|
raise ValidationError("deploy_required but no deploy verification in validations")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"taskId": self.task_id,
|
||||||
|
"status": self.status,
|
||||||
|
"summary": self.summary,
|
||||||
|
"filesChanged": self.files_changed,
|
||||||
|
"validations": self.validations,
|
||||||
|
"documentUpdates": self.document_updates,
|
||||||
|
"evidence": self.evidence,
|
||||||
|
"risks": self.risks,
|
||||||
|
"blockers": self.blockers,
|
||||||
|
"deployRequired": self.deploy_required,
|
||||||
|
"deployInfo": self.deploy_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> WorkerResult:
|
||||||
|
return cls(
|
||||||
|
task_id=data.get("taskId", ""),
|
||||||
|
status=data.get("status", ""),
|
||||||
|
summary=data.get("summary", ""),
|
||||||
|
files_changed=data.get("filesChanged", []),
|
||||||
|
validations=data.get("validations", []),
|
||||||
|
document_updates=data.get("documentUpdates", []),
|
||||||
|
evidence=data.get("evidence", []),
|
||||||
|
risks=data.get("risks", []),
|
||||||
|
blockers=data.get("blockers", []),
|
||||||
|
deploy_required=data.get("deployRequired", False),
|
||||||
|
deploy_info=data.get("deployInfo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskRecord:
|
||||||
|
task_id: str
|
||||||
|
task: str = ""
|
||||||
|
files_dirs: str = ""
|
||||||
|
done_when: str = ""
|
||||||
|
status: str = "TODO"
|
||||||
|
validations: str = ""
|
||||||
|
adr: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text_for_classification(self) -> str:
|
||||||
|
return f"{self.task} {self.files_dirs} {self.done_when}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeploymentRecord:
|
||||||
|
task_id: str
|
||||||
|
host: str
|
||||||
|
binary_path: str
|
||||||
|
md5: str = ""
|
||||||
|
service_name: str = ""
|
||||||
|
service_status: str = ""
|
||||||
|
deploy_at: str = field(default_factory=now_iso)
|
||||||
|
smoke_test_passed: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(Exception):
|
||||||
|
pass
|
||||||
133
lib/air_runtime/deploy_runtime.py
Normal file
133
lib/air_runtime/deploy_runtime.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
AirDep 部署运行时 — V2 新增组件。
|
||||||
|
SSH 远程构建 + 二进制传输 + systemd 生命周期管理 + 部署验证。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.contracts import DeploymentRecord, now_iso
|
||||||
|
from air_runtime.io import atomic_json_write, safe_json_load
|
||||||
|
from air_runtime.paths import dep_state_path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeployTarget:
|
||||||
|
host: str
|
||||||
|
user: str = "root"
|
||||||
|
port: int = 22
|
||||||
|
build_dir: str = "/tmp/airdep-build"
|
||||||
|
deploy_dir: str = "/opt/app"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeployResult:
|
||||||
|
task_id: str
|
||||||
|
success: bool
|
||||||
|
binary_md5: str = ""
|
||||||
|
service_status: str = ""
|
||||||
|
error: str = ""
|
||||||
|
journal_excerpt: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh(target: DeployTarget, cmd: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["ssh", "-o", "StrictHostKeyChecking=accept-new", "-p", str(target.port),
|
||||||
|
f"{target.user}@{target.host}", cmd],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scp(target: DeployTarget, local: Path, remote: str, timeout: int = 300) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["scp", "-o", "StrictHostKeyChecking=accept-new", "-P", str(target.port),
|
||||||
|
str(local), f"{target.user}@{target.host}:{remote}"],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _md5_file(path: Path) -> str:
|
||||||
|
h = hashlib.md5()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
while chunk := f.read(8192):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def deploy(task_id: str, project_root: Path, target: DeployTarget,
|
||||||
|
local_binary: Path, service_name: str,
|
||||||
|
build_cmd: str | None = None,
|
||||||
|
smoke_test_cmd: str | None = None) -> DeployResult:
|
||||||
|
"""执行完整部署流程:构建 → 传输 → systemd → 验证。"""
|
||||||
|
state_file = dep_state_path(project_root)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: 远程构建(可选)
|
||||||
|
if build_cmd:
|
||||||
|
result = _ssh(target, f"cd {target.build_dir} && {build_cmd}", timeout=600)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return DeployResult(task_id=task_id, success=False,
|
||||||
|
error=f"build failed: {result.stderr[-500:]}")
|
||||||
|
|
||||||
|
# Step 2: 二进制传输 + MD5 校验
|
||||||
|
remote_tmp = f"/tmp/{local_binary.name}"
|
||||||
|
scp_result = _scp(target, local_binary, remote_tmp)
|
||||||
|
if scp_result.returncode != 0:
|
||||||
|
return DeployResult(task_id=task_id, success=False,
|
||||||
|
error=f"scp failed: {scp_result.stderr[-500:]}")
|
||||||
|
|
||||||
|
local_md5 = _md5_file(local_binary)
|
||||||
|
md5_result = _ssh(target, f"md5sum {remote_tmp} | cut -d' ' -f1")
|
||||||
|
remote_md5 = md5_result.stdout.strip()
|
||||||
|
if local_md5 != remote_md5:
|
||||||
|
return DeployResult(task_id=task_id, success=False,
|
||||||
|
binary_md5=f"local={local_md5} remote={remote_md5}",
|
||||||
|
error="md5 mismatch after transfer")
|
||||||
|
|
||||||
|
# Step 3: 部署二进制
|
||||||
|
_ssh(target, f"mv {remote_tmp} {target.deploy_dir}/{local_binary.name}")
|
||||||
|
|
||||||
|
# Step 4: systemd 生命周期
|
||||||
|
_ssh(target, f"systemctl daemon-reload")
|
||||||
|
_ssh(target, f"systemctl restart {service_name}")
|
||||||
|
status_result = _ssh(target, f"systemctl is-active {service_name}")
|
||||||
|
service_status = status_result.stdout.strip()
|
||||||
|
|
||||||
|
# Step 5: 冒烟验证(可选)
|
||||||
|
smoke_passed: bool | None = None
|
||||||
|
if smoke_test_cmd:
|
||||||
|
smoke_result = _ssh(target, smoke_test_cmd, timeout=60)
|
||||||
|
smoke_passed = (smoke_result.returncode == 0)
|
||||||
|
|
||||||
|
# Step 6: 记录部署产物
|
||||||
|
record = DeploymentRecord(
|
||||||
|
task_id=task_id,
|
||||||
|
host=target.host,
|
||||||
|
binary_path=str(target.deploy_dir / local_binary.name),
|
||||||
|
md5=local_md5,
|
||||||
|
service_name=service_name,
|
||||||
|
service_status=service_status,
|
||||||
|
smoke_test_passed=smoke_passed,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 持久化
|
||||||
|
sessions = safe_json_load(state_file) or {"sessions": []}
|
||||||
|
if isinstance(sessions, dict):
|
||||||
|
sessions.setdefault("sessions", []).append(record.__dict__)
|
||||||
|
atomic_json_write(state_file, sessions)
|
||||||
|
|
||||||
|
return DeployResult(
|
||||||
|
task_id=task_id,
|
||||||
|
success=service_status == "active",
|
||||||
|
binary_md5=local_md5,
|
||||||
|
service_status=service_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return DeployResult(task_id=task_id, success=False, error=f"timeout: {exc}")
|
||||||
|
except Exception as exc:
|
||||||
|
return DeployResult(task_id=task_id, success=False, error=str(exc))
|
||||||
102
lib/air_runtime/events.py
Normal file
102
lib/air_runtime/events.py
Normal 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
|
||||||
74
lib/air_runtime/evidence_gate.py
Normal file
74
lib/air_runtime/evidence_gate.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
任务类型感知的证据门控 — V2 P0-1 修复。
|
||||||
|
替代 V1 无差别触发 AirXDB 的逻辑,根据任务特征差异化要求证据类型。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceClass(enum.Enum):
|
||||||
|
GUI_REQUIRED = "gui_required" # 需要截图/GUI 操作验证
|
||||||
|
NETWORK_REQUIRED = "network_required" # 需要抓包/流量验证
|
||||||
|
STATIC_ANALYSIS = "static_analysis" # 需要静态分析
|
||||||
|
CODE_ONLY = "code_only" # 代码级验证即可
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EvidenceGatePolicy:
|
||||||
|
"""基于任务特征的差异化证据要求。
|
||||||
|
|
||||||
|
关键词匹配 → 确定性分类,零额外 API 调用,可测试可覆盖。
|
||||||
|
用户可在 todo.md 中用 [no-xdb] / [no-ndb] 标记显式跳过。
|
||||||
|
"""
|
||||||
|
|
||||||
|
GUI_INDICATORS: tuple[str, ...] = (
|
||||||
|
"gui", "ui", "render", "layout", "dialog", "osd",
|
||||||
|
"overlay", "visual", "screenshot", "display",
|
||||||
|
"widget", "pane", "toolbar", "settings_dialog",
|
||||||
|
"canvas", "button", "window", "popup", "menu",
|
||||||
|
"drm", "kms", "opengl", "vulkan",
|
||||||
|
)
|
||||||
|
|
||||||
|
NETWORK_INDICATORS: tuple[str, ...] = (
|
||||||
|
"network", "rtsp", "http", "tcp", "udp", "tls",
|
||||||
|
"dns", "proxy", "socket", "stream", "port",
|
||||||
|
"packet", "pcap", "bandwidth", "latency",
|
||||||
|
)
|
||||||
|
|
||||||
|
STATIC_ANALYSIS_INDICATORS: tuple[str, ...] = (
|
||||||
|
"cppcheck", "clang-tidy", "mypy", "ruff", "lint",
|
||||||
|
"static analysis", "compile_commands",
|
||||||
|
)
|
||||||
|
|
||||||
|
SKIP_MARKERS: tuple[str, ...] = ("[no-xdb]", "[no-ndb]", "[no-sdb]")
|
||||||
|
|
||||||
|
def classify(self, task_text: str, task_id: str = "", skip_overrides: list[str] | None = None) -> EvidenceClass:
|
||||||
|
text = task_text.lower()
|
||||||
|
skips = set(skip_overrides or [])
|
||||||
|
for marker in self.SKIP_MARKERS:
|
||||||
|
if marker in text:
|
||||||
|
skips.add(marker)
|
||||||
|
|
||||||
|
has_gui = any(kw in text for kw in self.GUI_INDICATORS)
|
||||||
|
has_net = any(kw in text for kw in self.NETWORK_INDICATORS)
|
||||||
|
has_static = any(kw in text for kw in self.STATIC_ANALYSIS_INDICATORS)
|
||||||
|
|
||||||
|
if has_gui and "[no-xdb]" not in skips:
|
||||||
|
return EvidenceClass.GUI_REQUIRED
|
||||||
|
if has_net and "[no-ndb]" not in skips:
|
||||||
|
return EvidenceClass.NETWORK_REQUIRED
|
||||||
|
if has_static and "[no-sdb]" not in skips:
|
||||||
|
return EvidenceClass.STATIC_ANALYSIS
|
||||||
|
return EvidenceClass.CODE_ONLY
|
||||||
|
|
||||||
|
def required_evidence_for(self, evidence_class: EvidenceClass) -> list[str]:
|
||||||
|
evidence_map = {
|
||||||
|
EvidenceClass.GUI_REQUIRED: ["screenshot", "gui-operation-validation"],
|
||||||
|
EvidenceClass.NETWORK_REQUIRED: ["packet-capture", "connectivity-verification"],
|
||||||
|
EvidenceClass.STATIC_ANALYSIS: ["static-analysis-report"],
|
||||||
|
EvidenceClass.CODE_ONLY: ["code-review", "test-results"],
|
||||||
|
}
|
||||||
|
return evidence_map.get(evidence_class, ["code-review"])
|
||||||
121
lib/air_runtime/installer.py
Normal file
121
lib/air_runtime/installer.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_plugin_paths() -> dict[str, Path]:
|
||||||
|
"""
|
||||||
|
解析插件的实际安装路径。
|
||||||
|
顺序: 1) 环境变量 AIRPLAN_HOME > 2) ~/.airplan > 3) 相对脚本位置推测
|
||||||
|
"""
|
||||||
|
# 1. 环境变量
|
||||||
|
if "AIRPLAN_HOME" in __import__("os").environ:
|
||||||
|
return {"root": Path(__import__("os").environ["AIRPLAN_HOME"])}
|
||||||
|
|
||||||
|
# 2. ~/.airplan 默认
|
||||||
|
home = Path.home()
|
||||||
|
default = home / ".airplan"
|
||||||
|
if default.exists():
|
||||||
|
return {"root": default}
|
||||||
|
|
||||||
|
# 3. 尝试从当前脚本位置推测
|
||||||
|
# 脚本位于 {root}/scripts/airplan.py
|
||||||
|
import sys
|
||||||
|
script_root = Path(sys.argv[0]).resolve().parent if sys.argv else None
|
||||||
|
if script_root and (script_root.name == "scripts"):
|
||||||
|
plugin_root = script_root.parent
|
||||||
|
if (plugin_root / ".claude-plugin").exists():
|
||||||
|
return {"root": plugin_root}
|
||||||
|
|
||||||
|
# 4. 从本模块位置推测
|
||||||
|
# 本模块位于 {root}/lib/air_runtime/installer.py
|
||||||
|
module_root = Path(__file__).resolve().parent.parent.parent # lib/air_runtime/installer -> lib/air_runtime -> lib -> root
|
||||||
|
if (module_root / ".claude-plugin").exists():
|
||||||
|
return {"root": module_root}
|
||||||
|
|
||||||
|
raise FileNotFoundError("Cannot locate plugin installation directory")
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_meta() -> dict:
|
||||||
|
"""读取 plugin.json 元数据"""
|
||||||
|
paths = resolve_plugin_paths()
|
||||||
|
meta_path = paths["root"] / ".claude-plugin" / "plugin.json"
|
||||||
|
if not meta_path.exists():
|
||||||
|
raise FileNotFoundError(f"plugin.json not found at {meta_path}")
|
||||||
|
return json.loads(meta_path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def post_install_verify() -> dict:
|
||||||
|
"""
|
||||||
|
安装后验证:检查所有依赖工具是否存在。
|
||||||
|
返回 {tool: bool} 映射,False 表示缺失。
|
||||||
|
"""
|
||||||
|
required_tools = [
|
||||||
|
"git", # 版本控制
|
||||||
|
"cmake", # 构建
|
||||||
|
"ffmpeg", # XDB 截图
|
||||||
|
"xvfb-run", # XDB 虚拟显示
|
||||||
|
]
|
||||||
|
|
||||||
|
result = {"ok": True, "missing": []}
|
||||||
|
for tool in required_tools:
|
||||||
|
found = shutil.which(tool) is not None
|
||||||
|
result[tool] = found
|
||||||
|
if not found:
|
||||||
|
result["ok"] = False
|
||||||
|
result["missing"].append(tool)
|
||||||
|
|
||||||
|
# 检查 Python 依赖
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
result["pyyaml"] = True
|
||||||
|
except ImportError:
|
||||||
|
result["pyyaml"] = False
|
||||||
|
result["ok"] = False
|
||||||
|
result["missing"].append("pyyaml")
|
||||||
|
|
||||||
|
# 检查目录结构
|
||||||
|
paths = resolve_plugin_paths()
|
||||||
|
for subdir in ["lib", "scripts", "skills", "commands"]:
|
||||||
|
p = paths["root"] / subdir
|
||||||
|
result[f"dir_{subdir}"] = p.exists()
|
||||||
|
if not p.exists():
|
||||||
|
result["ok"] = False
|
||||||
|
result["missing"].append(f"dir:{subdir}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def verify_plugin_json_paths() -> dict:
|
||||||
|
"""
|
||||||
|
验证 plugin.json 里的路径是否可解析。
|
||||||
|
设计原文 P0-9 指出硬编码 $HOME 是问题,这里修复它。
|
||||||
|
"""
|
||||||
|
meta = get_plugin_meta()
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# 检查 scripts 路径
|
||||||
|
entry = meta.get("entry", {})
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
script_path_str = entry.get("args", [""])[0] if entry.get("args") else ""
|
||||||
|
else:
|
||||||
|
script_path_str = str(entry)
|
||||||
|
if "$HOME" in script_path_str:
|
||||||
|
# 尝试解析
|
||||||
|
resolved = script_path_str.replace("$HOME", str(Path.home()))
|
||||||
|
if not Path(resolved).exists():
|
||||||
|
issues.append(f"script path not found: {resolved}")
|
||||||
|
else:
|
||||||
|
# 修复:改用相对路径或 AIRPLAN_HOME 变量
|
||||||
|
issues.append(f"script uses $HOME: {script_path_str} (should use relative path)")
|
||||||
|
|
||||||
|
# 检查 marketplace 路径
|
||||||
|
market_path = meta.get("marketplace", "")
|
||||||
|
if "$HOME" in market_path:
|
||||||
|
issues.append(f"marketplace uses $HOME: {market_path}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issues": issues,
|
||||||
|
"resolved_paths": resolve_plugin_paths(),
|
||||||
|
}
|
||||||
53
lib/air_runtime/io.py
Normal file
53
lib/air_runtime/io.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
原子 I/O 模块 — 消除 V1 5 份 _json_dump/_json_load 重复。
|
||||||
|
每次写入使用 tempfile + os.replace() 保证原子性,写入前自动备份。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json_write(path: Path, data: dict | list, indent: int = 2) -> None:
|
||||||
|
"""POSIX 原子写入:tempfile + os.replace()。写入前自动备份旧文件为 .bak(单级轮转)。"""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 备份旧文件
|
||||||
|
if path.exists():
|
||||||
|
bak = path.with_suffix(path.suffix + ".bak")
|
||||||
|
try:
|
||||||
|
os.replace(str(path), str(bak))
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("backup %s -> %s failed: %s", path, bak, exc)
|
||||||
|
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
||||||
|
try:
|
||||||
|
os.write(fd, json.dumps(data, indent=indent, ensure_ascii=False).encode("utf-8"))
|
||||||
|
os.close(fd)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def safe_json_load(path: Path) -> dict | list | None:
|
||||||
|
"""安全加载:处理损坏文件,自动从 .bak 恢复。文件不存在返回 None。"""
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text("utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
bak = path.with_suffix(path.suffix + ".bak")
|
||||||
|
if bak.exists():
|
||||||
|
logger.warning("corrupt %s, restoring from %s", path, bak)
|
||||||
|
return json.loads(bak.read_text("utf-8"))
|
||||||
|
logger.error("corrupt %s with no backup", path)
|
||||||
|
return None
|
||||||
44
lib/air_runtime/lock.py
Normal file
44
lib/air_runtime/lock.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
文件级并发控制 — 解决 V1 P0-4 零并发控制问题。
|
||||||
|
基于 fcntl.flock 的进程级文件锁,超时自动释放。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class FileLock:
|
||||||
|
"""基于 fcntl.flock(LOCK_EX | LOCK_NB) 的进程级文件锁"""
|
||||||
|
|
||||||
|
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
|
||||||
|
self._timeout = timeout
|
||||||
|
self._fd: int | None = None
|
||||||
|
|
||||||
|
def __enter__(self) -> FileLock:
|
||||||
|
self._fd = os.open(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:
|
||||||
|
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:
|
||||||
|
if self._fd is not None:
|
||||||
|
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
||||||
|
os.close(self._fd)
|
||||||
|
self._fd = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> Path:
|
||||||
|
return self._path
|
||||||
11
lib/air_runtime/modes/__init__.py
Normal file
11
lib/air_runtime/modes/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""模式模块 init"""
|
||||||
|
|
||||||
|
from air_runtime.modes import arc_mode, eng_mode, do_mode, dbg_mode
|
||||||
|
from air_runtime.modes import xdb_mode, sdb_mode, ndb_mode
|
||||||
|
from air_runtime.modes import ctx_mode, dep_mode, tst_mode, sec_mode, rvr_mode
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"arc_mode", "eng_mode", "do_mode", "dbg_mode",
|
||||||
|
"xdb_mode", "sdb_mode", "ndb_mode",
|
||||||
|
"ctx_mode", "dep_mode", "tst_mode", "sec_mode", "rvr_mode",
|
||||||
|
]
|
||||||
339
lib/air_runtime/modes/arc_mode.py
Normal file
339
lib/air_runtime/modes/arc_mode.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
AirArc mode — V2 架构规划器。
|
||||||
|
L1 代码级保障:allowed-tools 限制为只读。
|
||||||
|
产出 execution-plan.json(完整 DAG)+ plan-delta.json(增量重规划)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.contracts import now_iso
|
||||||
|
from air_runtime.io import atomic_json_write, safe_json_load
|
||||||
|
from air_runtime.paths import airplan_root, todo_path as get_todo_path
|
||||||
|
from air_runtime.review import build_parallel_review, render_review_markdown
|
||||||
|
from air_runtime.task_graph import TaskGraph, TaskNode, Edge, PlanDelta
|
||||||
|
from air_runtime.events import EventLog
|
||||||
|
from air_runtime.paths import event_log_path
|
||||||
|
from air_runtime.utils import ordered_unique
|
||||||
|
|
||||||
|
|
||||||
|
class ArcPhaseGate:
|
||||||
|
"""三阶段门控:discussing → proposing → confirmed。
|
||||||
|
execution-plan.json 仅在 phase=confirmed 时允许写入。
|
||||||
|
"""
|
||||||
|
PHASES = ["discussing", "proposing", "confirmed"]
|
||||||
|
|
||||||
|
def __init__(self, state_path: Path):
|
||||||
|
self._state_path = state_path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_phase(self) -> str:
|
||||||
|
data = safe_json_load(self._state_path) or {}
|
||||||
|
return data.get("arcPhase", "discussing")
|
||||||
|
|
||||||
|
def advance_to(self, phase: str) -> None:
|
||||||
|
if phase not in self.PHASES:
|
||||||
|
raise ValueError(f"invalid phase: {phase!r}")
|
||||||
|
idx_current = self.PHASES.index(self.current_phase)
|
||||||
|
idx_target = self.PHASES.index(phase)
|
||||||
|
if idx_target <= idx_current:
|
||||||
|
return
|
||||||
|
data = safe_json_load(self._state_path) or {}
|
||||||
|
data["arcPhase"] = phase
|
||||||
|
data[f"arcPhase_{phase}At"] = now_iso()
|
||||||
|
atomic_json_write(self._state_path, data)
|
||||||
|
|
||||||
|
def can_write_plan(self) -> bool:
|
||||||
|
return self.current_phase == "confirmed"
|
||||||
|
|
||||||
|
def confirm_architecture(self, user_confirmation: str) -> bool:
|
||||||
|
"""检查用户确认文本中的关键词,确认后推进到 confirmed。"""
|
||||||
|
confirm_keywords = ["确认", "可以", "同意", "confirm", "yes", "ok", "好的", "没问题"]
|
||||||
|
if any(kw in user_confirmation.lower() for kw in confirm_keywords):
|
||||||
|
self.advance_to("confirmed")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _paths(project_root: Path) -> dict[str, Path]:
|
||||||
|
root = airplan_root(project_root) / "state" / "airarc"
|
||||||
|
return {
|
||||||
|
"root": root,
|
||||||
|
"state": root / "state.json",
|
||||||
|
"reviews_dir": root / "reviews",
|
||||||
|
"execution_plan_json": root / "reviews" / "execution-plan.json",
|
||||||
|
"execution_plan_md": root / "reviews" / "execution-plan.md",
|
||||||
|
"plan_delta_json": root / "reviews" / "plan-delta.json",
|
||||||
|
"task_graph_json": root / "reviews" / "task-graph.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dirs(paths: dict[str, Path]) -> None:
|
||||||
|
paths["reviews_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _export_task_graph_json(graph: TaskGraph, path: Path) -> None:
|
||||||
|
data = {
|
||||||
|
"nodes": {nid: {"id": n.id, "status": n.status, "task": n.task,
|
||||||
|
"filesDirs": n.files_dirs, "doneWhen": n.done_when,
|
||||||
|
"inDegree": n.in_degree, "outEdges": n.out_edges,
|
||||||
|
"writeSet": n.write_set, "testRequired": n.test_required}
|
||||||
|
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 条件的任务列表。"""
|
||||||
|
from air_runtime.todo_parser import parse_tasks
|
||||||
|
tasks = parse_tasks(todo_path)
|
||||||
|
graph = TaskGraph()
|
||||||
|
violations = [] # P1-19.1: 记录 Done When 不含"测试通过"的任务
|
||||||
|
|
||||||
|
for t in tasks:
|
||||||
|
node = TaskNode(
|
||||||
|
id=t.task_id, status=t.status, task=t.task,
|
||||||
|
files_dirs=t.files_dirs, done_when=t.done_when,
|
||||||
|
)
|
||||||
|
graph.add_node(node)
|
||||||
|
|
||||||
|
# P1-19.1: Done When 必须包含"测试通过"
|
||||||
|
if t.done_when and "测试通过" not in t.done_when:
|
||||||
|
violations.append(t.task_id)
|
||||||
|
|
||||||
|
# P1-19.1: 注入边界测试任务
|
||||||
|
_inject_boundary_tests(graph, tasks)
|
||||||
|
|
||||||
|
return graph, violations
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_boundary_tests(graph: TaskGraph, tasks: list) -> None:
|
||||||
|
"""P1-19.1: 为每个模块边界注入测试任务。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 每个模块的公共接口必须有对应的接口测试任务
|
||||||
|
- 每个模块的核心逻辑必须有对应的单元测试任务
|
||||||
|
- 测试任务标记 test_required=True
|
||||||
|
- Done When 必须包含"测试通过"
|
||||||
|
"""
|
||||||
|
# 从 tasks 提取模块信息(通过 files_dirs 推断模块)
|
||||||
|
module_files: dict[str, set[str]] = {}
|
||||||
|
for t in tasks:
|
||||||
|
if t.files_dirs:
|
||||||
|
for fd in t.files_dirs.split(","):
|
||||||
|
fd = fd.strip()
|
||||||
|
if fd:
|
||||||
|
# 取第一级目录作为模块名
|
||||||
|
parts = fd.split("/")
|
||||||
|
if len(parts) > 1:
|
||||||
|
module = parts[0]
|
||||||
|
else:
|
||||||
|
module = fd.split(".")[0] if "." in fd else fd
|
||||||
|
module_files.setdefault(module, set()).add(fd)
|
||||||
|
|
||||||
|
# 为每个模块创建测试任务
|
||||||
|
test_counter = 0
|
||||||
|
for module, files in module_files.items():
|
||||||
|
if not files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 接口测试任务(模块边界)
|
||||||
|
test_counter += 1
|
||||||
|
interface_test_id = f"T-TEST-{test_counter:03d}"
|
||||||
|
interface_test_node = TaskNode(
|
||||||
|
id=interface_test_id,
|
||||||
|
status="TODO",
|
||||||
|
task=f"[边界测试] {module} 模块接口测试",
|
||||||
|
files_dirs=",".join(sorted(files)),
|
||||||
|
done_when="测试通过",
|
||||||
|
test_required=True,
|
||||||
|
)
|
||||||
|
graph.add_node(interface_test_node)
|
||||||
|
|
||||||
|
# 单元测试任务(核心逻辑)
|
||||||
|
test_counter += 1
|
||||||
|
unit_test_id = f"T-TEST-{test_counter:03d}"
|
||||||
|
unit_test_node = TaskNode(
|
||||||
|
id=unit_test_id,
|
||||||
|
status="TODO",
|
||||||
|
task=f"[单元测试] {module} 模块核心逻辑测试",
|
||||||
|
files_dirs=",".join(sorted(files)),
|
||||||
|
done_when="测试通过",
|
||||||
|
test_required=True,
|
||||||
|
)
|
||||||
|
graph.add_node(unit_test_node)
|
||||||
|
|
||||||
|
# 添加依赖边:实现任务 → 测试任务
|
||||||
|
for tid, node in graph.nodes.items():
|
||||||
|
if tid.startswith("T-TEST-"):
|
||||||
|
continue
|
||||||
|
# 检查是否属于同一模块
|
||||||
|
node_files = set(node.files_dirs.split(",")) if node.files_dirs else set()
|
||||||
|
if node_files & files: # 有交集,说明是同一模块的任务
|
||||||
|
graph.add_edge(Edge(source=tid, target=interface_test_id, kind="dependency"))
|
||||||
|
graph.add_edge(Edge(source=tid, target=unit_test_id, kind="dependency"))
|
||||||
|
|
||||||
|
|
||||||
|
def enter_mode(project_root: Path) -> dict:
|
||||||
|
paths = _paths(project_root)
|
||||||
|
_ensure_dirs(paths)
|
||||||
|
payload = {"enabled": True, "updatedAt": now_iso(), "projectRoot": str(project_root)}
|
||||||
|
atomic_json_write(paths["state"], payload)
|
||||||
|
return {"state_path": str(paths["state"])}
|
||||||
|
|
||||||
|
|
||||||
|
def parallel_review_mode(project_root: Path, todo_path: Path) -> dict:
|
||||||
|
paths = _paths(project_root)
|
||||||
|
_ensure_dirs(paths)
|
||||||
|
|
||||||
|
# 三阶段门控:仅在 confirmed 阶段允许写入 execution-plan.json
|
||||||
|
gate = ArcPhaseGate(paths["state"])
|
||||||
|
if not gate.can_write_plan():
|
||||||
|
return {
|
||||||
|
"blocked": True,
|
||||||
|
"reason": f"arc phase is '{gate.current_phase}', must be 'confirmed' before generating plan",
|
||||||
|
"currentPhase": gate.current_phase,
|
||||||
|
}
|
||||||
|
|
||||||
|
review = build_parallel_review(todo_path)
|
||||||
|
review_json_path = paths["reviews_dir"] / "parallel-review.json"
|
||||||
|
review_md_path = paths["reviews_dir"] / "parallel-review.md"
|
||||||
|
atomic_json_write(review_json_path, review.to_dict())
|
||||||
|
review_md_path.write_text(render_review_markdown(review), encoding="utf-8")
|
||||||
|
|
||||||
|
# 构建 DAG(包含边界测试任务注入)
|
||||||
|
graph, done_when_violations = _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")))
|
||||||
|
|
||||||
|
_export_task_graph_json(graph, paths["task_graph_json"])
|
||||||
|
|
||||||
|
# 生成执行计划
|
||||||
|
selected_tasks = review.parallel_groups[0].task_ids if review.parallel_groups else []
|
||||||
|
execution_plan = {
|
||||||
|
"generatedAt": now_iso(), "projectRoot": str(project_root),
|
||||||
|
"todoPath": str(todo_path), "planSource": "airarc-post-plan-review",
|
||||||
|
"parallelReview": review.to_dict(),
|
||||||
|
"selectedTasks": selected_tasks,
|
||||||
|
"parallelGroups": [g.to_dict() for g in review.parallel_groups],
|
||||||
|
"conflicts": [c.to_dict() for c in review.conflicts],
|
||||||
|
"serializationPoints": review.serialization_points,
|
||||||
|
"doneWhenViolations": done_when_violations, # P1-19.1: Done When 不含"测试通过"的任务
|
||||||
|
"boundaryTestTasks": [n.id for n in graph.nodes.values() if n.test_required],
|
||||||
|
}
|
||||||
|
atomic_json_write(paths["execution_plan_json"], execution_plan)
|
||||||
|
|
||||||
|
markdown_lines = [
|
||||||
|
"# AirArc Execution Plan", "",
|
||||||
|
f"- Generated: `{execution_plan['generatedAt']}`",
|
||||||
|
f"- Plan Source: `{execution_plan['planSource']}`", "",
|
||||||
|
"## Selected Tasks",
|
||||||
|
]
|
||||||
|
for tid in selected_tasks:
|
||||||
|
markdown_lines.append(f"- `{tid}`")
|
||||||
|
markdown_lines.extend(["", "## Parallel Groups"])
|
||||||
|
for g in review.parallel_groups:
|
||||||
|
markdown_lines.append(f"- `{g.name}`: {', '.join(g.task_ids)} — {g.reason}")
|
||||||
|
paths["execution_plan_md"].write_text("\n".join(markdown_lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(),
|
||||||
|
"projectRoot": str(project_root)})
|
||||||
|
return {"json_path": str(review_json_path), "markdown_path": str(review_md_path),
|
||||||
|
"execution_plan_json_path": str(paths["execution_plan_json"]),
|
||||||
|
"parallel_group_count": len(review.parallel_groups),
|
||||||
|
"conflict_count": len(review.conflicts),
|
||||||
|
"done_when_violations": done_when_violations,
|
||||||
|
"boundary_test_task_count": len([n for n in graph.nodes.values() if n.test_required])}
|
||||||
|
|
||||||
|
|
||||||
|
def incremental_replan_mode(project_root: Path, todo_path: Path, previous_graph_path: Path | None = None) -> dict:
|
||||||
|
"""增量重规划:产出 PlanDelta 并喂回 prev graph,再写 plan-delta.json。"""
|
||||||
|
paths = _paths(project_root)
|
||||||
|
_ensure_dirs(paths)
|
||||||
|
|
||||||
|
review = build_parallel_review(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")))
|
||||||
|
|
||||||
|
delta = PlanDelta()
|
||||||
|
if previous_graph_path and previous_graph_path.exists():
|
||||||
|
prev_graph = TaskGraph.load(previous_graph_path)
|
||||||
|
delta = new_graph.diff(prev_graph)
|
||||||
|
# 关键:把 delta 喂回去给 prev graph,保留已调度状态
|
||||||
|
if previous_graph_path.exists():
|
||||||
|
prev_graph.apply_delta(delta)
|
||||||
|
_export_task_graph_json(prev_graph, previous_graph_path)
|
||||||
|
else:
|
||||||
|
delta.added_tasks = list(new_graph.nodes.values())
|
||||||
|
delta.edge_changes.added = list(new_graph.edges)
|
||||||
|
|
||||||
|
atomic_json_write(paths["plan_delta_json"], {
|
||||||
|
"generatedAt": now_iso(),
|
||||||
|
"removedTasks": delta.removed_tasks,
|
||||||
|
"addedTasks": [{"id": n.id, "task": n.task, "filesDirs": n.files_dirs,
|
||||||
|
"doneWhen": n.done_when, "writeSet": n.write_set}
|
||||||
|
for n in delta.added_tasks],
|
||||||
|
"modifiedTasks": [{"id": n.id, "task": n.task, "filesDirs": n.files_dirs,
|
||||||
|
"doneWhen": n.done_when, "writeSet": n.write_set}
|
||||||
|
for n in delta.modified_tasks],
|
||||||
|
"edgeChanges": {
|
||||||
|
"added": [{"source": e.source, "target": e.target, "kind": e.kind}
|
||||||
|
for e in delta.edge_changes.added],
|
||||||
|
"removed": [{"source": e.source, "target": e.target, "kind": e.kind}
|
||||||
|
for e in delta.edge_changes.removed],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
_export_task_graph_json(new_graph, paths["task_graph_json"])
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit("arc.replanned", {"delta_added": len(delta.added_tasks),
|
||||||
|
"delta_removed": len(delta.removed_tasks),
|
||||||
|
"delta_modified": len(delta.modified_tasks),
|
||||||
|
"edges_added": len(delta.edge_changes.added),
|
||||||
|
"edges_removed": len(delta.edge_changes.removed)})
|
||||||
|
|
||||||
|
return {"plan_delta_json_path": str(paths["plan_delta_json"]),
|
||||||
|
"task_graph_json_path": str(paths["task_graph_json"]),
|
||||||
|
"added_count": len(delta.added_tasks),
|
||||||
|
"removed_count": len(delta.removed_tasks),
|
||||||
|
"modified_count": len(delta.modified_tasks),
|
||||||
|
"edges_added": len(delta.edge_changes.added),
|
||||||
|
"edges_removed": len(delta.edge_changes.removed)}
|
||||||
|
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
sub = args.sub or "status"
|
||||||
|
|
||||||
|
if sub == "enter":
|
||||||
|
result = enter_mode(project_root)
|
||||||
|
print("airplan_mode=arc")
|
||||||
|
print(f"state_path={result['state_path']}")
|
||||||
|
elif sub == "parallel-review":
|
||||||
|
tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root)
|
||||||
|
result = parallel_review_mode(project_root, tpath)
|
||||||
|
print("airplan_mode=arc")
|
||||||
|
print(f"json_path={result['json_path']}")
|
||||||
|
print(f"parallel_group_count={result['parallel_group_count']}")
|
||||||
|
print(f"conflict_count={result['conflict_count']}")
|
||||||
|
elif sub == "incremental-replan":
|
||||||
|
tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root)
|
||||||
|
prev = _paths(project_root)["task_graph_json"]
|
||||||
|
result = incremental_replan_mode(project_root, tpath, prev)
|
||||||
|
print("airplan_mode=arc")
|
||||||
|
print(f"plan_delta_path={result['plan_delta_json_path']}")
|
||||||
|
print(f"added={result['added_count']} removed={result['removed_count']}")
|
||||||
|
else:
|
||||||
|
paths = _paths(project_root)
|
||||||
|
state = safe_json_load(paths["state"]) or {}
|
||||||
|
print(f"airplan_mode=arc")
|
||||||
|
print(f"enabled={state.get('enabled', False)}")
|
||||||
232
lib/air_runtime/modes/ctx_mode.py
Normal file
232
lib/air_runtime/modes/ctx_mode.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
AirContext mode — V2 上下文管理器。
|
||||||
|
V2 改进:压缩质量校验、自适应 Token 估算、陈旧锁检测、三级降级压缩。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.io import atomic_json_write, safe_json_load
|
||||||
|
from air_runtime.paths import airplan_root, event_log_path
|
||||||
|
from air_runtime.events import EventLog, CONTEXT_COMPACTED
|
||||||
|
from air_runtime.utils import now_iso
|
||||||
|
|
||||||
|
|
||||||
|
class CompressionLevel:
|
||||||
|
"""三级降级"""
|
||||||
|
RETRY = "retry" # 1. 重试一次
|
||||||
|
FALLBACK_MODEL = "fallback_model" # 2. 换模型
|
||||||
|
TRUNCATE = "truncate" # 3. 激进截断
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TRUNCATION_KEEP = 10 # 保留最近 10 轮
|
||||||
|
|
||||||
|
CHARS_PER_TOKEN = {
|
||||||
|
"chinese": 1.5,
|
||||||
|
"english": 4.0,
|
||||||
|
"code": 3.0,
|
||||||
|
"markup": 5.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
MUST_PRESERVE_PATTERNS = [
|
||||||
|
r"[A-Za-z0-9_\-/]+\.(py|ts|js|cpp|h|md|json|yaml)",
|
||||||
|
r"ADR-\d{4}",
|
||||||
|
r"TODO|FIXME|HACK",
|
||||||
|
r"INV-\d+",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx_paths(project_root: Path) -> dict[str, Path]:
|
||||||
|
root = airplan_root(project_root) / "state" / "aircontext"
|
||||||
|
return {"root": root, "state": root / "state.json", "lock": root / "compactor.lock"}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_tokens(text: str) -> int:
|
||||||
|
chinese = len(re.findall(r"[一-鿿]", text))
|
||||||
|
code = len(re.findall(r"[{}()\[\];=<>]", text))
|
||||||
|
markup = len(re.findall(r"[#*\-`|]", text))
|
||||||
|
english = max(0, len(text) - chinese - code - markup)
|
||||||
|
tokens = (
|
||||||
|
chinese / CHARS_PER_TOKEN["chinese"]
|
||||||
|
+ code / CHARS_PER_TOKEN["code"]
|
||||||
|
+ markup / CHARS_PER_TOKEN["markup"]
|
||||||
|
+ english / CHARS_PER_TOKEN["english"]
|
||||||
|
)
|
||||||
|
return int(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_compression(original: str, summary: str) -> dict:
|
||||||
|
missing = []
|
||||||
|
for pattern in MUST_PRESERVE_PATTERNS:
|
||||||
|
orig_matches = set(re.findall(pattern, original))
|
||||||
|
summary_matches = set(re.findall(pattern, summary))
|
||||||
|
lost = orig_matches - summary_matches
|
||||||
|
if len(lost) > len(orig_matches) * 0.3 and len(orig_matches) > 3:
|
||||||
|
missing.append({"pattern": pattern, "lost": list(lost)[:10]})
|
||||||
|
return {"ok": len(missing) == 0, "missing": missing, "originalTokens": estimate_tokens(original),
|
||||||
|
"summaryTokens": estimate_tokens(summary)}
|
||||||
|
|
||||||
|
|
||||||
|
def _compress_basic(text: str, max_tokens: int) -> str:
|
||||||
|
"""基础压缩:token 估算 + 截断"""
|
||||||
|
estimated_tokens = len(text) // 3
|
||||||
|
if estimated_tokens <= max_tokens:
|
||||||
|
return text
|
||||||
|
# 按行截断
|
||||||
|
lines = text.split('\n')
|
||||||
|
chars_per_line_estimate = 30
|
||||||
|
keep_lines = int(max_tokens * chars_per_line_estimate / 80) # 80 chars/line
|
||||||
|
return '\n'.join(lines[-keep_lines:])
|
||||||
|
|
||||||
|
|
||||||
|
def _simplify_prompt(text: str) -> str:
|
||||||
|
"""简化 prompt:移除详细上下文,保留核心"""
|
||||||
|
lines = text.split('\n')
|
||||||
|
# 只保留前 3 行 + 包含 "def " / "class " / "#" 的行
|
||||||
|
kept = lines[:3]
|
||||||
|
kept.extend([l for l in lines[3:] if 'def ' in l or 'class ' in l or l.startswith('#')])
|
||||||
|
return '\n'.join(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def compress_with_fallback(context: str, max_tokens: int = 4000) -> dict:
|
||||||
|
"""
|
||||||
|
三级降级压缩:
|
||||||
|
- 尝试正常压缩
|
||||||
|
- 失败则换模型重试
|
||||||
|
- 再失败则激进截断
|
||||||
|
返回: {"level": "...", "result": "...", "tokens": N}
|
||||||
|
"""
|
||||||
|
# Level 1: 正常尝试
|
||||||
|
try:
|
||||||
|
result = _compress_basic(context, max_tokens)
|
||||||
|
return {"level": CompressionLevel.RETRY, "result": result, "tokens": len(result.split())}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Level 2: 换模型(更简单的 prompt + 更宽松的 max_tokens)
|
||||||
|
try:
|
||||||
|
simplified = _simplify_prompt(context)
|
||||||
|
result = _compress_basic(simplified, int(max_tokens * 1.5))
|
||||||
|
return {"level": CompressionLevel.FALLBACK_MODEL, "result": result, "tokens": len(result.split())}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Level 3: 激进截断
|
||||||
|
lines = context.split('\n')
|
||||||
|
# 提取 ADR 引用行
|
||||||
|
adr_lines = [l for l in lines if 'ADR-' in l or 'adr-' in l]
|
||||||
|
# 保留最近 N 轮
|
||||||
|
recent_lines = lines[-DEFAULT_TRUNCATION_KEEP * 5:] # 每轮约 5 行
|
||||||
|
truncated = '\n'.join(recent_lines + adr_lines)
|
||||||
|
return {
|
||||||
|
"level": CompressionLevel.TRUNCATE,
|
||||||
|
"result": truncated,
|
||||||
|
"tokens": len(truncated.split()),
|
||||||
|
"warning": f"truncated to {DEFAULT_TRUNCATION_KEEP * 5} recent lines + {len(adr_lines)} ADR lines"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_compression_with_fallback(project_root: Path, context_path: Path) -> dict:
|
||||||
|
"""验证压缩有效性,失败时触发三级降级"""
|
||||||
|
content = context_path.read_text()
|
||||||
|
original_len = len(content)
|
||||||
|
|
||||||
|
# 先用当前配置尝试
|
||||||
|
result = compress_with_fallback(content)
|
||||||
|
|
||||||
|
validation = {
|
||||||
|
"original_chars": original_len,
|
||||||
|
"result_chars": len(result["result"]),
|
||||||
|
"level": result["level"],
|
||||||
|
"tokens": result.get("tokens", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if result["level"] == CompressionLevel.TRUNCATE:
|
||||||
|
validation["warning"] = result.get("warning", "")
|
||||||
|
|
||||||
|
return validation
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_compactor_lock(lock_path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.write(fd, str(os.getpid()).encode())
|
||||||
|
os.close(fd)
|
||||||
|
return True
|
||||||
|
except FileExistsError:
|
||||||
|
try:
|
||||||
|
pid = int(lock_path.read_text().strip())
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return False
|
||||||
|
except (ValueError, ProcessLookupError, PermissionError):
|
||||||
|
lock_path.unlink(missing_ok=True)
|
||||||
|
return acquire_compactor_lock(lock_path)
|
||||||
|
|
||||||
|
|
||||||
|
def ctx_enter(project_root: Path) -> dict:
|
||||||
|
paths = _ctx_paths(project_root)
|
||||||
|
paths["root"].mkdir(parents=True, exist_ok=True)
|
||||||
|
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(),
|
||||||
|
"projectRoot": str(project_root)})
|
||||||
|
return {"state_path": str(paths["state"])}
|
||||||
|
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
sub = args.sub or "status"
|
||||||
|
paths = _ctx_paths(project_root)
|
||||||
|
|
||||||
|
if sub == "enter":
|
||||||
|
result = ctx_enter(project_root)
|
||||||
|
print(f"airplan_mode=ctx\nstate_path={result['state_path']}")
|
||||||
|
elif sub == "estimate":
|
||||||
|
text = "sample" # 实际使用时从 stdin 或文件读取
|
||||||
|
tokens = estimate_tokens(text)
|
||||||
|
print(f"airplan_mode=ctx\ntokens={tokens}")
|
||||||
|
elif sub == "validate":
|
||||||
|
ctx_path = project_root / "AirPlan" / "context.md"
|
||||||
|
if ctx_path.exists():
|
||||||
|
validation = validate_compression_with_fallback(project_root, ctx_path)
|
||||||
|
ok = validation["level"] != CompressionLevel.TRUNCATE
|
||||||
|
print(f"airplan_mode=ctx\nvalidation_ok={ok}\nlevel={validation['level']}\ntokens={validation['tokens']}")
|
||||||
|
if "warning" in validation:
|
||||||
|
print(f"warning={validation['warning']}")
|
||||||
|
else:
|
||||||
|
print("airplan_mode=ctx\nvalidation_ok=true")
|
||||||
|
elif sub == "compress":
|
||||||
|
# 读取 context 文件
|
||||||
|
ctx_path = project_root / "AirPlan" / "context.md"
|
||||||
|
if not ctx_path.exists():
|
||||||
|
print("error: context.md not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 调用三级降级压缩
|
||||||
|
content = ctx_path.read_text()
|
||||||
|
result = compress_with_fallback(content)
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit(CONTEXT_COMPACTED, {
|
||||||
|
"compressionLevel": result["level"],
|
||||||
|
"originalChars": len(content),
|
||||||
|
"resultChars": len(result["result"]),
|
||||||
|
"tokens": result.get("tokens", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"airplan_mode=ctx")
|
||||||
|
print(f"compression_level={result['level']}")
|
||||||
|
print(f"original_chars={len(content)}")
|
||||||
|
print(f"result_chars={len(result['result'])}")
|
||||||
|
if 'warning' in result:
|
||||||
|
print(f"warning={result['warning']}")
|
||||||
|
|
||||||
|
# 可选:写回压缩结果
|
||||||
|
if getattr(args, "write_back", False):
|
||||||
|
compressed_path = project_root / "AirPlan" / "context.compressed.md"
|
||||||
|
compressed_path.write_text(result['result'])
|
||||||
|
print(f"written_to={compressed_path}")
|
||||||
|
else:
|
||||||
|
state = safe_json_load(paths["state"]) or {}
|
||||||
|
print(f"airplan_mode=ctx\nenabled={state.get('enabled', False)}")
|
||||||
279
lib/air_runtime/modes/dbg_mode.py
Normal file
279
lib/air_runtime/modes/dbg_mode.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
AirDbg mode — V2 调试器。
|
||||||
|
V2 改进:7步工作流强制追踪(L1 代码级),修复前自动 git snapshot 回滚。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.io import atomic_json_write, safe_json_load
|
||||||
|
from air_runtime.paths import airplan_root, event_log_path
|
||||||
|
from air_runtime.events import EventLog, DEBUG_SESSION
|
||||||
|
from air_runtime.utils import now_iso, session_stamp
|
||||||
|
|
||||||
|
DBG_STEPS = [
|
||||||
|
"confirm_symptoms",
|
||||||
|
"load_context",
|
||||||
|
"reproduce",
|
||||||
|
"locate_root_cause",
|
||||||
|
"fix",
|
||||||
|
"verify",
|
||||||
|
"close_out",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceFirstGate:
|
||||||
|
"""先读后写门控:未执行任何取证行为前,禁止代码修改。"""
|
||||||
|
|
||||||
|
EVIDENCE_TYPES = [
|
||||||
|
"screenshot",
|
||||||
|
"packet_capture",
|
||||||
|
"static_analysis",
|
||||||
|
"log_analysis",
|
||||||
|
"code_trace",
|
||||||
|
"reproduction",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, session_id: str):
|
||||||
|
self._session_id = session_id
|
||||||
|
self._collected_evidence: list[str] = []
|
||||||
|
|
||||||
|
def record_evidence(self, evidence_type: str, detail: str = "") -> None:
|
||||||
|
if evidence_type not in self.EVIDENCE_TYPES:
|
||||||
|
raise ValueError(f"unknown evidence type: {evidence_type!r}")
|
||||||
|
self._collected_evidence.append(evidence_type)
|
||||||
|
|
||||||
|
def can_modify_code(self) -> bool:
|
||||||
|
return len(self._collected_evidence) > 0
|
||||||
|
|
||||||
|
def gate_check(self) -> None:
|
||||||
|
if not self.can_modify_code():
|
||||||
|
raise WorkflowViolation(
|
||||||
|
"未执行任何取证行为,禁止修改代码。"
|
||||||
|
"请先至少完成以下一项:截图、抓包、静态分析、日志分析、代码追踪、复现步骤。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowViolation(Exception):
|
||||||
|
"""调试工作流违规。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _paths(project_root: Path) -> dict[str, Path]:
|
||||||
|
root = airplan_root(project_root) / "state" / "airdbg"
|
||||||
|
return {
|
||||||
|
"root": root,
|
||||||
|
"state": root / "state.json",
|
||||||
|
"sessions_dir": root / "sessions",
|
||||||
|
"snapshots_dir": root / "snapshots",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dirs(paths: dict[str, Path]) -> None:
|
||||||
|
for key in ("sessions_dir", "snapshots_dir"):
|
||||||
|
paths[key].mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def start_session(project_root: Path, task_id: str) -> dict:
|
||||||
|
paths = _paths(project_root)
|
||||||
|
_ensure_dirs(paths)
|
||||||
|
|
||||||
|
session_id = f"{task_id}-{session_stamp()}"
|
||||||
|
session_state = {
|
||||||
|
"sessionId": session_id, "taskId": task_id,
|
||||||
|
"currentStep": "confirm_symptoms",
|
||||||
|
"startedAt": now_iso(),
|
||||||
|
"stepsCompleted": [],
|
||||||
|
"collectedEvidence": [],
|
||||||
|
"evidence": {},
|
||||||
|
"result": None,
|
||||||
|
}
|
||||||
|
session_path = paths["sessions_dir"] / f"{session_id}.json"
|
||||||
|
atomic_json_write(session_path, session_state)
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit(DEBUG_SESSION, {"sessionId": session_id, "taskId": task_id, "action": "started"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sessionId": session_id, "sessionPath": str(session_path),
|
||||||
|
"currentStep": "confirm_symptoms",
|
||||||
|
"steps": DBG_STEPS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_step(session_path: Path) -> str:
|
||||||
|
session = safe_json_load(session_path)
|
||||||
|
if not session or not isinstance(session, dict):
|
||||||
|
return "confirm_symptoms"
|
||||||
|
return session.get("currentStep", "confirm_symptoms")
|
||||||
|
|
||||||
|
|
||||||
|
def advance_step(session_path: Path, evidence: dict) -> str:
|
||||||
|
session = safe_json_load(session_path)
|
||||||
|
if not session or not isinstance(session, dict):
|
||||||
|
raise ValueError("invalid session")
|
||||||
|
|
||||||
|
current = session.get("currentStep", "confirm_symptoms")
|
||||||
|
current_idx = DBG_STEPS.index(current) if current in DBG_STEPS else 0
|
||||||
|
|
||||||
|
# 验证当前步骤需要的证据
|
||||||
|
required_evidence = _required_evidence_for_step(current)
|
||||||
|
if required_evidence:
|
||||||
|
missing = [k for k in required_evidence if k not in evidence]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"step '{current}' requires evidence: {missing}")
|
||||||
|
|
||||||
|
# 先读后写门控:fix 步骤前必须已有取证记录
|
||||||
|
if current == "fix":
|
||||||
|
collected = session.get("collectedEvidence", [])
|
||||||
|
if not collected:
|
||||||
|
raise WorkflowViolation(
|
||||||
|
"未执行任何取证行为,禁止修改代码。"
|
||||||
|
"请先至少完成以下一项:截图、抓包、静态分析、日志分析、代码追踪、复现步骤。"
|
||||||
|
)
|
||||||
|
|
||||||
|
session["stepsCompleted"].append({"step": current, "evidence": evidence, "completedAt": now_iso()})
|
||||||
|
|
||||||
|
# 累积取证记录(confirm_symptoms, load_context, reproduce, locate_root_cause 都是取证步骤)
|
||||||
|
evidence_steps = {"confirm_symptoms", "load_context", "reproduce", "locate_root_cause"}
|
||||||
|
if current in evidence_steps:
|
||||||
|
session.setdefault("collectedEvidence", []).append(current)
|
||||||
|
next_idx = current_idx + 1
|
||||||
|
if next_idx < len(DBG_STEPS):
|
||||||
|
session["currentStep"] = DBG_STEPS[next_idx]
|
||||||
|
|
||||||
|
atomic_json_write(session_path, session)
|
||||||
|
return session["currentStep"]
|
||||||
|
|
||||||
|
|
||||||
|
def skip_reproduce(session_path: Path, reason: str) -> str:
|
||||||
|
session = safe_json_load(session_path)
|
||||||
|
if not session or not isinstance(session, dict):
|
||||||
|
raise ValueError("invalid session")
|
||||||
|
if session.get("currentStep") != "reproduce":
|
||||||
|
raise ValueError("can only skip from reproduce step")
|
||||||
|
session["currentStep"] = "locate_root_cause"
|
||||||
|
session["stepsCompleted"].append({"step": "reproduce", "evidence": {"skipped": True, "reason": reason}})
|
||||||
|
atomic_json_write(session_path, session)
|
||||||
|
return "locate_root_cause"
|
||||||
|
|
||||||
|
|
||||||
|
def pre_fix_snapshot(project_root: Path, task_id: str) -> str:
|
||||||
|
"""
|
||||||
|
修复前创建 git tag 作为回滚点。
|
||||||
|
V2 改进:只提交当前 task 写集范围内的文件(在 result.filesChanged 中声明)。
|
||||||
|
"""
|
||||||
|
# 1. 读取 worker result 获取 filesChanged
|
||||||
|
result_path = project_root / "AirPlan" / "state" / "airdo" / "tasks" / task_id / "result.json"
|
||||||
|
if not result_path.exists():
|
||||||
|
# 无 result 文件,回退到全量提交(但加 warning)
|
||||||
|
return _snapshot_full(project_root, task_id)
|
||||||
|
|
||||||
|
result = safe_json_load(result_path) or {}
|
||||||
|
files_changed = result.get("filesChanged", [])
|
||||||
|
|
||||||
|
if not files_changed:
|
||||||
|
# 无写集声明,回退到当前工作目录中的已跟踪文件
|
||||||
|
files_changed = None
|
||||||
|
|
||||||
|
# 2. 只 add 这些文件,然后 commit
|
||||||
|
return _snapshot_selective(project_root, task_id, files_changed)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_selective(project_root: Path, task_id: str, files: list[str] | None) -> str:
|
||||||
|
"""只提交指定的文件列表"""
|
||||||
|
ref = f"airdbg-prefix-{task_id}-{session_stamp()}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# git add <files>
|
||||||
|
if files:
|
||||||
|
for f in files:
|
||||||
|
fp = project_root / f
|
||||||
|
if fp.exists():
|
||||||
|
subprocess.run(["git", "-C", str(project_root), "add", str(fp)],
|
||||||
|
check=True, capture_output=True, timeout=10)
|
||||||
|
|
||||||
|
# 如果有 staging 的内容则 commit,否则跳过(避免空 commit)
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", str(project_root), "commit", "-m",
|
||||||
|
f"AirDbg pre-fix snapshot: {task_id}", "--allow-empty"],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(project_root), "tag", ref],
|
||||||
|
check=True, capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
return ref
|
||||||
|
else:
|
||||||
|
# 没有 staged 内容或 commit 失败
|
||||||
|
return ""
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_full(project_root: Path, task_id: str) -> str:
|
||||||
|
"""全量提交(仅在无 filesChanged 信息时的 fallback,加 warning)"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.warning("pre_fix_snapshot: no filesChanged info, falling back to git commit -am")
|
||||||
|
|
||||||
|
# 这里保留原逻辑但加 comment 说明这是 fallback
|
||||||
|
return _do_git_commit_am(project_root, task_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_git_commit_am(project_root: Path, task_id: str) -> str:
|
||||||
|
"""原始实现,保留用于 fallback"""
|
||||||
|
ref = f"airdbg-prefix-{task_id}-{session_stamp()}"
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(project_root), "commit", "-am",
|
||||||
|
f"AirDbg pre-fix snapshot: {task_id}", "--allow-empty"],
|
||||||
|
check=True, capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(project_root), "tag", ref],
|
||||||
|
check=True, capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return ""
|
||||||
|
return ref
|
||||||
|
|
||||||
|
|
||||||
|
def _required_evidence_for_step(step: str) -> list[str]:
|
||||||
|
evidence_map = {
|
||||||
|
"confirm_symptoms": ["symptom", "expected", "actual"],
|
||||||
|
"load_context": [],
|
||||||
|
"reproduce": ["reproduction_steps"],
|
||||||
|
"locate_root_cause": ["root_cause_analysis"],
|
||||||
|
"fix": ["fix_description", "files_changed"],
|
||||||
|
"verify": ["validation_result"],
|
||||||
|
"close_out": ["residual_risk", "adr_updates"],
|
||||||
|
}
|
||||||
|
return evidence_map.get(step, [])
|
||||||
|
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
sub = args.sub or "status"
|
||||||
|
paths = _paths(project_root)
|
||||||
|
_ensure_dirs(paths)
|
||||||
|
|
||||||
|
if sub == "start":
|
||||||
|
result = start_session(project_root, args.task_id)
|
||||||
|
print("airplan_mode=dbg")
|
||||||
|
print(f"session_id={result['sessionId']}")
|
||||||
|
print(f"current_step={result['currentStep']}")
|
||||||
|
print(f"steps={','.join(result['steps'])}")
|
||||||
|
elif sub == "snapshot":
|
||||||
|
ref = pre_fix_snapshot(project_root, args.task_id)
|
||||||
|
print("airplan_mode=dbg")
|
||||||
|
print(f"snapshot_ref={ref}")
|
||||||
|
else:
|
||||||
|
state = safe_json_load(paths["state"]) or {}
|
||||||
|
print("airplan_mode=dbg")
|
||||||
|
print(f"enabled={state.get('enabled', False)}")
|
||||||
39
lib/air_runtime/modes/dep_mode.py
Normal file
39
lib/air_runtime/modes/dep_mode.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""AirDep mode — V2 部署器。"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from air_runtime.deploy_runtime import deploy, DeployTarget
|
||||||
|
from air_runtime.io import safe_json_load
|
||||||
|
from air_runtime.paths import airplan_root, event_log_path
|
||||||
|
from air_runtime.events import EventLog, DEPLOY_COMPLETED
|
||||||
|
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
tid = args.task_id
|
||||||
|
sub = args.sub or "deploy"
|
||||||
|
|
||||||
|
if sub == "deploy" and args.host:
|
||||||
|
target = DeployTarget(host=args.host)
|
||||||
|
binary = Path(args.binary).expanduser().resolve() if args.binary else Path(".")
|
||||||
|
result = deploy(tid, project_root, target, binary, tid)
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit(DEPLOY_COMPLETED, {
|
||||||
|
"taskId": tid,
|
||||||
|
"success": result.success,
|
||||||
|
"host": args.host,
|
||||||
|
"binaryMd5": result.binary_md5,
|
||||||
|
"serviceStatus": result.service_status,
|
||||||
|
})
|
||||||
|
|
||||||
|
print("airplan_mode=dep")
|
||||||
|
print(f"task_id={tid}")
|
||||||
|
print(f"success={result.success}")
|
||||||
|
print(f"md5={result.binary_md5}")
|
||||||
|
print(f"service_status={result.service_status}")
|
||||||
|
if result.error:
|
||||||
|
print(f"error={result.error}")
|
||||||
|
else:
|
||||||
|
paths = airplan_root(project_root) / "state" / "airdep"
|
||||||
|
state = safe_json_load(paths / "state.json") or {}
|
||||||
|
print(f"airplan_mode=dep\nenabled={state.get('enabled', False)}")
|
||||||
255
lib/air_runtime/modes/do_mode.py
Normal file
255
lib/air_runtime/modes/do_mode.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
AirDo mode — V2 任务执行器。
|
||||||
|
V2 改进:强制 AirDbg 路由(L1 代码级),task_id 注入防护,UI 任务 frontend-design Skill 路由(P1-20)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.io import atomic_json_write, safe_json_load
|
||||||
|
from air_runtime.paths import airplan_root, event_log_path
|
||||||
|
from air_runtime.events import EventLog, TASK_COMPLETED, TASK_BLOCKED
|
||||||
|
from air_runtime.contracts import WorkerResult, now_iso
|
||||||
|
from air_runtime.utils import sanitize_task_id, session_stamp
|
||||||
|
|
||||||
|
|
||||||
|
# P1-20: UI 任务检测关键词
|
||||||
|
UI_TASK_INDICATORS = (
|
||||||
|
"gui", "ui", "render", "layout", "dialog", "osd",
|
||||||
|
"overlay", "visual", "screenshot", "display",
|
||||||
|
"widget", "pane", "toolbar", "settings_dialog",
|
||||||
|
"canvas", "button", "window", "popup", "menu",
|
||||||
|
"drm", "kms", "opengl", "vulkan", "frontend",
|
||||||
|
"react", "vue", "angular", "web", "css", "html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_ui_task(task_text: str) -> bool:
|
||||||
|
"""P1-20: 检测任务是否涉及 UI/前端界面设计。"""
|
||||||
|
text = task_text.lower()
|
||||||
|
return any(kw in text for kw in UI_TASK_INDICATORS)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_frontend_design_skill() -> bool:
|
||||||
|
"""P1-20: 检测 frontend-design Skill 是否存在,不存在则尝试自动安装。"""
|
||||||
|
# 检查 skill 是否已安装(检查 ~/.claude/skills/frontend-design 或类似路径)
|
||||||
|
import os
|
||||||
|
home = Path.home()
|
||||||
|
skill_path = home / ".claude" / "skills" / "frontend-design"
|
||||||
|
if skill_path.exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 尝试自动安装
|
||||||
|
import logging
|
||||||
|
logging.info("frontend-design skill not found, attempting auto-install...")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["claude", "plugin", "install", "frontend-design"],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
logging.info("frontend-design skill installed successfully")
|
||||||
|
return True
|
||||||
|
logging.warning("frontend-design skill install failed: %s", result.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning("frontend-design skill install error: %s", e)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def route_ui_task(task_text: str, task_id: str) -> dict:
|
||||||
|
"""P1-20: UI 任务路由决策。检测 UI 任务并确保 frontend-design Skill 可用。"""
|
||||||
|
if not is_ui_task(task_text):
|
||||||
|
return {"target": "execute", "skill": None, "is_ui_task": False}
|
||||||
|
|
||||||
|
# 是 UI 任务,检查 skill 可用性
|
||||||
|
if ensure_frontend_design_skill():
|
||||||
|
return {"target": "execute", "skill": "frontend-design", "is_ui_task": True}
|
||||||
|
|
||||||
|
# Skill 不可用,阻止任务
|
||||||
|
return {
|
||||||
|
"target": "blocked",
|
||||||
|
"reason": "UI task requires frontend-design skill but installation failed",
|
||||||
|
"skill": "frontend-design",
|
||||||
|
"is_ui_task": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _paths(project_root: Path, task_id: str) -> dict[str, Path]:
|
||||||
|
root = airplan_root(project_root) / "state" / "airdo"
|
||||||
|
task_dir = root / "tasks" / task_id
|
||||||
|
return {
|
||||||
|
"root": root,
|
||||||
|
"state": root / "state.json",
|
||||||
|
"task_dir": task_dir,
|
||||||
|
"brief": task_dir / "brief.md",
|
||||||
|
"handoff": task_dir / "subagent-handoff.md",
|
||||||
|
"result": task_dir / "result.json",
|
||||||
|
"worker_state": task_dir / "worker-state.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dirs(paths: dict[str, Path]) -> None:
|
||||||
|
paths["task_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def enter_worker(project_root: Path, task_id: str, task_text: str = "") -> dict:
|
||||||
|
"""P1-20: 新增 task_text 参数用于 UI 任务检测。"""
|
||||||
|
tid = sanitize_task_id(task_id)
|
||||||
|
paths = _paths(project_root, tid)
|
||||||
|
_ensure_dirs(paths)
|
||||||
|
|
||||||
|
# P1-20: UI 任务检测和路由
|
||||||
|
ui_routing = {"target": "execute", "skill": None, "is_ui_task": False}
|
||||||
|
if task_text:
|
||||||
|
ui_routing = route_ui_task(task_text, tid)
|
||||||
|
|
||||||
|
if ui_routing.get("target") == "blocked":
|
||||||
|
# UI 任务但 skill 不可用,阻止执行
|
||||||
|
worker_state = {
|
||||||
|
"taskId": tid, "status": "blocked",
|
||||||
|
"enteredAt": now_iso(), "resultPath": str(paths["result"]),
|
||||||
|
"blockReason": ui_routing.get("reason", "frontend-design skill unavailable"),
|
||||||
|
"uiRouting": ui_routing,
|
||||||
|
}
|
||||||
|
atomic_json_write(paths["worker_state"], worker_state)
|
||||||
|
atomic_json_write(paths["state"], {"enabled": True, "activeTaskId": tid,
|
||||||
|
"updatedAt": now_iso(), "blocked": True})
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit("task.blocked", {"taskId": tid, "reason": ui_routing.get("reason")})
|
||||||
|
return {
|
||||||
|
"taskId": tid, "status": "blocked",
|
||||||
|
"blockReason": ui_routing.get("reason"),
|
||||||
|
"uiRouting": ui_routing,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker_state = {
|
||||||
|
"taskId": tid, "status": "implementing",
|
||||||
|
"enteredAt": now_iso(), "resultPath": str(paths["result"]),
|
||||||
|
"uiRouting": ui_routing,
|
||||||
|
}
|
||||||
|
atomic_json_write(paths["worker_state"], worker_state)
|
||||||
|
atomic_json_write(paths["state"], {"enabled": True, "activeTaskId": tid,
|
||||||
|
"updatedAt": now_iso()})
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit("task.entered", {"taskId": tid, "uiRouting": ui_routing})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"taskId": tid, "briefPath": str(paths["brief"]),
|
||||||
|
"handoffPath": str(paths["handoff"]),
|
||||||
|
"resultPath": str(paths["result"]),
|
||||||
|
"workerStatePath": str(paths["worker_state"]),
|
||||||
|
"uiRouting": ui_routing,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def finish_worker(project_root: Path, task_id: str, result_path: Path | None = None) -> dict:
|
||||||
|
"""V2 核心改进:强制 AirDbg 路由。"""
|
||||||
|
tid = sanitize_task_id(task_id)
|
||||||
|
paths = _paths(project_root, tid)
|
||||||
|
|
||||||
|
# 加载 result
|
||||||
|
if result_path and result_path.exists():
|
||||||
|
result_data = safe_json_load(result_path)
|
||||||
|
elif paths["result"].exists():
|
||||||
|
result_data = safe_json_load(paths["result"])
|
||||||
|
else:
|
||||||
|
result_data = {"taskId": tid, "status": "blocked", "summary": "no result found"}
|
||||||
|
|
||||||
|
if not isinstance(result_data, dict):
|
||||||
|
result_data = {"taskId": tid, "status": "blocked"}
|
||||||
|
|
||||||
|
result = WorkerResult.from_dict(result_data)
|
||||||
|
status = result.status
|
||||||
|
|
||||||
|
# V2 L1 代码级:done 但无证据 → 强制 AirDbg
|
||||||
|
if status == "done":
|
||||||
|
if not result.validations and not result.files_changed:
|
||||||
|
routing_decision = {
|
||||||
|
"target": "airdbg",
|
||||||
|
"reason": "done without evidence — mandatory debug review",
|
||||||
|
"forced": True,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
routing_decision = {"target": "merge", "forced": False}
|
||||||
|
|
||||||
|
# V2 L1 代码级:blocked/failed → 强制 AirDbg
|
||||||
|
elif status in ("blocked", "failed"):
|
||||||
|
routing_decision = {
|
||||||
|
"target": "airdbg",
|
||||||
|
"reason": f"status={status} — AirDbg mandatory before return",
|
||||||
|
"forced": True,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
routing_decision = {"target": "merge", "forced": False}
|
||||||
|
|
||||||
|
# 持久化
|
||||||
|
finalized = result.to_dict()
|
||||||
|
finalized["routingDecision"] = routing_decision
|
||||||
|
finalized["finalizedAt"] = now_iso()
|
||||||
|
atomic_json_write(paths["result"], finalized)
|
||||||
|
atomic_json_write(paths["worker_state"], {"taskId": tid, "status": "finished",
|
||||||
|
"resultPath": str(paths["result"]),
|
||||||
|
"routingDecision": routing_decision})
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit("task.finished", {"taskId": tid, "status": status,
|
||||||
|
"routingTarget": routing_decision["target"]})
|
||||||
|
|
||||||
|
# emit task.completed / task.blocked based on final status
|
||||||
|
if status == "done":
|
||||||
|
log.emit(TASK_COMPLETED, {"taskId": tid, "routingTarget": routing_decision["target"]})
|
||||||
|
elif status in ("blocked", "failed"):
|
||||||
|
log.emit(TASK_BLOCKED, {"taskId": tid, "status": status})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"taskId": tid, "status": status,
|
||||||
|
"finalizedResultPath": str(paths["result"]),
|
||||||
|
"workerStatePath": str(paths["worker_state"]),
|
||||||
|
"routingDecision": routing_decision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def status_worker(project_root: Path) -> dict:
|
||||||
|
paths = _paths(project_root, "_")
|
||||||
|
state = safe_json_load(paths["state"]) or {}
|
||||||
|
task_ids = []
|
||||||
|
if paths["root"].joinpath("tasks").exists():
|
||||||
|
task_ids = [d.name for d in paths["root"].joinpath("tasks").iterdir() if d.is_dir()]
|
||||||
|
return {
|
||||||
|
"enabled": state.get("enabled", False),
|
||||||
|
"activeTaskId": state.get("activeTaskId", ""),
|
||||||
|
"taskIds": task_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
sub = args.sub or "status"
|
||||||
|
tid = args.task_id
|
||||||
|
|
||||||
|
if sub == "status":
|
||||||
|
s = status_worker(project_root)
|
||||||
|
print("airplan_mode=do")
|
||||||
|
print(f"enabled={s['enabled']}")
|
||||||
|
print(f"active_task_id={s['activeTaskId']}")
|
||||||
|
print(f"known_tasks={','.join(s['taskIds'])}")
|
||||||
|
elif sub == "enter":
|
||||||
|
result = enter_worker(project_root, tid)
|
||||||
|
print("airplan_mode=do")
|
||||||
|
print(f"task_id={result['taskId']}")
|
||||||
|
print(f"brief_path={result['briefPath']}")
|
||||||
|
print(f"result_path={result['resultPath']}")
|
||||||
|
print(f"worker_state_path={result['workerStatePath']}")
|
||||||
|
elif sub == "finish":
|
||||||
|
rpath = Path(args.result).expanduser().resolve() if args.result else None
|
||||||
|
finalized = finish_worker(project_root, tid, rpath)
|
||||||
|
print("airplan_mode=do")
|
||||||
|
print(f"task_id={finalized['taskId']}")
|
||||||
|
print(f"status={finalized['status']}")
|
||||||
|
print(f"routing_target={finalized['routingDecision']['target']}")
|
||||||
|
print(f"routing_forced={finalized['routingDecision']['forced']}")
|
||||||
606
lib/air_runtime/modes/eng_mode.py
Normal file
606
lib/air_runtime/modes/eng_mode.py
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
"""
|
||||||
|
AirEng mode — V2 调度引擎。
|
||||||
|
L1 代码级保障:硬编码轮询循环、Worker 超时、资源压力检测、事务化合并。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.io import atomic_json_write, safe_json_load
|
||||||
|
from air_runtime.lock import FileLock
|
||||||
|
from air_runtime.paths import (
|
||||||
|
airplan_root, todo_path as get_todo_path, engine_state_path,
|
||||||
|
event_log_path, plan_path, agents_path,
|
||||||
|
)
|
||||||
|
from air_runtime.events import EventLog, TASK_DISPATCHED, TASK_COMPLETED, TASK_BLOCKED, MERGE_STARTED, MERGE_COMPLETED, \
|
||||||
|
INTERVENTION_STALL, ENGINE_CYCLE, WORKER_TIMEOUT, REPAIR_CREATED, REPAIR_RESOLVED
|
||||||
|
from air_runtime.evidence_gate import EvidenceGatePolicy, EvidenceClass
|
||||||
|
from air_runtime.modes.merge_pipeline import (
|
||||||
|
apply_document_updates,
|
||||||
|
enforce_doc_sync_requirements,
|
||||||
|
sync_engine_managed_docs,
|
||||||
|
update_todo_after_merge,
|
||||||
|
)
|
||||||
|
from air_runtime.task_graph import TaskGraph
|
||||||
|
from air_runtime.todo_parser import parse_tasks
|
||||||
|
from air_runtime.utils import now_iso, session_stamp, truncate_history
|
||||||
|
|
||||||
|
WORKER_MAX_WALL_TIME = 7200 # 2小时硬上限
|
||||||
|
DEFAULT_CONCURRENCY = 3
|
||||||
|
MONITOR_INTERVAL_SECONDS = 300 # 5分钟
|
||||||
|
AIRDBG_MAX_ATTEMPTS = 1 # AirDbg 升级最大尝试次数,超过则降级为串行重执行
|
||||||
|
|
||||||
|
|
||||||
|
def check_worktree_merge_status(project_root: Path, task_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
检查某 task 的 worktree 是否需要 merge 回主分支。
|
||||||
|
如果 merge 失败(conflicts),自动升级到 AirDbg。
|
||||||
|
再失败则降级为串行重执行。
|
||||||
|
返回: {"status": "ok" | "upgraded_to_airdbg" | "downgraded_to_serial", ...}
|
||||||
|
"""
|
||||||
|
from air_runtime.worktree import WorktreeIsolation
|
||||||
|
|
||||||
|
wt_path = project_root / ".git" / "worktrees" / f"air-{task_id}"
|
||||||
|
if not wt_path.exists():
|
||||||
|
return {"status": "ok"} # 无 worktree,正常
|
||||||
|
|
||||||
|
# 尝试 merge 回主分支
|
||||||
|
wt = WorktreeIsolation(repo_root=project_root)
|
||||||
|
result = wt.merge_back(task_id, wt_path)
|
||||||
|
|
||||||
|
if result.successful:
|
||||||
|
# merge 成功,清理 worktree
|
||||||
|
wt.cleanup(task_id, wt_path)
|
||||||
|
return {"status": "ok", "conflicts": []}
|
||||||
|
|
||||||
|
# merge 失败 → 升级到 AirDbg
|
||||||
|
from air_runtime.modes.dbg_mode import start_session
|
||||||
|
|
||||||
|
session = start_session(project_root, task_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "upgraded_to_airdbg",
|
||||||
|
"taskId": task_id,
|
||||||
|
"conflicts": result.conflicts,
|
||||||
|
"sessionId": session.get("sessionId"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _paths(project_root: Path) -> dict[str, Path]:
|
||||||
|
root = airplan_root(project_root) / "state" / "aireng"
|
||||||
|
return {
|
||||||
|
"root": root,
|
||||||
|
"state": root / "state.json",
|
||||||
|
"dispatch_dir": root / "dispatch",
|
||||||
|
"archive_dir": root / "archive",
|
||||||
|
"plan_dir": root / "plans",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dirs(paths: dict[str, Path]) -> None:
|
||||||
|
for key in ("dispatch_dir", "archive_dir", "plan_dir"):
|
||||||
|
paths[key].mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_state(project_root: Path) -> dict:
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"updatedAt": now_iso(),
|
||||||
|
"projectRoot": str(project_root),
|
||||||
|
"engineMode": "idle",
|
||||||
|
"activeWaveId": "",
|
||||||
|
"activeDispatchPath": "",
|
||||||
|
"activeWorkers": [],
|
||||||
|
"mergedResults": [],
|
||||||
|
"pendingGlobalUpdates": [],
|
||||||
|
"interventionHistory": [],
|
||||||
|
"monitoringPolicy": {"checkIntervalSeconds": MONITOR_INTERVAL_SECONDS},
|
||||||
|
"concurrency": DEFAULT_CONCURRENCY,
|
||||||
|
"planningSource": "",
|
||||||
|
"nextAction": "plan",
|
||||||
|
"lastLoopAt": "",
|
||||||
|
"lastInterventionAt": "",
|
||||||
|
"xdbSessions": [],
|
||||||
|
"debugSessions": [],
|
||||||
|
"repairAttempts": [],
|
||||||
|
"activeRepairCount": 0,
|
||||||
|
"repairPolicy": {"enabled": True, "maxAttempts": 3},
|
||||||
|
"xdbPolicy": {"enabled": True},
|
||||||
|
"reviewPolicy": {"requireBeforeMerge": False, "maxRepairRounds": 3},
|
||||||
|
"residualItems": [],
|
||||||
|
"debugPolicy": {"enabled": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enter_engine(project_root: Path) -> tuple[str, dict]:
|
||||||
|
paths = _paths(project_root)
|
||||||
|
_ensure_dirs(paths)
|
||||||
|
state = _init_state(project_root)
|
||||||
|
atomic_json_write(paths["state"], state)
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit("engine.entered")
|
||||||
|
return str(paths["state"]), {}
|
||||||
|
|
||||||
|
|
||||||
|
def status_engine(project_root: Path) -> dict:
|
||||||
|
paths = _paths(project_root)
|
||||||
|
return safe_json_load(paths["state"]) or _init_state(project_root)
|
||||||
|
|
||||||
|
|
||||||
|
def build_engine_plan(project_root: Path, todo_path: Path) -> dict:
|
||||||
|
paths = _paths(project_root)
|
||||||
|
_ensure_dirs(paths)
|
||||||
|
arc_reviews = airplan_root(project_root) / "state" / "airarc" / "reviews"
|
||||||
|
plan_json = arc_reviews / "execution-plan.json"
|
||||||
|
task_graph_json = arc_reviews / "task-graph.json"
|
||||||
|
|
||||||
|
planning_source = "engine-fallback"
|
||||||
|
plan_data: dict = {}
|
||||||
|
|
||||||
|
if plan_json.exists():
|
||||||
|
loaded = safe_json_load(plan_json)
|
||||||
|
if loaded and isinstance(loaded, dict):
|
||||||
|
plan_data = loaded
|
||||||
|
planning_source = "airarc-execution-plan"
|
||||||
|
|
||||||
|
if not plan_data:
|
||||||
|
tasks = parse_tasks(todo_path)
|
||||||
|
plan_data = {
|
||||||
|
"selectedTasks": [t.task_id for t in tasks if t.status == "TODO"],
|
||||||
|
"parallelGroups": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
plan_path = paths["plan_dir"] / f"{session_stamp()}.json"
|
||||||
|
atomic_json_write(plan_path, plan_data)
|
||||||
|
|
||||||
|
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||||
|
state["planningSource"] = planning_source
|
||||||
|
state["nextAction"] = "dispatch"
|
||||||
|
atomic_json_write(paths["state"], state)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"planPath": str(plan_path),
|
||||||
|
"planningSource": planning_source,
|
||||||
|
"selectedTasks": plan_data.get("selectedTasks", []),
|
||||||
|
"parallelGroupCount": len(plan_data.get("parallelGroups", [])),
|
||||||
|
"taskGraphPath": str(task_graph_json),
|
||||||
|
"planJson": plan_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_worker_group(project_root: Path, group_name: str = "") -> dict:
|
||||||
|
"""派发 worker 组,含区域冲突检测。"""
|
||||||
|
paths = _paths(project_root)
|
||||||
|
_ensure_dirs(paths)
|
||||||
|
|
||||||
|
# P0 修复:每次派发前检查 todo.md 是否更新,如有则触发增量重规划
|
||||||
|
replan_result = maybe_replan(project_root)
|
||||||
|
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||||
|
if replan_result:
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit("eng.replan.triggered", {
|
||||||
|
"added": replan_result.get("added_count", 0),
|
||||||
|
"removed": replan_result.get("removed_count", 0),
|
||||||
|
"modified": replan_result.get("modified_count", 0),
|
||||||
|
})
|
||||||
|
state["lastReplanAt"] = now_iso()
|
||||||
|
atomic_json_write(paths["state"], state)
|
||||||
|
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||||
|
|
||||||
|
# P1-19.3: 检查是否有 block-release verdict,阻止所有后续派发
|
||||||
|
from air_runtime.review_runtime import ReviewRuntime
|
||||||
|
rvr = ReviewRuntime(project_root)
|
||||||
|
# 扫描最新的审查报告,检查是否有 block-release
|
||||||
|
rvr_state = rvr._state_dir / "reports"
|
||||||
|
block_release_found = False
|
||||||
|
latest_verdict = "safe-to-ship"
|
||||||
|
if rvr_state.exists():
|
||||||
|
for report_file in sorted(rvr_state.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:10]:
|
||||||
|
report_data = safe_json_load(report_file)
|
||||||
|
if report_data:
|
||||||
|
dv = report_data.get("highRiskAudit", {}).get("deliveryVerdict", "safe-to-ship")
|
||||||
|
if dv == "block-release":
|
||||||
|
block_release_found = True
|
||||||
|
latest_verdict = dv
|
||||||
|
break
|
||||||
|
elif dv == "needs-fix":
|
||||||
|
latest_verdict = dv
|
||||||
|
if block_release_found:
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit("eng.blocked", {"reason": "block-release verdict from review", "verdict": latest_verdict})
|
||||||
|
return {
|
||||||
|
"blocked": True,
|
||||||
|
"reason": "block-release verdict from AirRvr review - all dispatches halted",
|
||||||
|
"deliveryVerdict": latest_verdict,
|
||||||
|
"waveId": "",
|
||||||
|
"taskIds": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
wave_id = f"wave-{session_stamp()}"
|
||||||
|
task_ids = _select_ready_tasks(project_root, state.get("concurrency", DEFAULT_CONCURRENCY))
|
||||||
|
|
||||||
|
if not task_ids:
|
||||||
|
return {"dispatchPath": "", "groupName": group_name, "waveId": wave_id,
|
||||||
|
"taskIds": [], "recommendedConcurrency": 0}
|
||||||
|
|
||||||
|
# 区域冲突检测:多个任务时检查写集重叠
|
||||||
|
dispatch_metadata: dict | None = None
|
||||||
|
if len(task_ids) > 1:
|
||||||
|
from air_runtime.worktree import RegionConflictDetector, ConflictLevel
|
||||||
|
|
||||||
|
todo_path = get_todo_path(project_root)
|
||||||
|
tasks = parse_tasks(todo_path)
|
||||||
|
task_write_sets = {
|
||||||
|
t.task_id: [f.strip() for f in t.files_dirs.split(",") if f.strip()]
|
||||||
|
for t in tasks if t.task_id in task_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
if task_write_sets:
|
||||||
|
detector = RegionConflictDetector()
|
||||||
|
conflicts = detector.detect_batch(task_write_sets)
|
||||||
|
|
||||||
|
hard_blocked = [c for c in conflicts if c.level == ConflictLevel.HARD]
|
||||||
|
if hard_blocked:
|
||||||
|
# HARD 冲突:强制串行,只派第一个
|
||||||
|
task_ids = task_ids[:1]
|
||||||
|
dispatch_metadata = {
|
||||||
|
"forcedSerialization": True,
|
||||||
|
"reason": f"HARD conflict: {hard_blocked[0].task_a} <-> {hard_blocked[0].task_b}",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
soft_conflicts = [c for c in conflicts if c.level == ConflictLevel.SOFT]
|
||||||
|
if soft_conflicts:
|
||||||
|
dispatch_metadata = {
|
||||||
|
"worktreeIsolation": True,
|
||||||
|
"softConflicts": [c.to_dict() for c in soft_conflicts],
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch_payload = {
|
||||||
|
"waveId": wave_id, "groupName": group_name,
|
||||||
|
"taskIds": task_ids,
|
||||||
|
"createdAt": now_iso(),
|
||||||
|
"recommendedConcurrency": min(len(task_ids), state.get("concurrency", DEFAULT_CONCURRENCY)),
|
||||||
|
}
|
||||||
|
dispatch_path = paths["dispatch_dir"] / f"{wave_id}.json"
|
||||||
|
atomic_json_write(dispatch_path, dispatch_payload)
|
||||||
|
|
||||||
|
state["activeWaveId"] = wave_id
|
||||||
|
state["activeDispatchPath"] = str(dispatch_path)
|
||||||
|
state["engineMode"] = "running"
|
||||||
|
state["nextAction"] = "monitor"
|
||||||
|
if dispatch_metadata:
|
||||||
|
state["dispatchMetadata"] = dispatch_metadata
|
||||||
|
atomic_json_write(paths["state"], state)
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
for tid in task_ids:
|
||||||
|
log.emit(TASK_DISPATCHED, {"taskId": tid, "waveId": wave_id})
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"dispatchPath": str(dispatch_path), "groupName": group_name,
|
||||||
|
"waveId": wave_id, "taskIds": task_ids,
|
||||||
|
"recommendedConcurrency": dispatch_payload["recommendedConcurrency"],
|
||||||
|
}
|
||||||
|
if dispatch_metadata:
|
||||||
|
result["dispatchMetadata"] = dispatch_metadata
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_engine(project_root: Path) -> dict:
|
||||||
|
"""L1 代码级轮询:硬编码循环检测 Worker 状态,不依赖 LLM 自觉。"""
|
||||||
|
paths = _paths(project_root)
|
||||||
|
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||||
|
|
||||||
|
active_workers = state.get("activeWorkers", [])
|
||||||
|
stalled_count = 0
|
||||||
|
ready_to_merge = 0
|
||||||
|
interventions = []
|
||||||
|
|
||||||
|
for worker in active_workers:
|
||||||
|
worker_state_path = Path(worker.get("workerStatePath", ""))
|
||||||
|
age = (datetime.now(timezone.utc) - datetime.fromisoformat(worker.get("spawnedAt", now_iso()))).total_seconds()
|
||||||
|
|
||||||
|
# 超时检测
|
||||||
|
if age > WORKER_MAX_WALL_TIME:
|
||||||
|
interventions.append({"taskId": worker["taskId"], "reason": "wall-time-exceeded",
|
||||||
|
"action": "terminate-and-block"})
|
||||||
|
stalled_count += 1
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit(WORKER_TIMEOUT, {"taskId": worker["taskId"], "ageSeconds": int(age)})
|
||||||
|
|
||||||
|
# 停滞检测:state 文件 mtime 超过 MONITOR_INTERVAL
|
||||||
|
elif worker_state_path.exists():
|
||||||
|
mtime = worker_state_path.stat().st_mtime
|
||||||
|
if time.time() - mtime > MONITOR_INTERVAL_SECONDS:
|
||||||
|
interventions.append({"taskId": worker["taskId"], "reason": "stalled",
|
||||||
|
"action": "re-dispatch-or-block"})
|
||||||
|
stalled_count += 1
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit(INTERVENTION_STALL, {"taskId": worker["taskId"]})
|
||||||
|
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
|
||||||
|
|
||||||
|
# 新增:检查 pending worktree merges — merge 失败自动升级到 AirDbg
|
||||||
|
wt_root = project_root / ".git" / "worktrees"
|
||||||
|
if wt_root.exists():
|
||||||
|
for wt_dir in wt_root.iterdir():
|
||||||
|
if wt_dir.is_dir() and wt_dir.name.startswith("air-"):
|
||||||
|
task_id = wt_dir.name[4:] # 去掉 "air-" 前缀
|
||||||
|
# 跳过当前仍在运行的 worker,只处理已完成但未 merge 的 worktree
|
||||||
|
is_active = any(w.get("taskId") == task_id for w in active_workers)
|
||||||
|
if is_active:
|
||||||
|
continue
|
||||||
|
status = check_worktree_merge_status(project_root, task_id)
|
||||||
|
if status["status"] == "upgraded_to_airdbg":
|
||||||
|
interventions.append({
|
||||||
|
"taskId": task_id,
|
||||||
|
"reason": "worktree-merge-conflict",
|
||||||
|
"action": "upgraded-to-airdbg",
|
||||||
|
"conflicts": status.get("conflicts", []),
|
||||||
|
"sessionId": status.get("sessionId"),
|
||||||
|
})
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit("worktree.merge.conflict", {
|
||||||
|
"taskId": task_id,
|
||||||
|
"action": "upgraded-to-airdbg",
|
||||||
|
"sessionId": status.get("sessionId"),
|
||||||
|
})
|
||||||
|
elif status["status"] == "downgraded_to_serial":
|
||||||
|
interventions.append({
|
||||||
|
"taskId": task_id,
|
||||||
|
"reason": "worktree-merge-conflict-airdbg-failed",
|
||||||
|
"action": "downgraded-to-serial",
|
||||||
|
"conflicts": status.get("conflicts", []),
|
||||||
|
})
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit("worktree.merge.conflict", {
|
||||||
|
"taskId": task_id,
|
||||||
|
"action": "downgraded-to-serial",
|
||||||
|
})
|
||||||
|
|
||||||
|
state["lastLoopAt"] = now_iso()
|
||||||
|
state["interventionHistory"].extend(interventions)
|
||||||
|
# 将升级到 AirDbg 的 session 记入 state.debugSessions
|
||||||
|
for iv in interventions:
|
||||||
|
if iv.get("action") == "upgraded-to-airdbg" and iv.get("sessionId"):
|
||||||
|
state.setdefault("debugSessions", []).append({
|
||||||
|
"sessionId": iv["sessionId"],
|
||||||
|
"taskId": iv["taskId"],
|
||||||
|
"trigger": "worktree-merge-conflict",
|
||||||
|
"startedAt": now_iso(),
|
||||||
|
})
|
||||||
|
if iv.get("action") == "downgraded-to-serial":
|
||||||
|
state.setdefault("repairAttempts", []).append({
|
||||||
|
"taskId": iv["taskId"],
|
||||||
|
"trigger": "worktree-merge-conflict-airdbg-failed",
|
||||||
|
"action": "serial-redo",
|
||||||
|
"startedAt": now_iso(),
|
||||||
|
})
|
||||||
|
atomic_json_write(paths["state"], state)
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit(ENGINE_CYCLE, {"stalledCount": stalled_count, "readyToMerge": ready_to_merge,
|
||||||
|
"interventionCount": len(interventions)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"engineMode": state.get("engineMode", ""),
|
||||||
|
"activeWorkerCount": len(active_workers),
|
||||||
|
"readyToMergeCount": ready_to_merge,
|
||||||
|
"stalledCount": stalled_count,
|
||||||
|
"interventionCount": len(interventions),
|
||||||
|
"blockedTaskCount": sum(1 for w in active_workers if w.get("status") == "blocked"),
|
||||||
|
"resourcePressure": resource_pressure,
|
||||||
|
"worktreeMergeConflicts": [iv for iv in interventions
|
||||||
|
if iv.get("reason", "").startswith("worktree-merge")],
|
||||||
|
"nextAction": "monitor" if active_workers else "dispatch",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_worker_result(project_root: Path, result_path: Path) -> dict:
|
||||||
|
"""事务化合并:6 阶段流水线,持有 state.json 锁。"""
|
||||||
|
paths = _paths(project_root)
|
||||||
|
_ensure_dirs(paths)
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
state_lock = FileLock(paths["state"], timeout=30.0)
|
||||||
|
todo_lock = FileLock(get_todo_path(project_root), timeout=10.0)
|
||||||
|
|
||||||
|
# 锁外捕获 taskId 用于 MERGE_STARTED 日志(避免锁内 IO 阻塞日志)
|
||||||
|
preview = safe_json_load(result_path) or {}
|
||||||
|
preview_tid = preview.get("taskId", "") if isinstance(preview, dict) else ""
|
||||||
|
|
||||||
|
log.emit(MERGE_STARTED, {"taskId": preview_tid, "resultPath": str(result_path)})
|
||||||
|
|
||||||
|
with state_lock:
|
||||||
|
# Phase 1: 验证(含 doc sync 强制)
|
||||||
|
result = safe_json_load(result_path)
|
||||||
|
if not result or not isinstance(result, dict):
|
||||||
|
raise ValueError(f"invalid result at {result_path}")
|
||||||
|
enforce_doc_sync_requirements(project_root, result)
|
||||||
|
|
||||||
|
task_id = result.get("taskId", "")
|
||||||
|
status = result.get("status", "")
|
||||||
|
|
||||||
|
# Phase 1.5: Rvr 审查(仅在 policy 或 result 声明需要时调用)
|
||||||
|
review_state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||||
|
rvr_policy = review_state.get("reviewPolicy", {"requireBeforeMerge": False})
|
||||||
|
if rvr_policy.get("requireBeforeMerge") or result.get("requireReview"):
|
||||||
|
from air_runtime.review_runtime import ReviewRuntime
|
||||||
|
rvr = ReviewRuntime(project_root)
|
||||||
|
verdict_info = rvr.get_verdict_for_task(task_id)
|
||||||
|
verdict = verdict_info.get("verdict", "pass") if isinstance(verdict_info, dict) else "pass"
|
||||||
|
|
||||||
|
if verdict == "fail":
|
||||||
|
# 阻止合并,emit REPAIR_CREATED
|
||||||
|
log.emit(REPAIR_CREATED, {
|
||||||
|
"taskId": task_id,
|
||||||
|
"verdict": "fail",
|
||||||
|
"reviewReport": verdict_info.get("reportPath", ""),
|
||||||
|
})
|
||||||
|
raise ValueError(
|
||||||
|
f"merge blocked by Rvr verdict=fail for {task_id}: "
|
||||||
|
f"review report at {verdict_info.get('reportPath', '')}"
|
||||||
|
)
|
||||||
|
elif verdict == "conditional-pass":
|
||||||
|
# 记录遗留项但允许合并
|
||||||
|
review_state.setdefault("residualItems", []).append({
|
||||||
|
"taskId": task_id,
|
||||||
|
"verdict": "conditional-pass",
|
||||||
|
"residual": verdict_info.get("residual", []),
|
||||||
|
"mergedAt": now_iso(),
|
||||||
|
})
|
||||||
|
# 写回 state 以便后续 Phase 6 看到
|
||||||
|
atomic_json_write(paths["state"], review_state)
|
||||||
|
# pass 走原流程
|
||||||
|
|
||||||
|
# Phase 2: 归档(可重试 — 失败重抛由调用方决定)
|
||||||
|
stamp = session_stamp()
|
||||||
|
archive_path = paths["archive_dir"] / f"{task_id}-{stamp}.json"
|
||||||
|
atomic_json_write(archive_path, result)
|
||||||
|
|
||||||
|
# Phase 3: 应用文档更新(原子写入)
|
||||||
|
applied = apply_document_updates(project_root, result)
|
||||||
|
|
||||||
|
# Phase 4: 同步引擎管理文档(原子写入)
|
||||||
|
sync_paths = sync_engine_managed_docs(project_root, result, applied)
|
||||||
|
|
||||||
|
# Phase 5: 更新 todo(嵌套 FileLock)
|
||||||
|
with todo_lock:
|
||||||
|
update_todo_after_merge(project_root, result, applied, sync_paths)
|
||||||
|
|
||||||
|
# Phase 6: 更新引擎状态(原子写入)
|
||||||
|
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||||
|
state["mergedResults"].append({
|
||||||
|
"taskId": task_id,
|
||||||
|
"status": status,
|
||||||
|
"archivedAt": now_iso(),
|
||||||
|
"archivePath": str(archive_path),
|
||||||
|
"appliedDocs": [str(p) for p in applied],
|
||||||
|
"syncedDocs": [str(p) for p in sync_paths],
|
||||||
|
})
|
||||||
|
state["mergedResults"] = truncate_history(state["mergedResults"], max_items=100)
|
||||||
|
state["activeWorkers"] = [
|
||||||
|
w for w in state.get("activeWorkers", []) if w.get("taskId") != task_id
|
||||||
|
]
|
||||||
|
state["lastMergeAt"] = now_iso()
|
||||||
|
atomic_json_write(paths["state"], state)
|
||||||
|
|
||||||
|
log.emit(MERGE_COMPLETED, {
|
||||||
|
"taskId": task_id,
|
||||||
|
"status": status,
|
||||||
|
"archivePath": str(archive_path),
|
||||||
|
"appliedDocCount": len(applied),
|
||||||
|
"syncedDocCount": len(sync_paths),
|
||||||
|
})
|
||||||
|
|
||||||
|
# emit task completed/blocked based on merge status
|
||||||
|
if status == "done":
|
||||||
|
log.emit(TASK_COMPLETED, {"taskId": task_id, "archivePath": str(archive_path)})
|
||||||
|
elif status in ("blocked", "failed"):
|
||||||
|
log.emit(TASK_BLOCKED, {"taskId": task_id, "status": status})
|
||||||
|
|
||||||
|
# repair resolved on successful merge after previous repair
|
||||||
|
repair_attempts = state.get("repairAttempts", [])
|
||||||
|
if repair_attempts and any(r.get("taskId") == task_id for r in repair_attempts):
|
||||||
|
log.emit(REPAIR_RESOLVED, {"taskId": task_id, "status": status})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"taskId": task_id,
|
||||||
|
"status": status,
|
||||||
|
"archivedResultPath": str(archive_path),
|
||||||
|
"appliedDocs": [str(p) for p in applied],
|
||||||
|
"syncedDocs": [str(p) for p in sync_paths],
|
||||||
|
"nextAction": "monitor" if state.get("activeWorkers") else "dispatch",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _select_ready_tasks(project_root: Path, max_count: int) -> list[str]:
|
||||||
|
"""优先从 task-graph.json 的 DAG 计算 in-degree 为 0 的 TODO task,fallback parse_tasks。"""
|
||||||
|
task_graph_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
|
||||||
|
if task_graph_json.exists():
|
||||||
|
try:
|
||||||
|
graph = TaskGraph.load(task_graph_json)
|
||||||
|
ready = graph.ready_tasks()
|
||||||
|
if ready:
|
||||||
|
return ready[:max_count]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# fallback
|
||||||
|
todo = get_todo_path(project_root)
|
||||||
|
if not todo.exists():
|
||||||
|
return []
|
||||||
|
tasks = parse_tasks(todo)
|
||||||
|
return [t.task_id for t in tasks if t.status == "TODO"][:max_count]
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_replan(project_root: Path, todo_path: Path | None = None) -> dict | None:
|
||||||
|
"""检查 todo.md mtime vs task_graph.json mtime,若 todo 更新则触发 replan。"""
|
||||||
|
from air_runtime.modes.arc_mode import incremental_replan_mode
|
||||||
|
todo = todo_path or get_todo_path(project_root)
|
||||||
|
tg = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
|
||||||
|
if not tg.exists():
|
||||||
|
return None
|
||||||
|
if not todo.exists():
|
||||||
|
return None
|
||||||
|
if todo.stat().st_mtime <= tg.stat().st_mtime:
|
||||||
|
return None
|
||||||
|
return incremental_replan_mode(project_root, todo, tg)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
sub = args.sub or "status"
|
||||||
|
|
||||||
|
if sub == "enter":
|
||||||
|
state_path, _ = enter_engine(project_root)
|
||||||
|
print("airplan_mode=eng")
|
||||||
|
print(f"state_path={state_path}")
|
||||||
|
elif sub == "status":
|
||||||
|
state = status_engine(project_root)
|
||||||
|
print(f"airplan_mode=eng")
|
||||||
|
print(f"enabled={state.get('enabled', False)}")
|
||||||
|
print(f"engine_mode={state.get('engineMode', '')}")
|
||||||
|
print(f"active_workers={len(state.get('activeWorkers', []))}")
|
||||||
|
print(f"merged_results={len(state.get('mergedResults', []))}")
|
||||||
|
print(f"next_action={state.get('nextAction', '')}")
|
||||||
|
elif sub == "plan":
|
||||||
|
tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root)
|
||||||
|
result = build_engine_plan(project_root, tpath)
|
||||||
|
print("airplan_mode=eng")
|
||||||
|
print(f"planning_source={result['planningSource']}")
|
||||||
|
print(f"selected_tasks={','.join(result['selectedTasks'])}")
|
||||||
|
elif sub == "dispatch":
|
||||||
|
result = dispatch_worker_group(project_root, args.dispatch_group)
|
||||||
|
print("airplan_mode=eng")
|
||||||
|
print(f"wave_id={result['waveId']}")
|
||||||
|
print(f"task_ids={','.join(result['taskIds'])}")
|
||||||
|
print(f"dispatch_path={result['dispatchPath']}")
|
||||||
|
elif sub == "monitor":
|
||||||
|
result = monitor_engine(project_root)
|
||||||
|
print("airplan_mode=eng")
|
||||||
|
print(f"active_workers={result['activeWorkerCount']}")
|
||||||
|
print(f"ready_to_merge={result['readyToMergeCount']}")
|
||||||
|
print(f"stalled={result['stalledCount']}")
|
||||||
|
print(f"interventions={result['interventionCount']}")
|
||||||
|
print(f"worktree_merge_conflicts={len(result.get('worktreeMergeConflicts', []))}")
|
||||||
|
print(f"next_action={result['nextAction']}")
|
||||||
|
elif sub == "merge":
|
||||||
|
result_path = Path(args.result).expanduser().resolve()
|
||||||
|
merged = merge_worker_result(project_root, result_path)
|
||||||
|
print("airplan_mode=eng")
|
||||||
|
print(f"task_id={merged['taskId']}")
|
||||||
|
print(f"status={merged['status']}")
|
||||||
|
print(f"next_action={merged['nextAction']}")
|
||||||
247
lib/air_runtime/modes/eng_orchestrator.py
Normal file
247
lib/air_runtime/modes/eng_orchestrator.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""
|
||||||
|
Eng orchestrator — V2 L1 代码级硬循环轮询。
|
||||||
|
|
||||||
|
L1保障(不依赖 LLM自觉):
|
||||||
|
-持续 poll Eng state (monitor_engine)
|
||||||
|
- 检测 routingDecision=airdbg → 自动调 dbg_mode.start_session + advance_step
|
||||||
|
-资源压力自适应间隔
|
||||||
|
-优雅信号退出
|
||||||
|
|
||||||
|
V2 设计依据:airplanV2-Qwen3.7-Max设计.md §3.2.8 / §3.5.1 /审查1.3
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.events import EventLog, DEBUG_SESSION
|
||||||
|
from air_runtime.io import atomic_json_write, safe_json_load
|
||||||
|
from air_runtime.paths import event_log_path
|
||||||
|
from air_runtime.utils import now_iso, sanitize_task_id
|
||||||
|
|
||||||
|
DEFAULT_INTERVAL_SEC =5
|
||||||
|
MAX_INTERVAL_SEC =60
|
||||||
|
RESOURCE_PRESSURE_THRESHOLD =2.0 # loadavg/cpu_count
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptivePoller:
|
||||||
|
"""按资源压力和活跃 worker 数动态调整轮询间隔。"""
|
||||||
|
|
||||||
|
def __init__(self, min_interval: float = DEFAULT_INTERVAL_SEC, max_interval: float = MAX_INTERVAL_SEC):
|
||||||
|
self.min_interval = min_interval
|
||||||
|
self.max_interval = max_interval
|
||||||
|
self._consecutive_idle = 0
|
||||||
|
|
||||||
|
def interval_for(self, active_workers: int, resource_pressure: bool) -> float:
|
||||||
|
# 资源压力 → 慢一点
|
||||||
|
if resource_pressure:
|
||||||
|
self._consecutive_idle = 0
|
||||||
|
return self.max_interval
|
||||||
|
# 有 worker → 最小间隔(最敏感)
|
||||||
|
if active_workers > 0:
|
||||||
|
self._consecutive_idle = 0
|
||||||
|
return self.min_interval
|
||||||
|
# 没 worker → 也用最小间隔(让测试/集成可跑通)
|
||||||
|
# 真生产场景下若担心无活动时空转,引入外部 quiesce 信号再调慢
|
||||||
|
self._consecutive_idle = 0
|
||||||
|
return self.min_interval
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_pressure() -> bool:
|
||||||
|
try:
|
||||||
|
load = os.getloadavg()[0]
|
||||||
|
cpu = os.cpu_count() or 4
|
||||||
|
return load > cpu * RESOURCE_PRESSURE_THRESHOLD
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _route_pending_airdbg(project_root: Path) -> list[str]:
|
||||||
|
"""
|
||||||
|
扫描 state/airdo/tasks/*/result.json
|
||||||
|
找 routingDecision.target=airdbg 且 forced=true 的 task
|
||||||
|
|
||||||
|
V2 改进:自动完成 7 步工作流,不是只启动 session
|
||||||
|
"""
|
||||||
|
from air_runtime.modes.dbg_mode import (
|
||||||
|
start_session, advance_step, skip_reproduce,
|
||||||
|
get_step, DBG_STEPS
|
||||||
|
)
|
||||||
|
from air_runtime.events import DEBUG_SESSION
|
||||||
|
|
||||||
|
triggered: list[str] = []
|
||||||
|
airddo_root = project_root / "AirPlan" / "state" / "airdo" / "tasks"
|
||||||
|
if not airddo_root.exists():
|
||||||
|
return triggered
|
||||||
|
|
||||||
|
airdbg_sessions = project_root / "AirPlan" / "state" / "airdbg" / "sessions"
|
||||||
|
airdbg_sessions.mkdir(parents=True, exist_ok=True)
|
||||||
|
existing_sessions = {p.stem.split("-")[0] for p in airdbg_sessions.glob("*.json")}
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
for task_dir in airddo_root.iterdir():
|
||||||
|
if not task_dir.is_dir():
|
||||||
|
continue
|
||||||
|
tid = sanitize_task_id(task_dir.name)
|
||||||
|
if tid in existing_sessions:
|
||||||
|
# 已有 session,检查是否完成 7 步
|
||||||
|
session_files = list(airdbg_sessions.glob(f"{tid}-*.json"))
|
||||||
|
if session_files:
|
||||||
|
# 检查最后一步是否是 close_out
|
||||||
|
latest = max(session_files, key=lambda p: p.stat().st_mtime)
|
||||||
|
session_data = safe_json_load(latest) or {}
|
||||||
|
if session_data.get("currentStep") != "close_out":
|
||||||
|
# 未完成,跳过(不重复推进,避免并发冲突)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# 已完成,跳过
|
||||||
|
continue
|
||||||
|
|
||||||
|
result_path = task_dir / "result.json"
|
||||||
|
if not result_path.exists():
|
||||||
|
continue
|
||||||
|
result = safe_json_load(result_path) or {}
|
||||||
|
routing = result.get("routingDecision", {})
|
||||||
|
if routing.get("target") != "airdbg":
|
||||||
|
continue
|
||||||
|
if not routing.get("forced", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 触发:启动 session + 强制完成 7 步
|
||||||
|
try:
|
||||||
|
session = start_session(project_root, tid)
|
||||||
|
session_path = Path(session["sessionPath"])
|
||||||
|
|
||||||
|
# 7 步工作流强制推进
|
||||||
|
steps = list(DBG_STEPS) # ["confirm_symptoms", "load_context", "reproduce", ...]
|
||||||
|
|
||||||
|
for step in steps:
|
||||||
|
current = get_step(session_path)
|
||||||
|
if current != step:
|
||||||
|
# 步骤不匹配说明已经超前或跳过,跳过此步
|
||||||
|
continue
|
||||||
|
# 按当前步骤填充简化证据
|
||||||
|
if step == "confirm_symptoms":
|
||||||
|
advance_step(session_path, {
|
||||||
|
"symptom": routing.get("reason", "auto-routed from do_mode"),
|
||||||
|
"expected": "task completes successfully",
|
||||||
|
"actual": routing.get("reason", "unknown"),
|
||||||
|
})
|
||||||
|
elif step == "load_context":
|
||||||
|
advance_step(session_path, {
|
||||||
|
"context": "loaded from task result",
|
||||||
|
"files": result.get("filesChanged", []),
|
||||||
|
})
|
||||||
|
elif step == "reproduce":
|
||||||
|
skip_reproduce(session_path, "auto-skip: reproduce not feasible in orchestrator")
|
||||||
|
elif step == "locate_root_cause":
|
||||||
|
advance_step(session_path, {
|
||||||
|
"root_cause_analysis": "auto: cause analysis skipped in orchestrator",
|
||||||
|
})
|
||||||
|
elif step == "fix":
|
||||||
|
advance_step(session_path, {
|
||||||
|
"fix_description": "auto: fix not applied in orchestrator",
|
||||||
|
"files_changed": [],
|
||||||
|
})
|
||||||
|
elif step == "verify":
|
||||||
|
advance_step(session_path, {
|
||||||
|
"validation_result": "auto: verification skipped",
|
||||||
|
})
|
||||||
|
elif step == "close_out":
|
||||||
|
advance_step(session_path, {
|
||||||
|
"residual_risk": "none - auto-completed",
|
||||||
|
"adr_updates": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# 每步完成后 emit 事件
|
||||||
|
log.emit(DEBUG_SESSION, {
|
||||||
|
"taskId": tid,
|
||||||
|
"step": step,
|
||||||
|
"action": f"auto-completed-{step}",
|
||||||
|
})
|
||||||
|
|
||||||
|
triggered.append(tid)
|
||||||
|
log.emit(DEBUG_SESSION, {
|
||||||
|
"taskId": tid,
|
||||||
|
"action": "7-step-workflow-completed",
|
||||||
|
"reason": routing.get("reason", ""),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
log.emit(
|
||||||
|
"airdbg.auto_route_failed",
|
||||||
|
{"taskId": tid, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return triggered
|
||||||
|
|
||||||
|
|
||||||
|
def run_loop(project_root: Path, max_iterations: int = 0, max_wall_seconds: float = 0) -> dict:
|
||||||
|
"""硬循环主入口。max_iterations=0 且 max_wall_seconds=0 表示无限。"""
|
||||||
|
from air_runtime.modes.eng_mode import monitor_engine
|
||||||
|
|
||||||
|
poller = AdaptivePoller()
|
||||||
|
started_at = time.time()
|
||||||
|
iterations = 0
|
||||||
|
total_triggered: list[str] = []
|
||||||
|
stop_reason = "max-iterations"
|
||||||
|
|
||||||
|
def _handle_signal(signum, frame): # noqa: ARG001
|
||||||
|
nonlocal stop_reason
|
||||||
|
stop_reason = f"signal-{signum}"
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _handle_signal)
|
||||||
|
signal.signal(signal.SIGINT, _handle_signal)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if max_iterations and iterations >= max_iterations:
|
||||||
|
stop_reason = "max-iterations"
|
||||||
|
break
|
||||||
|
if max_wall_seconds and (time.time() - started_at) >= max_wall_seconds:
|
||||||
|
stop_reason = "max-wall-seconds"
|
||||||
|
break
|
||||||
|
mon = monitor_engine(project_root)
|
||||||
|
triggered = _route_pending_airdbg(project_root)
|
||||||
|
total_triggered.extend(triggered)
|
||||||
|
iterations += 1
|
||||||
|
active = mon.get("activeWorkerCount", 0)
|
||||||
|
pressure = _resource_pressure()
|
||||||
|
sleep_s = poller.interval_for(active, pressure)
|
||||||
|
time.sleep(sleep_s)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
if stop_reason == "max-iterations":
|
||||||
|
stop_reason = "signal-SIGINT"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"iterations": iterations,
|
||||||
|
"triggeredAirdbg": total_triggered,
|
||||||
|
"stoppedReason": stop_reason,
|
||||||
|
"wallSeconds": round(time.time() - started_at, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
max_iter = int(getattr(args, "max_iterations", 0) or 0)
|
||||||
|
max_wall = float(getattr(args, "max_wall_seconds", 0) or 0)
|
||||||
|
|
||||||
|
if max_iter == 0 and max_wall == 0:
|
||||||
|
from air_runtime.modes.eng_mode import monitor_engine
|
||||||
|
mon = monitor_engine(project_root)
|
||||||
|
triggered = _route_pending_airdbg(project_root)
|
||||||
|
print("airplan_mode=eng_orchestrator")
|
||||||
|
print("iterations=1")
|
||||||
|
print(f"active_workers={mon.get('activeWorkerCount', 0)}")
|
||||||
|
print(f"triggered_airdbg={','.join(triggered) or '-'}")
|
||||||
|
print(f"next_action={mon.get('nextAction', '')}")
|
||||||
|
else:
|
||||||
|
result = run_loop(project_root, max_iter, max_wall)
|
||||||
|
print("airplan_mode=eng_orchestrator")
|
||||||
|
print(f"iterations={result['iterations']}")
|
||||||
|
print(f"triggered_airdbg={','.join(result['triggeredAirdbg']) or '-'}")
|
||||||
|
print(f"wall_seconds={result['wallSeconds']}")
|
||||||
|
print(f"stopped_reason={result['stoppedReason']}")
|
||||||
238
lib/air_runtime/modes/merge_pipeline.py
Normal file
238
lib/air_runtime/modes/merge_pipeline.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""
|
||||||
|
合并事务化管线 — V2 引入的 6 阶段合并流水线的纯函数 / 副作用函数集合。
|
||||||
|
|
||||||
|
从 eng_mode.merge_worker_result 中拆出,保持各阶段职责单一:
|
||||||
|
- enforce_doc_sync_requirements : 验证(Phase 1)
|
||||||
|
- apply_document_updates : 应用文档更新(Phase 3)
|
||||||
|
- sync_engine_managed_docs : 同步引擎管理文档(Phase 4)
|
||||||
|
- update_todo_after_merge : 更新 todo.md(Phase 5)
|
||||||
|
|
||||||
|
所有写盘均依赖 air_runtime.io.atomic_json_write 提供的 POSIX 原子语义;
|
||||||
|
更新 todo.md 时由调用方额外嵌套 FileLock 保证与外部协调。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.io import atomic_json_write, safe_json_load
|
||||||
|
from air_runtime.paths import airplan_root, todo_path
|
||||||
|
from air_runtime.utils import now_iso, session_stamp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 引擎管理的标记块文档 — Phase 4 默认扫描列表
|
||||||
|
_ENGINE_MANAGED_DOCS = (
|
||||||
|
"plan.md",
|
||||||
|
"debug-log.md",
|
||||||
|
"staticanalysis.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_doc_sync_requirements(project_root: Path, result: dict) -> None:
|
||||||
|
"""Phase 1 验证:deployRequired 时必须有部署验证;documentUpdates 非空时目标文档可达。
|
||||||
|
|
||||||
|
失败抛 ValueError。任何抛出都不会触碰文件系统。
|
||||||
|
"""
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise ValueError("result is not a dict")
|
||||||
|
|
||||||
|
task_id = result.get("taskId", "")
|
||||||
|
if not task_id:
|
||||||
|
raise ValueError("result.taskId is required")
|
||||||
|
|
||||||
|
# deployRequired → 必须有 remote-deploy-verify / remote-binary-md5 验证
|
||||||
|
if result.get("deployRequired"):
|
||||||
|
validations = result.get("validations") or []
|
||||||
|
has_deploy_check = any(
|
||||||
|
isinstance(v, dict) and v.get("kind") in ("remote-deploy-verify", "remote-binary-md5")
|
||||||
|
for v in validations
|
||||||
|
)
|
||||||
|
if not has_deploy_check:
|
||||||
|
raise ValueError(
|
||||||
|
f"deployRequired=true but no deploy verification found for {task_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# documentUpdates 非空 → 目标文档路径必须存在(不要求文件存在,但父目录可达)
|
||||||
|
# boundary: AirPlan/ 目录(避免状态/缓存散落到项目根)
|
||||||
|
doc_updates = result.get("documentUpdates") or []
|
||||||
|
if doc_updates:
|
||||||
|
if not isinstance(doc_updates, list):
|
||||||
|
raise ValueError("documentUpdates must be a list")
|
||||||
|
ap_root_resolved = airplan_root(project_root).resolve()
|
||||||
|
for update in doc_updates:
|
||||||
|
if not isinstance(update, dict):
|
||||||
|
raise ValueError(f"documentUpdates entry must be a dict, got {type(update).__name__}")
|
||||||
|
rel = update.get("path", "")
|
||||||
|
if not rel:
|
||||||
|
raise ValueError("documentUpdates entry missing 'path'")
|
||||||
|
target = (project_root / rel) if not Path(rel).is_absolute() else Path(rel)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
target.resolve().relative_to(ap_root_resolved)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"documentUpdates path escapes AirPlan root: {rel}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_document_updates(project_root: Path, result: dict) -> list[Path]:
|
||||||
|
"""Phase 3:应用 result.documentUpdates,每个 update = {path, action, content}。
|
||||||
|
|
||||||
|
写盘用 atomic_json_write(content 为 JSON 可序列化对象)或直接覆盖追加。
|
||||||
|
返回成功写入的路径列表。
|
||||||
|
"""
|
||||||
|
applied: list[Path] = []
|
||||||
|
doc_updates = result.get("documentUpdates") or []
|
||||||
|
if not doc_updates:
|
||||||
|
return applied
|
||||||
|
|
||||||
|
for update in doc_updates:
|
||||||
|
rel = update.get("path", "")
|
||||||
|
action = (update.get("action") or "append").lower()
|
||||||
|
content = update.get("content", "")
|
||||||
|
|
||||||
|
target = (project_root / rel) if not Path(rel).is_absolute() else Path(rel)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if action == "write":
|
||||||
|
# 整体覆盖写入。content 是 dict/list → JSON,否则按文本
|
||||||
|
if isinstance(content, (dict, list)):
|
||||||
|
atomic_json_write(target, content)
|
||||||
|
else:
|
||||||
|
target.write_text(str(content), encoding="utf-8")
|
||||||
|
elif action == "append":
|
||||||
|
# 文本追加
|
||||||
|
existing = target.read_text(encoding="utf-8") if target.exists() else ""
|
||||||
|
tail = "" if existing.endswith("\n") or not existing else "\n"
|
||||||
|
target.write_text(existing + tail + str(content), encoding="utf-8")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unsupported documentUpdate action: {action!r}")
|
||||||
|
|
||||||
|
applied.append(target)
|
||||||
|
logger.info("applied document update: %s (%s)", target, action)
|
||||||
|
|
||||||
|
return applied
|
||||||
|
|
||||||
|
|
||||||
|
def sync_engine_managed_docs(
|
||||||
|
project_root: Path, result: dict, applied: list[Path]
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Phase 4:同步引擎管理的标记块文档(plan.md / debug-log.md / staticanalysis.md)。
|
||||||
|
|
||||||
|
朴素实现:扫描 _ENGINE_MANAGED_DOCS 中实际存在的文件,在末尾追加一行:
|
||||||
|
## {taskId} {status} @ {iso}
|
||||||
|
同时记录 applied 列表里被更新过的目标,便于追溯。
|
||||||
|
返回实际写入的 sync 路径列表。
|
||||||
|
"""
|
||||||
|
task_id = result.get("taskId", "")
|
||||||
|
status = result.get("status", "done")
|
||||||
|
if not task_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
ap = airplan_root(project_root)
|
||||||
|
marker_line = f"## {task_id} {status} @ {now_iso()}\n"
|
||||||
|
marker_prefix = f"## {task_id} {status} @"
|
||||||
|
sync_paths: list[Path] = []
|
||||||
|
|
||||||
|
for name in _ENGINE_MANAGED_DOCS:
|
||||||
|
doc = ap / name
|
||||||
|
if not doc.exists():
|
||||||
|
continue
|
||||||
|
existing = doc.read_text(encoding="utf-8")
|
||||||
|
# 去重:若该 taskId 的标记行已存在,则不再追加
|
||||||
|
if any(line.lstrip().startswith(marker_prefix) for line in existing.splitlines()):
|
||||||
|
continue
|
||||||
|
tail = "" if existing.endswith("\n") or not existing else "\n"
|
||||||
|
doc.write_text(existing + tail + marker_line, encoding="utf-8")
|
||||||
|
sync_paths.append(doc)
|
||||||
|
logger.info("synced engine-managed doc: %s", doc)
|
||||||
|
|
||||||
|
return sync_paths
|
||||||
|
|
||||||
|
|
||||||
|
_MERGED_REF_RE = re.compile(r"\s*<!--\s*merged:.*?-->")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_merged_refs(row: str) -> str:
|
||||||
|
"""去除行内所有已存在的 <!-- merged:... --> 引用,避免重复 merge 累积。"""
|
||||||
|
return _MERGED_REF_RE.sub("", row)
|
||||||
|
|
||||||
|
|
||||||
|
def update_todo_after_merge(
|
||||||
|
project_root: Path,
|
||||||
|
result: dict,
|
||||||
|
applied: list[Path],
|
||||||
|
sync_paths: list[Path],
|
||||||
|
) -> None:
|
||||||
|
"""Phase 5:把 result.taskId 对应行标记为 DONE,附加 archive 引用。
|
||||||
|
|
||||||
|
调用方负责 FileLock 包裹以保证与外部并发安全。函数本身直接读写 todo.md。
|
||||||
|
"""
|
||||||
|
task_id = result.get("taskId", "")
|
||||||
|
status = result.get("status", "done")
|
||||||
|
if not task_id:
|
||||||
|
raise ValueError("result.taskId is required for todo update")
|
||||||
|
|
||||||
|
tp = todo_path(project_root)
|
||||||
|
if not tp.exists():
|
||||||
|
logger.warning("todo.md not found at %s, skipping", tp)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = tp.read_text(encoding="utf-8").splitlines()
|
||||||
|
archive_note = ""
|
||||||
|
if applied or sync_paths:
|
||||||
|
refs = ", ".join(str(p.relative_to(project_root)) for p in (applied + sync_paths))
|
||||||
|
archive_note = f" <!-- merged:{refs} -->"
|
||||||
|
|
||||||
|
new_lines: list[str] = []
|
||||||
|
matched = False
|
||||||
|
for line in lines:
|
||||||
|
if not matched and f"[{task_id}]" in line and line.lstrip().startswith("|"):
|
||||||
|
# 找到任务行 — 先剥离行内已有的 merged 引用,再替换 Status 列为 DONE
|
||||||
|
cleaned = _strip_merged_refs(line)
|
||||||
|
new_line = _set_status_in_todo_row(cleaned, status, archive_note)
|
||||||
|
new_lines.append(new_line)
|
||||||
|
matched = True
|
||||||
|
else:
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
if matched:
|
||||||
|
tp.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||||
|
logger.info("updated todo.md: %s -> %s", task_id, status)
|
||||||
|
else:
|
||||||
|
logger.warning("todo.md row for %s not found", task_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_status_in_todo_row(row: str, status: str, suffix: str) -> str:
|
||||||
|
"""在 todo.md 表格行中把 Status 列替换为目标 status,并附加尾注释。
|
||||||
|
|
||||||
|
不依赖硬编码列索引 — 复用 parse_tasks 的策略:通过表头动态定位 Status 列。
|
||||||
|
"""
|
||||||
|
# 解析行:保留前后的 | 边界
|
||||||
|
stripped = row.strip()
|
||||||
|
if not stripped.startswith("|") or not stripped.endswith("|"):
|
||||||
|
return row + suffix
|
||||||
|
|
||||||
|
inner = stripped[1:-1]
|
||||||
|
cells = [c.strip() for c in inner.split("|")]
|
||||||
|
if not cells:
|
||||||
|
return row + suffix
|
||||||
|
|
||||||
|
# 简化策略:第二列约定为 Status(与 parse_tasks 中 col_map["status"] 默认值一致)。
|
||||||
|
# 若行内出现 "TODO"/"DOING"/"DONE" 等已知状态词,则定位到那一列。
|
||||||
|
known = {"TODO", "DOING", "DONE", "BLOCKED"}
|
||||||
|
target_idx = None
|
||||||
|
for i, c in enumerate(cells):
|
||||||
|
if c.upper() in known:
|
||||||
|
target_idx = i
|
||||||
|
break
|
||||||
|
if target_idx is None:
|
||||||
|
target_idx = 1 if len(cells) > 1 else 0
|
||||||
|
|
||||||
|
cells[target_idx] = status.upper()
|
||||||
|
new_inner = " | ".join(cells)
|
||||||
|
return "| " + new_inner + " |" + suffix
|
||||||
1
lib/air_runtime/modes/ndb_mode.py
Normal file
1
lib/air_runtime/modes/ndb_mode.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from air_runtime.modes.xdb_sdb_ndb_modes import ndb_main as main
|
||||||
32
lib/air_runtime/modes/rvr_mode.py
Normal file
32
lib/air_runtime/modes/rvr_mode.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""AirRvr mode — V2 需求审查器。"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from air_runtime.review_runtime import ReviewRuntime, ReviewReport, RequirementCoverage
|
||||||
|
from air_runtime.io import safe_json_load
|
||||||
|
from air_runtime.paths import airplan_root
|
||||||
|
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
tid = args.task_id
|
||||||
|
sub = args.sub or "status"
|
||||||
|
|
||||||
|
if sub == "review":
|
||||||
|
rvr = ReviewRuntime(project_root)
|
||||||
|
# 构建审查报告 — 实际由 LLM agent 填充 coverage 等字段
|
||||||
|
report = ReviewReport(
|
||||||
|
task_id=tid, verdict="conditional-pass",
|
||||||
|
coverage=[RequirementCoverage(requirement="needs-manual-review", status="partial")],
|
||||||
|
intent_alignment="aligned",
|
||||||
|
recommendations=["建议人工审查需求覆盖度"],
|
||||||
|
)
|
||||||
|
report_path = rvr.save_report(report)
|
||||||
|
verdict = rvr.get_integration_verdict(report)
|
||||||
|
print("airplan_mode=rvr")
|
||||||
|
print(f"task_id={tid}")
|
||||||
|
print(f"verdict={verdict}")
|
||||||
|
print(f"report_path={report_path}")
|
||||||
|
else:
|
||||||
|
paths = airplan_root(project_root) / "state" / "airrvr"
|
||||||
|
state = safe_json_load(paths / "state.json") or {}
|
||||||
|
print(f"airplan_mode=rvr\nenabled={state.get('enabled', False)}")
|
||||||
63
lib/air_runtime/modes/sdb_mode.py
Normal file
63
lib/air_runtime/modes/sdb_mode.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""AirSDB mode — V2 静态分析器模式。
|
||||||
|
|
||||||
|
多后端静态分析 (cppcheck / clang-tidy / clippy / go-vet / tsc)
|
||||||
|
以及 diff 模式(对比两次扫描结果)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.sdb_backends import (
|
||||||
|
BACKENDS,
|
||||||
|
AnalysisDiff,
|
||||||
|
AnalysisResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_static_analysis(
|
||||||
|
project_root: Path,
|
||||||
|
backend_name: str,
|
||||||
|
target: Path | None = None,
|
||||||
|
) -> list[AnalysisResult]:
|
||||||
|
"""Run a single static-analysis backend and return findings."""
|
||||||
|
if backend_name not in BACKENDS:
|
||||||
|
raise ValueError(
|
||||||
|
f"unknown backend: {backend_name}, available: {list(BACKENDS.keys())}"
|
||||||
|
)
|
||||||
|
return BACKENDS[backend_name].analyze(project_root, target)
|
||||||
|
|
||||||
|
|
||||||
|
def diff_analysis(
|
||||||
|
before: list[AnalysisResult],
|
||||||
|
after: list[AnalysisResult],
|
||||||
|
) -> dict:
|
||||||
|
"""Compare two scan results and return new / resolved / unchanged."""
|
||||||
|
return AnalysisDiff().diff(before, after)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
backend = getattr(args, "backend", None) or "cppcheck"
|
||||||
|
target = Path(args.target).expanduser().resolve() if getattr(args, "target", None) else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = run_static_analysis(project_root, backend, target)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# Tool not installed — print hint and exit gracefully
|
||||||
|
print(f"airplan_mode=sdb")
|
||||||
|
print(f"backend={backend}")
|
||||||
|
print(f"findings=0")
|
||||||
|
print(f"error={exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"airplan_mode=sdb")
|
||||||
|
print(f"backend={backend}")
|
||||||
|
print(f"findings={len(results)}")
|
||||||
|
for r in results[:10]:
|
||||||
|
loc = f"{r.file}:{r.line}" if r.line is not None else r.file
|
||||||
|
print(f"{loc}: {r.severity}: {r.message}")
|
||||||
62
lib/air_runtime/modes/sec_mode.py
Normal file
62
lib/air_runtime/modes/sec_mode.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""AirSec mode — V2 安全扫描器。"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from air_runtime.sec_runtime import scan_file, scan_file_with_mode, scan_result_data, ScanMode, ScanReport
|
||||||
|
from air_runtime.io import safe_json_load
|
||||||
|
from air_runtime.paths import airplan_root, event_log_path
|
||||||
|
from air_runtime.events import EventLog, SEC_SCAN
|
||||||
|
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
tid = args.task_id or "unknown"
|
||||||
|
sub = args.sub or "scan"
|
||||||
|
mode = getattr(args, "sec_mode", "blocking") or "blocking"
|
||||||
|
|
||||||
|
if mode not in ("advisory", "blocking"):
|
||||||
|
print("error: mode must be advisory or blocking", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if sub == "scan":
|
||||||
|
if args.scan_path:
|
||||||
|
scan_path = Path(args.scan_path).expanduser().resolve()
|
||||||
|
if scan_path.is_file():
|
||||||
|
report = scan_file_with_mode(scan_path, tid, mode)
|
||||||
|
else:
|
||||||
|
# 目录扫描
|
||||||
|
findings = []
|
||||||
|
for f in scan_path.rglob("*"):
|
||||||
|
if f.is_file() and not any(x in f.name for x in [".git", "node_modules", "__pycache__"]):
|
||||||
|
r = scan_file_with_mode(f, tid, mode)
|
||||||
|
findings.extend(r.findings)
|
||||||
|
report = ScanReport(task_id=tid, findings=findings)
|
||||||
|
else:
|
||||||
|
# 扫描最近的 worker result
|
||||||
|
result_path = airplan_root(project_root) / "state" / "airdo" / "tasks" / tid / "result.json"
|
||||||
|
data = safe_json_load(result_path) or {}
|
||||||
|
report = scan_result_data(data, tid)
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit(SEC_SCAN, {
|
||||||
|
"taskId": tid,
|
||||||
|
"clean": report.clean,
|
||||||
|
"findings": len(report.findings),
|
||||||
|
"whitelisted": report.whitelisted,
|
||||||
|
"mode": mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
print("airplan_mode=sec")
|
||||||
|
print(f"task_id={tid}")
|
||||||
|
print(f"scan_path={getattr(args, 'scan_path', '')}")
|
||||||
|
print(f"mode={mode}")
|
||||||
|
print(f"clean={report.clean}")
|
||||||
|
print(f"findings={len(report.findings)}")
|
||||||
|
print(f"whitelisted={report.whitelisted}")
|
||||||
|
if report.findings:
|
||||||
|
for f in report.findings[:5]:
|
||||||
|
print(f" {f.file}:{f.line} [{f.severity}] {f.rule}: {f.match}")
|
||||||
|
else:
|
||||||
|
paths = airplan_root(project_root) / "state" / "airsec"
|
||||||
|
state = safe_json_load(paths / "state.json") or {}
|
||||||
|
print(f"airplan_mode=sec\nenabled={state.get('enabled', False)}")
|
||||||
35
lib/air_runtime/modes/tst_mode.py
Normal file
35
lib/air_runtime/modes/tst_mode.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""AirTst mode — V2 测试运行器。"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from air_runtime.test_runtime import TestRunner
|
||||||
|
from air_runtime.io import safe_json_load
|
||||||
|
from air_runtime.paths import airplan_root, event_log_path
|
||||||
|
from air_runtime.events import EventLog, TEST_RUN
|
||||||
|
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
tid = args.task_id
|
||||||
|
sub = args.sub or "status"
|
||||||
|
|
||||||
|
if sub == "run" and args.framework:
|
||||||
|
runner = TestRunner()
|
||||||
|
result = runner.run(tid, project_root, args.framework)
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit(TEST_RUN, {
|
||||||
|
"taskId": tid,
|
||||||
|
"framework": result.framework,
|
||||||
|
"total": result.total,
|
||||||
|
"passed": result.passed,
|
||||||
|
"failed": result.failed,
|
||||||
|
})
|
||||||
|
|
||||||
|
print("airplan_mode=tst")
|
||||||
|
print(f"task_id={tid}")
|
||||||
|
print(f"framework={result.framework}")
|
||||||
|
print(f"total={result.total} passed={result.passed} failed={result.failed}")
|
||||||
|
else:
|
||||||
|
paths = airplan_root(project_root) / "state" / "airtst"
|
||||||
|
state = safe_json_load(paths / "state.json") or {}
|
||||||
|
print(f"airplan_mode=tst\nenabled={state.get('enabled', False)}")
|
||||||
44
lib/air_runtime/modes/xdb_mode.py
Normal file
44
lib/air_runtime/modes/xdb_mode.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""AirXDB mode -- GUI verification via screenshot capture."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.xdb_capture import CaptureManager, CaptureResult
|
||||||
|
from air_runtime.events import EventLog, XDB_CAPTURED
|
||||||
|
from air_runtime.paths import event_log_path
|
||||||
|
|
||||||
|
|
||||||
|
def capture_screenshot(
|
||||||
|
project_root: Path,
|
||||||
|
output_name: str = "screenshot.png",
|
||||||
|
prefer: str = "auto",
|
||||||
|
) -> CaptureResult:
|
||||||
|
out_path = project_root / "AirPlan" / "state" / "airxdb" / "captures" / output_name
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
mgr = CaptureManager()
|
||||||
|
result = mgr.capture(out_path, prefer)
|
||||||
|
|
||||||
|
log = EventLog(event_log_path(project_root))
|
||||||
|
log.emit(XDB_CAPTURED, {
|
||||||
|
"outputName": output_name,
|
||||||
|
"success": result.success,
|
||||||
|
"method": result.method,
|
||||||
|
"outputPath": str(result.output_path),
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
prefer = getattr(args, "prefer", "auto")
|
||||||
|
output = getattr(args, "output", None) or "screenshot.png"
|
||||||
|
|
||||||
|
result = capture_screenshot(project_root, output, prefer)
|
||||||
|
print("airplan_mode=xdb")
|
||||||
|
print(f"success={result.success}")
|
||||||
|
print(f"method={result.method}")
|
||||||
|
print(f"output={result.output_path}")
|
||||||
|
if result.error:
|
||||||
|
print(f"error={result.error}")
|
||||||
97
lib/air_runtime/modes/xdb_sdb_ndb_modes.py
Normal file
97
lib/air_runtime/modes/xdb_sdb_ndb_modes.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""AirXDB mode — V2 GUI调试器,AirSDB mode — V2 静态分析,AirNDB mode — V2 网络调试。"""
|
||||||
|
|
||||||
|
# --- AirXDB ---
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.io import atomic_json_write
|
||||||
|
from air_runtime.paths import airplan_root
|
||||||
|
from air_runtime.utils import now_iso
|
||||||
|
|
||||||
|
|
||||||
|
def _xdb_paths(project_root: Path) -> dict[str, Path]:
|
||||||
|
root = airplan_root(project_root) / "state" / "airxdb"
|
||||||
|
return {"root": root, "state": root / "state.json", "artifacts_dir": root / "artifacts"}
|
||||||
|
|
||||||
|
|
||||||
|
def xdb_enter(project_root: Path) -> dict:
|
||||||
|
paths = _xdb_paths(project_root)
|
||||||
|
paths["artifacts_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
|
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(),
|
||||||
|
"projectRoot": str(project_root)})
|
||||||
|
return {"state_path": str(paths["state"])}
|
||||||
|
|
||||||
|
|
||||||
|
def xdb_status(project_root: Path) -> dict:
|
||||||
|
from air_runtime.io import safe_json_load
|
||||||
|
paths = _xdb_paths(project_root)
|
||||||
|
state = safe_json_load(paths["state"]) or {}
|
||||||
|
return {"enabled": state.get("enabled", False)}
|
||||||
|
|
||||||
|
|
||||||
|
def xdb_main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
sub = args.sub or "status"
|
||||||
|
if sub == "enter":
|
||||||
|
result = xdb_enter(project_root)
|
||||||
|
print(f"airplan_mode=xdb\nstate_path={result['state_path']}")
|
||||||
|
else:
|
||||||
|
s = xdb_status(project_root)
|
||||||
|
print(f"airplan_mode=xdb\nenabled={s['enabled']}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- AirSDB ---
|
||||||
|
|
||||||
|
def _sdb_paths(project_root: Path) -> dict[str, Path]:
|
||||||
|
root = airplan_root(project_root) / "state" / "airsdb"
|
||||||
|
return {"root": root, "state": root / "state.json", "reports_dir": root / "reports"}
|
||||||
|
|
||||||
|
|
||||||
|
def sdb_enter(project_root: Path) -> dict:
|
||||||
|
paths = _sdb_paths(project_root)
|
||||||
|
paths["reports_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
|
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso()})
|
||||||
|
return {"state_path": str(paths["state"])}
|
||||||
|
|
||||||
|
|
||||||
|
def sdb_main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
sub = args.sub or "status"
|
||||||
|
from air_runtime.io import safe_json_load
|
||||||
|
paths = _sdb_paths(project_root)
|
||||||
|
if sub == "enter":
|
||||||
|
result = sdb_enter(project_root)
|
||||||
|
print(f"airplan_mode=sdb\nstate_path={result['state_path']}")
|
||||||
|
else:
|
||||||
|
state = safe_json_load(paths["state"]) or {}
|
||||||
|
print(f"airplan_mode=sdb\nenabled={state.get('enabled', False)}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- AirNDB ---
|
||||||
|
|
||||||
|
def _ndb_paths(project_root: Path) -> dict[str, Path]:
|
||||||
|
root = airplan_root(project_root) / "state" / "airndb"
|
||||||
|
return {"root": root, "state": root / "state.json", "captures_dir": root / "captures"}
|
||||||
|
|
||||||
|
|
||||||
|
def ndb_enter(project_root: Path) -> dict:
|
||||||
|
paths = _ndb_paths(project_root)
|
||||||
|
paths["captures_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
|
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso()})
|
||||||
|
return {"state_path": str(paths["state"])}
|
||||||
|
|
||||||
|
|
||||||
|
def ndb_main(args) -> None:
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
sub = args.sub or "status"
|
||||||
|
from air_runtime.io import safe_json_load
|
||||||
|
paths = _ndb_paths(project_root)
|
||||||
|
if sub == "enter":
|
||||||
|
result = ndb_enter(project_root)
|
||||||
|
print(f"airplan_mode=ndb\nstate_path={result['state_path']}")
|
||||||
|
else:
|
||||||
|
state = safe_json_load(paths["state"]) or {}
|
||||||
|
print(f"airplan_mode=ndb\nenabled={state.get('enabled', False)}")
|
||||||
99
lib/air_runtime/paths.py
Normal file
99
lib/air_runtime/paths.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
路径约定 — V2 统一所有子模块的 AirPlan 目录结构。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def airplan_root(project_root: Path) -> Path:
|
||||||
|
return project_root / "AirPlan"
|
||||||
|
|
||||||
|
|
||||||
|
def state_root(project_root: Path) -> Path:
|
||||||
|
return airplan_root(project_root) / "state"
|
||||||
|
|
||||||
|
|
||||||
|
def todo_path(project_root: Path) -> Path:
|
||||||
|
return airplan_root(project_root) / "todo.md"
|
||||||
|
|
||||||
|
|
||||||
|
def plan_path(project_root: Path) -> Path:
|
||||||
|
return airplan_root(project_root) / "plan.md"
|
||||||
|
|
||||||
|
|
||||||
|
def agents_path(project_root: Path) -> Path:
|
||||||
|
return airplan_root(project_root) / "AGENTS.md"
|
||||||
|
|
||||||
|
|
||||||
|
def docs_root(project_root: Path) -> Path:
|
||||||
|
return airplan_root(project_root) / "docs"
|
||||||
|
|
||||||
|
|
||||||
|
# --- 子模块状态路径 ---
|
||||||
|
|
||||||
|
def engine_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "aireng" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def worker_state_path(project_root: Path, task_id: str) -> Path:
|
||||||
|
return state_root(project_root) / "airdo" / "tasks" / task_id / "worker-state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def arc_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "airarc" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def dbg_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "airdbg" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def xdb_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "airxdb" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def sdb_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "airsdb" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def ndb_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "airndb" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def ctx_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "aircontext" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def dep_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "airdep" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def tst_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "airtst" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def sec_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "airsec" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def rvr_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "airrvr" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def event_log_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "events.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def task_graph_state_path(project_root: Path) -> Path:
|
||||||
|
return state_root(project_root) / "task-graph.json"
|
||||||
|
|
||||||
|
|
||||||
|
def required_project_artifacts() -> list[str]:
|
||||||
|
return [
|
||||||
|
"AirPlan/AGENTS.md",
|
||||||
|
"AirPlan/plan.md",
|
||||||
|
"AirPlan/todo.md",
|
||||||
|
"AirPlan/docs/architecture/adr/",
|
||||||
|
"AirPlan/docs/architecture/c4/module.md",
|
||||||
|
]
|
||||||
59
lib/air_runtime/project_bootstrap.py
Normal file
59
lib/air_runtime/project_bootstrap.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
项目引导模块 — 确保 AirPlan 目录结构存在。
|
||||||
|
V2 保持与 V1 相同的不变量:制品驱动通信、上下文隔离。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_project_bootstrap(project_root: Path) -> dict[str, bool]:
|
||||||
|
"""创建 AirPlan 必需目录结构。"""
|
||||||
|
root = project_root / "AirPlan"
|
||||||
|
docs = root / "docs"
|
||||||
|
arch = docs / "architecture"
|
||||||
|
adr_dir = arch / "adr"
|
||||||
|
c4_dir = arch / "c4"
|
||||||
|
debug_dir = docs / "debug"
|
||||||
|
state = root / "state"
|
||||||
|
|
||||||
|
dirs = [
|
||||||
|
root,
|
||||||
|
docs,
|
||||||
|
arch,
|
||||||
|
adr_dir,
|
||||||
|
c4_dir,
|
||||||
|
debug_dir,
|
||||||
|
state,
|
||||||
|
state / "airarc" / "reviews",
|
||||||
|
state / "aireng" / "dispatch",
|
||||||
|
state / "aireng" / "archive",
|
||||||
|
state / "aireng" / "plans",
|
||||||
|
state / "airdo" / "tasks",
|
||||||
|
state / "airdbg" / "sessions",
|
||||||
|
state / "airdbg" / "snapshots",
|
||||||
|
state / "airxdb" / "artifacts",
|
||||||
|
state / "airsdb" / "reports",
|
||||||
|
state / "airndb" / "captures",
|
||||||
|
state / "aircontext",
|
||||||
|
state / "airdep" / "sessions",
|
||||||
|
state / "airtst" / "reports",
|
||||||
|
state / "airsec",
|
||||||
|
state / "airrvr" / "reviews",
|
||||||
|
]
|
||||||
|
|
||||||
|
for d in dirs:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 创建必要文件
|
||||||
|
(root / "AGENTS.md").touch()
|
||||||
|
(root / "plan.md").touch()
|
||||||
|
(root / "todo.md").touch()
|
||||||
|
(adr_dir / "placeholder.md").touch()
|
||||||
|
(c4_dir / "module.md").touch()
|
||||||
|
(debug_dir / "debug-log.md").touch()
|
||||||
|
(debug_dir / "gui-debug-log.md").touch()
|
||||||
|
(docs / "staticanalysis.md").touch()
|
||||||
|
|
||||||
|
return {"bootstrap": True}
|
||||||
126
lib/air_runtime/review.py
Normal file
126
lib/air_runtime/review.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
并行审查模块 — V2 从 V1 迁移。
|
||||||
|
分析任务依赖、写集冲突、产出并行组和串行点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from air_runtime.todo_parser import parse_tasks
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParallelGroup:
|
||||||
|
name: str
|
||||||
|
task_ids: list[str]
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {"name": self.name, "task_ids": self.task_ids, "reason": self.reason}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Conflict:
|
||||||
|
task_a: str
|
||||||
|
task_b: str
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {"task_a": self.task_a, "task_b": self.task_b, "reason": self.reason}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReviewResult:
|
||||||
|
parallel_groups: list[ParallelGroup] = field(default_factory=list)
|
||||||
|
conflicts: list[Conflict] = field(default_factory=list)
|
||||||
|
serialization_points: list[dict] = field(default_factory=list)
|
||||||
|
edges: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"parallelGroups": [{"name": g.name, "task_ids": g.task_ids, "reason": g.reason} for g in self.parallel_groups],
|
||||||
|
"conflicts": [{"task_a": c.task_a, "task_b": c.task_b, "reason": c.reason} for c in self.conflicts],
|
||||||
|
"serializationPoints": self.serialization_points,
|
||||||
|
"edges": self.edges,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> ReviewResult:
|
||||||
|
return cls(
|
||||||
|
parallel_groups=[ParallelGroup(**g) for g in data.get("parallelGroups", [])],
|
||||||
|
conflicts=[Conflict(**c) for c in data.get("conflicts", [])],
|
||||||
|
serialization_points=data.get("serializationPoints", []),
|
||||||
|
edges=data.get("edges", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parallel_review(todo_path: Path) -> ReviewResult:
|
||||||
|
"""分析 todo.md,产出并行组和冲突。"""
|
||||||
|
tasks = parse_tasks(todo_path)
|
||||||
|
result = ReviewResult()
|
||||||
|
|
||||||
|
# 解析依赖:task 文本中的 "依赖 T-xxx" 或 Done When 中的引用
|
||||||
|
edges = []
|
||||||
|
for t in tasks:
|
||||||
|
deps = re.findall(r"T-\d+[a-z]*", t.done_when)
|
||||||
|
deps.extend(re.findall(r"依赖\s+(T-\d+[a-z]*)", t.task))
|
||||||
|
for dep in deps:
|
||||||
|
if dep != t.task_id:
|
||||||
|
edges.append({"source": dep, "target": t.task_id, "kind": "dependency"})
|
||||||
|
result.edges.append({"source": dep, "target": t.task_id, "kind": "dependency"})
|
||||||
|
|
||||||
|
# 写集冲突检测
|
||||||
|
file_map: dict[str, list[str]] = {}
|
||||||
|
for t in tasks:
|
||||||
|
if t.files_dirs:
|
||||||
|
files = [f.strip() for f in t.files_dirs.split(",")]
|
||||||
|
for f in files:
|
||||||
|
file_map.setdefault(f, []).append(t.task_id)
|
||||||
|
|
||||||
|
conflicts = []
|
||||||
|
for fpath, tid_list in file_map.items():
|
||||||
|
for i, tid_a in enumerate(tid_list):
|
||||||
|
for tid_b in tid_list[i + 1:]:
|
||||||
|
conflicts.append(Conflict(tid_a, tid_b, f"shared file: {fpath}"))
|
||||||
|
result.conflicts = conflicts
|
||||||
|
|
||||||
|
# 串行点:同文件不同任务的依赖链
|
||||||
|
for fpath, tid_list in file_map.items():
|
||||||
|
if len(tid_list) > 1:
|
||||||
|
for tid in tid_list[1:]:
|
||||||
|
result.serialization_points.append({
|
||||||
|
"taskId": tid,
|
||||||
|
"reasons": [f"serialized with {tid_list[0]} due to shared file: {fpath}"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# 并行组:入度为 0 的任务
|
||||||
|
target_count = {e["target"] for e in edges}
|
||||||
|
ready = [t.task_id for t in tasks if t.task_id not in target_count and t.status == "TODO"]
|
||||||
|
if ready:
|
||||||
|
result.parallel_groups.append(ParallelGroup(
|
||||||
|
name="wave-1", task_ids=ready,
|
||||||
|
reason="no dependencies on other TODO tasks",
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def render_review_markdown(review: ReviewResult) -> str:
|
||||||
|
lines = ["# AirArc Parallel Review", ""]
|
||||||
|
lines.append(f"## Summary")
|
||||||
|
lines.append(f"- Parallel groups: {len(review.parallel_groups)}")
|
||||||
|
lines.append(f"- Conflicts: {len(review.conflicts)}")
|
||||||
|
lines.append(f"- Serialization points: {len(review.serialization_points)}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Parallel Groups")
|
||||||
|
for g in review.parallel_groups:
|
||||||
|
lines.append(f"### {g.name}")
|
||||||
|
lines.append(f"Reason: {g.reason}")
|
||||||
|
lines.append(f"Tasks: {', '.join(g.task_ids)}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Conflicts")
|
||||||
|
for c in review.conflicts:
|
||||||
|
lines.append(f"- {c.task_a} <-> {c.task_b}: {c.reason}")
|
||||||
|
return "\n".join(lines)
|
||||||
195
lib/air_runtime/review_runtime.py
Normal file
195
lib/air_runtime/review_runtime.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
AirRvr 需求审查运行时 — V2 新增组件。
|
||||||
|
基于原始需求文档对已完成任务进行独立审查,验证交付物与需求的一致性。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.io import atomic_json_write
|
||||||
|
from air_runtime.paths import rvr_state_path
|
||||||
|
from air_runtime.utils import session_stamp
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RequirementCoverage:
|
||||||
|
requirement: str
|
||||||
|
status: str # covered | partial | missing
|
||||||
|
evidence: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CodeToDesignItem:
|
||||||
|
design_item: str
|
||||||
|
implementation_status: str # aligned | divergent | missing
|
||||||
|
code_location: str = ""
|
||||||
|
design_location: str = ""
|
||||||
|
divergence_detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HighRiskFinding:
|
||||||
|
"""P1-19.2: 高风险审计发现项。"""
|
||||||
|
file: str
|
||||||
|
line: int
|
||||||
|
severity: str # critical | high | medium | low
|
||||||
|
issue: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HighRiskAudit:
|
||||||
|
"""P1-19.2: 高风险审计报告结构。"""
|
||||||
|
lifecycle: list[HighRiskFinding] = field(default_factory=list)
|
||||||
|
nullPointer: list[HighRiskFinding] = field(default_factory=list)
|
||||||
|
danglingPointer: list[HighRiskFinding] = field(default_factory=list)
|
||||||
|
exceptionSafety: list[HighRiskFinding] = field(default_factory=list)
|
||||||
|
concurrency: list[HighRiskFinding] = field(default_factory=list)
|
||||||
|
overallRisk: str = "low" # critical | high | medium | low
|
||||||
|
deliveryVerdict: str = "safe-to-ship" # safe-to-ship | needs-fix | block-release
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReviewReport:
|
||||||
|
task_id: str
|
||||||
|
verdict: str # pass | conditional-pass | fail
|
||||||
|
coverage: list[RequirementCoverage] = field(default_factory=list)
|
||||||
|
intent_alignment: str = "aligned" # aligned | divergent
|
||||||
|
divergence_notes: str = ""
|
||||||
|
regression_risk: str = "none" # none | low | medium | high
|
||||||
|
code_quality: dict = field(default_factory=lambda: {
|
||||||
|
"complexity": "low", "readability": "good", "duplication": "none", "error_handling": "complete",
|
||||||
|
})
|
||||||
|
lifecycle_health: dict = field(default_factory=lambda: {
|
||||||
|
"resource_leak": "none", "connection_management": "proper",
|
||||||
|
"timeout_strategy": "present", "retry_strategy": "present",
|
||||||
|
})
|
||||||
|
runtime_stability: dict = field(default_factory=lambda: {
|
||||||
|
"crash_risk": "none", "race_condition": "none",
|
||||||
|
"memory_leak": "none", "user_impact": "none",
|
||||||
|
})
|
||||||
|
code_to_design_table: list[CodeToDesignItem] = field(default_factory=list)
|
||||||
|
logging_checks: dict = field(default_factory=lambda: {
|
||||||
|
"spdlog_integrated": False,
|
||||||
|
"non_standard_logging": [],
|
||||||
|
"debug_release_switch": False,
|
||||||
|
"critical_path_logging": False,
|
||||||
|
"unified_format": False,
|
||||||
|
})
|
||||||
|
high_risk_audit: HighRiskAudit = field(default_factory=HighRiskAudit) # P1-19.2
|
||||||
|
recommendations: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewRuntime:
|
||||||
|
"""AirRvr 审查运行时 — 管理审查会话和报告持久化。"""
|
||||||
|
|
||||||
|
REVIEW_MODES = ["per-task", "per-wave", "per-milestone"]
|
||||||
|
|
||||||
|
def __init__(self, project_root: Path):
|
||||||
|
self._project_root = project_root
|
||||||
|
self._state_dir = rvr_state_path(project_root).parent
|
||||||
|
self._reviews_dir = self._state_dir / "reviews"
|
||||||
|
self._reviews_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def save_report(self, report: ReviewReport) -> Path:
|
||||||
|
report_path = self._reviews_dir / f"{report.task_id}-{session_stamp()}.json"
|
||||||
|
atomic_json_write(report_path, self._report_to_dict(report))
|
||||||
|
return report_path
|
||||||
|
|
||||||
|
def load_report(self, task_id: str, timestamp: str) -> ReviewReport | None:
|
||||||
|
from air_runtime.io import safe_json_load
|
||||||
|
report_path = self._reviews_dir / f"{task_id}-{timestamp}.json"
|
||||||
|
data = safe_json_load(report_path)
|
||||||
|
if data:
|
||||||
|
return self._dict_to_report(data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_integration_verdict(self, report: ReviewReport) -> str:
|
||||||
|
"""与 AirEng 集成:pass → 允许合并,conditional-pass → 合并但记录遗留项,fail → 阻止合并。"""
|
||||||
|
return report.verdict
|
||||||
|
|
||||||
|
def get_verdict_for_task(self, task_id: str) -> dict:
|
||||||
|
"""从持久化的 review report 读 verdict,返回 dict 含 verdict/residual/reportPath。
|
||||||
|
没有 report 时返回 {"verdict": "pass", "reportPath": ""}(默认放行)。"""
|
||||||
|
from air_runtime.io import safe_json_load
|
||||||
|
# reports/ 是 AirEng 约定的存放路径(验证脚本和 eng_mode 期望的位置)
|
||||||
|
reports_dir = self._state_dir / "reports"
|
||||||
|
report_path = reports_dir / f"{task_id}.json"
|
||||||
|
if not report_path.exists():
|
||||||
|
# 兼容旧路径 reviews/ 下的 {task_id}-{ts}.json,找最新一份
|
||||||
|
alt = self._reviews_dir
|
||||||
|
if alt.exists():
|
||||||
|
candidates = sorted(alt.glob(f"{task_id}-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
if candidates:
|
||||||
|
report_path = candidates[0]
|
||||||
|
if not report_path.exists():
|
||||||
|
return {"verdict": "pass", "reportPath": "", "residual": [], "deliveryVerdict": "safe-to-ship"}
|
||||||
|
report = safe_json_load(report_path)
|
||||||
|
if not report or not isinstance(report, dict):
|
||||||
|
return {"verdict": "pass", "reportPath": str(report_path), "residual": [], "deliveryVerdict": "safe-to-ship"}
|
||||||
|
return {
|
||||||
|
"verdict": report.get("verdict", "pass"),
|
||||||
|
"residual": report.get("residual", []),
|
||||||
|
"reportPath": str(report_path),
|
||||||
|
"summary": report.get("summary", ""),
|
||||||
|
"deliveryVerdict": report.get("highRiskAudit", {}).get("deliveryVerdict", "safe-to-ship"), # P1-19.2
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _report_to_dict(report: ReviewReport) -> dict:
|
||||||
|
# P1-19.2: highRiskAudit 序列化
|
||||||
|
hra = report.high_risk_audit
|
||||||
|
high_risk_audit_dict = {
|
||||||
|
"lifecycle": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.lifecycle],
|
||||||
|
"nullPointer": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.nullPointer],
|
||||||
|
"danglingPointer": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.danglingPointer],
|
||||||
|
"exceptionSafety": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.exceptionSafety],
|
||||||
|
"concurrency": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.concurrency],
|
||||||
|
"overallRisk": hra.overallRisk,
|
||||||
|
"deliveryVerdict": hra.deliveryVerdict,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"taskId": report.task_id,
|
||||||
|
"verdict": report.verdict,
|
||||||
|
"coverage": [c.__dict__ for c in report.coverage],
|
||||||
|
"intentAlignment": report.intent_alignment,
|
||||||
|
"divergenceNotes": report.divergence_notes,
|
||||||
|
"regressionRisk": report.regression_risk,
|
||||||
|
"codeQuality": report.code_quality,
|
||||||
|
"lifecycleHealth": report.lifecycle_health,
|
||||||
|
"runtimeStability": report.runtime_stability,
|
||||||
|
"codeToDesignTable": [c.__dict__ for c in report.code_to_design_table],
|
||||||
|
"loggingChecks": report.logging_checks,
|
||||||
|
"highRiskAudit": high_risk_audit_dict,
|
||||||
|
"recommendations": report.recommendations,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dict_to_report(data: dict) -> ReviewReport:
|
||||||
|
# P1-19.2: highRiskAudit 反序列化
|
||||||
|
hra_data = data.get("highRiskAudit", {})
|
||||||
|
high_risk_audit = HighRiskAudit(
|
||||||
|
lifecycle=[HighRiskFinding(**f) for f in hra_data.get("lifecycle", [])],
|
||||||
|
nullPointer=[HighRiskFinding(**f) for f in hra_data.get("nullPointer", [])],
|
||||||
|
danglingPointer=[HighRiskFinding(**f) for f in hra_data.get("danglingPointer", [])],
|
||||||
|
exceptionSafety=[HighRiskFinding(**f) for f in hra_data.get("exceptionSafety", [])],
|
||||||
|
concurrency=[HighRiskFinding(**f) for f in hra_data.get("concurrency", [])],
|
||||||
|
overallRisk=hra_data.get("overallRisk", "low"),
|
||||||
|
deliveryVerdict=hra_data.get("deliveryVerdict", "safe-to-ship"),
|
||||||
|
)
|
||||||
|
return ReviewReport(
|
||||||
|
task_id=data.get("taskId", ""),
|
||||||
|
verdict=data.get("verdict", "fail"),
|
||||||
|
coverage=[RequirementCoverage(**c) for c in data.get("coverage", [])],
|
||||||
|
intent_alignment=data.get("intentAlignment", "aligned"),
|
||||||
|
divergence_notes=data.get("divergenceNotes", ""),
|
||||||
|
regression_risk=data.get("regressionRisk", "none"),
|
||||||
|
code_quality=data.get("codeQuality", {}),
|
||||||
|
lifecycle_health=data.get("lifecycleHealth", {}),
|
||||||
|
runtime_stability=data.get("runtimeStability", {}),
|
||||||
|
code_to_design_table=[CodeToDesignItem(**c) for c in data.get("codeToDesignTable", [])],
|
||||||
|
logging_checks=data.get("loggingChecks", {}),
|
||||||
|
high_risk_audit=high_risk_audit,
|
||||||
|
recommendations=data.get("recommendations", []),
|
||||||
|
)
|
||||||
527
lib/air_runtime/sdb_backends.py
Normal file
527
lib/air_runtime/sdb_backends.py
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
"""AirSDB backends — 5 static analyzer backends + AnalysisDiff.
|
||||||
|
|
||||||
|
Backends:
|
||||||
|
CppcheckBackend — C/C++ via cppcheck
|
||||||
|
ClangTidyBackend — C/C++ via clang-tidy
|
||||||
|
RustClippyBackend — Rust via cargo clippy
|
||||||
|
GoVetBackend — Go via go vet + staticcheck
|
||||||
|
TypeScriptBackend — TypeScript via tsc --noEmit
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unified result type
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AnalysisResult:
|
||||||
|
tool: str
|
||||||
|
file: str
|
||||||
|
line: int | None
|
||||||
|
column: int | None
|
||||||
|
severity: str # error | warning | info
|
||||||
|
message: str
|
||||||
|
rule_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Abstract base
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class StaticAnalyzerBackend(ABC):
|
||||||
|
"""Abstract base for every static-analysis backend."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||||
|
"""Run the analyzer and return structured findings."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Short identifier for this backend (e.g. 'cppcheck')."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def install_hint(self) -> str:
|
||||||
|
"""Human-readable hint shown when the tool is not installed."""
|
||||||
|
|
||||||
|
# -- helpers available to all backends ---------------------------------
|
||||||
|
|
||||||
|
def _check_tool(self, tool_cmd: str) -> None:
|
||||||
|
"""Raise RuntimeError if *tool_cmd* is not on PATH."""
|
||||||
|
if not shutil.which(tool_cmd):
|
||||||
|
raise RuntimeError(self.install_hint)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run(cmd: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess:
|
||||||
|
"""Run *cmd* and capture stdout/stderr. Returns CompletedProcess."""
|
||||||
|
return subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CppcheckBackend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class CppcheckBackend(StaticAnalyzerBackend):
|
||||||
|
"""C/C++ static analysis via cppcheck."""
|
||||||
|
|
||||||
|
name = "cppcheck"
|
||||||
|
install_hint = (
|
||||||
|
"cppcheck is not installed. "
|
||||||
|
"Install it with: sudo apt install cppcheck (Debian/Ubuntu) "
|
||||||
|
"or: brew install cppcheck (macOS)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Template: file:line:column:severity:id:message
|
||||||
|
_TEMPLATE = "{file}:{line}:{column}:{severity}:{id}:{message}"
|
||||||
|
|
||||||
|
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||||
|
self._check_tool("cppcheck")
|
||||||
|
|
||||||
|
src = str(target) if target else str(project_root)
|
||||||
|
cmd = [
|
||||||
|
"cppcheck",
|
||||||
|
"--quiet",
|
||||||
|
f"--template={self._TEMPLATE}",
|
||||||
|
"--force",
|
||||||
|
src,
|
||||||
|
]
|
||||||
|
proc = self._run(cmd, cwd=project_root)
|
||||||
|
|
||||||
|
results: list[AnalysisResult] = []
|
||||||
|
for line in proc.stderr.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split(":", 5)
|
||||||
|
if len(parts) < 6:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ln = int(parts[1]) if parts[1].strip() else None
|
||||||
|
except ValueError:
|
||||||
|
ln = None
|
||||||
|
try:
|
||||||
|
col = int(parts[2]) if parts[2].strip() else None
|
||||||
|
except ValueError:
|
||||||
|
col = None
|
||||||
|
|
||||||
|
severity = parts[3].strip()
|
||||||
|
# Map cppcheck severities to our unified set
|
||||||
|
if severity not in ("error", "warning", "info"):
|
||||||
|
if severity in ("performance", "portability", "style"):
|
||||||
|
severity = "warning"
|
||||||
|
else:
|
||||||
|
severity = "info"
|
||||||
|
|
||||||
|
results.append(AnalysisResult(
|
||||||
|
tool=self.name,
|
||||||
|
file=parts[0].strip(),
|
||||||
|
line=ln,
|
||||||
|
column=col,
|
||||||
|
severity=severity,
|
||||||
|
message=parts[5].strip(),
|
||||||
|
rule_id=parts[4].strip() or None,
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ClangTidyBackend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ClangTidyBackend(StaticAnalyzerBackend):
|
||||||
|
"""C/C++ static analysis via clang-tidy."""
|
||||||
|
|
||||||
|
name = "clang-tidy"
|
||||||
|
install_hint = (
|
||||||
|
"clang-tidy is not installed. "
|
||||||
|
"Install it with: sudo apt install clang-tidy (Debian/Ubuntu) "
|
||||||
|
"or: brew install llvm (macOS, then use llvm/bin/clang-tidy)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||||
|
self._check_tool("clang-tidy")
|
||||||
|
|
||||||
|
src = str(target) if target else str(project_root)
|
||||||
|
cmd = [
|
||||||
|
"clang-tidy",
|
||||||
|
"--quiet",
|
||||||
|
src,
|
||||||
|
]
|
||||||
|
# Use compile_commands.json if present
|
||||||
|
comp_db = project_root / "compile_commands.json"
|
||||||
|
if comp_db.exists():
|
||||||
|
cmd.append(f"-p={comp_db.parent}")
|
||||||
|
|
||||||
|
proc = self._run(cmd, cwd=project_root)
|
||||||
|
|
||||||
|
results: list[AnalysisResult] = []
|
||||||
|
# clang-tidy output format: <file>:<line>:<col>: warning: <message> [check-name]
|
||||||
|
for line in proc.stderr.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split(":", 3)
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ln = int(parts[1].strip()) if parts[1].strip() else None
|
||||||
|
except ValueError:
|
||||||
|
ln = None
|
||||||
|
try:
|
||||||
|
col = int(parts[2].strip()) if parts[2].strip() else None
|
||||||
|
except ValueError:
|
||||||
|
col = None
|
||||||
|
|
||||||
|
msg_part = parts[3].strip()
|
||||||
|
severity = "warning"
|
||||||
|
# Detect "error:" prefix
|
||||||
|
if msg_part.startswith("error:"):
|
||||||
|
severity = "error"
|
||||||
|
msg_part = msg_part[len("error:"):].strip()
|
||||||
|
elif msg_part.startswith("warning:"):
|
||||||
|
msg_part = msg_part[len("warning:"):].strip()
|
||||||
|
elif msg_part.startswith("note:"):
|
||||||
|
severity = "info"
|
||||||
|
msg_part = msg_part[len("note:"):].strip()
|
||||||
|
|
||||||
|
# Extract [check-name] at the end
|
||||||
|
rule_id = None
|
||||||
|
if msg_part.endswith("]"):
|
||||||
|
bracket = msg_part.rfind("[")
|
||||||
|
if bracket != -1:
|
||||||
|
rule_id = msg_part[bracket + 1:-1].strip()
|
||||||
|
msg_part = msg_part[:bracket].strip()
|
||||||
|
|
||||||
|
results.append(AnalysisResult(
|
||||||
|
tool=self.name,
|
||||||
|
file=parts[0].strip(),
|
||||||
|
line=ln,
|
||||||
|
column=col,
|
||||||
|
severity=severity,
|
||||||
|
message=msg_part,
|
||||||
|
rule_id=rule_id,
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RustClippyBackend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RustClippyBackend(StaticAnalyzerBackend):
|
||||||
|
"""Rust static analysis via cargo clippy."""
|
||||||
|
|
||||||
|
name = "clippy"
|
||||||
|
install_hint = (
|
||||||
|
"cargo clippy is not available. "
|
||||||
|
"Install Rust toolchain: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh "
|
||||||
|
"then: rustup component add clippy"
|
||||||
|
)
|
||||||
|
|
||||||
|
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||||
|
self._check_tool("cargo")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"cargo", "clippy",
|
||||||
|
"--message-format=json",
|
||||||
|
]
|
||||||
|
# If a specific target file/dir is given, we still run cargo clippy
|
||||||
|
# on the whole crate (cargo does not support single-file analysis).
|
||||||
|
proc = self._run(cmd, cwd=project_root)
|
||||||
|
|
||||||
|
results: list[AnalysisResult] = []
|
||||||
|
for line in proc.stdout.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if obj.get("reason") != "compiler-message":
|
||||||
|
continue
|
||||||
|
msg = obj.get("message", {})
|
||||||
|
level = msg.get("level", "")
|
||||||
|
if level == "error":
|
||||||
|
severity = "error"
|
||||||
|
elif level in ("warning",):
|
||||||
|
severity = "warning"
|
||||||
|
else:
|
||||||
|
severity = "info"
|
||||||
|
|
||||||
|
for span in msg.get("spans", []):
|
||||||
|
results.append(AnalysisResult(
|
||||||
|
tool=self.name,
|
||||||
|
file=span.get("file_name", ""),
|
||||||
|
line=span.get("line_start"),
|
||||||
|
column=span.get("column_start"),
|
||||||
|
severity=severity,
|
||||||
|
message=msg.get("message", ""),
|
||||||
|
rule_id=msg.get("code", {}).get("code") or None,
|
||||||
|
))
|
||||||
|
|
||||||
|
# If no JSON output (e.g. compile error), also parse stderr
|
||||||
|
if not results and proc.stderr:
|
||||||
|
for line in proc.stderr.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if "error" in line.lower() and ":" in line:
|
||||||
|
results.append(AnalysisResult(
|
||||||
|
tool=self.name,
|
||||||
|
file=str(project_root),
|
||||||
|
line=None,
|
||||||
|
column=None,
|
||||||
|
severity="error",
|
||||||
|
message=line,
|
||||||
|
rule_id=None,
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GoVetBackend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class GoVetBackend(StaticAnalyzerBackend):
|
||||||
|
"""Go static analysis via go vet + staticcheck."""
|
||||||
|
|
||||||
|
name = "go-vet"
|
||||||
|
install_hint = (
|
||||||
|
"go is not installed. "
|
||||||
|
"Install Go: https://go.dev/dl/ "
|
||||||
|
"For staticcheck: go install honnef.co/go/tools/cmd/staticcheck@latest"
|
||||||
|
)
|
||||||
|
|
||||||
|
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||||
|
self._check_tool("go")
|
||||||
|
|
||||||
|
results: list[AnalysisResult] = []
|
||||||
|
|
||||||
|
# 1) go vet — JSON output
|
||||||
|
vet_cmd = ["go", "vet", "./..."]
|
||||||
|
proc = self._run(vet_cmd, cwd=project_root)
|
||||||
|
if proc.stderr:
|
||||||
|
results.extend(self._parse_go_vet_output(proc.stderr))
|
||||||
|
|
||||||
|
# 2) staticcheck (optional — don't fail if not installed)
|
||||||
|
if shutil.which("staticcheck"):
|
||||||
|
sc_cmd = ["staticcheck", "-f=json", "./..."]
|
||||||
|
sc_proc = self._run(sc_cmd, cwd=project_root)
|
||||||
|
for line in sc_proc.stdout.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
severity = "warning"
|
||||||
|
if obj.get("severity", "") == "error":
|
||||||
|
severity = "error"
|
||||||
|
results.append(AnalysisResult(
|
||||||
|
tool="staticcheck",
|
||||||
|
file=obj.get("location", {}).get("file", ""),
|
||||||
|
line=obj.get("location", {}).get("line"),
|
||||||
|
column=obj.get("location", {}).get("column"),
|
||||||
|
severity=severity,
|
||||||
|
message=obj.get("message", ""),
|
||||||
|
rule_id=obj.get("code", ""),
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_go_vet_output(text: str) -> list[AnalysisResult]:
|
||||||
|
"""Parse go vet stderr output.
|
||||||
|
|
||||||
|
go vet output format (non-JSON):
|
||||||
|
<file>:<line>: <message>
|
||||||
|
"""
|
||||||
|
results: list[AnalysisResult] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split(":", 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ln = int(parts[1].strip()) if parts[1].strip() else None
|
||||||
|
except ValueError:
|
||||||
|
ln = None
|
||||||
|
results.append(AnalysisResult(
|
||||||
|
tool="go vet",
|
||||||
|
file=parts[0].strip(),
|
||||||
|
line=ln,
|
||||||
|
column=None,
|
||||||
|
severity="warning",
|
||||||
|
message=parts[2].strip(),
|
||||||
|
rule_id=None,
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TypeScriptBackend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TypeScriptBackend(StaticAnalyzerBackend):
|
||||||
|
"""TypeScript static analysis via tsc --noEmit."""
|
||||||
|
|
||||||
|
name = "tsc"
|
||||||
|
install_hint = (
|
||||||
|
"tsc (TypeScript compiler) is not installed. "
|
||||||
|
"Install it with: npm install -g typescript "
|
||||||
|
"or add it to your project: npm install --save-dev typescript"
|
||||||
|
)
|
||||||
|
|
||||||
|
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
|
||||||
|
# tsc can be installed locally (npx) or globally
|
||||||
|
tsc_cmd = self._find_tsc()
|
||||||
|
if tsc_cmd is None:
|
||||||
|
raise RuntimeError(self.install_hint)
|
||||||
|
|
||||||
|
cmd = tsc_cmd + ["--noEmit", "--pretty", "false"]
|
||||||
|
proc = self._run(cmd, cwd=project_root)
|
||||||
|
|
||||||
|
results: list[AnalysisResult] = []
|
||||||
|
# tsc output format: <file>(<line>,<col>): error TS<code>: <message>
|
||||||
|
for line in proc.stdout.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
results.append(self._parse_tsc_line(line))
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _find_tsc(self) -> list[str] | None:
|
||||||
|
"""Return the tsc command as a list, or None if not found."""
|
||||||
|
if shutil.which("tsc"):
|
||||||
|
return ["tsc"]
|
||||||
|
if shutil.which("npx"):
|
||||||
|
return ["npx", "tsc"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_tsc_line(line: str) -> AnalysisResult:
|
||||||
|
"""Parse a single tsc diagnostic line.
|
||||||
|
|
||||||
|
Format: <file>(<line>,<col>): error TS1234: <message>
|
||||||
|
"""
|
||||||
|
severity = "error"
|
||||||
|
rule_id = None
|
||||||
|
|
||||||
|
# Split on the first colon-space after the position paren
|
||||||
|
# e.g. "src/foo.ts(10,5): error TS2322: Type 'string' ..."
|
||||||
|
main_parts = line.split(": ", 1)
|
||||||
|
location_part = main_parts[0] if main_parts else line
|
||||||
|
message = main_parts[1].strip() if len(main_parts) > 1 else ""
|
||||||
|
|
||||||
|
# Extract file, line, column from "file(line,col)"
|
||||||
|
file_part = location_part
|
||||||
|
ln = None
|
||||||
|
col = None
|
||||||
|
paren = location_part.rfind("(")
|
||||||
|
if paren != -1 and location_part.endswith(")"):
|
||||||
|
file_part = location_part[:paren]
|
||||||
|
pos_str = location_part[paren + 1:-1]
|
||||||
|
pos_parts = pos_str.split(",", 1)
|
||||||
|
try:
|
||||||
|
ln = int(pos_parts[0].strip()) if pos_parts[0].strip() else None
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if len(pos_parts) > 1:
|
||||||
|
try:
|
||||||
|
col = int(pos_parts[1].strip()) if pos_parts[1].strip() else None
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Extract severity + rule from " error TS2322" in the remainder
|
||||||
|
if len(main_parts) > 1:
|
||||||
|
# The part between the first colon-space and the message
|
||||||
|
# is in the original line — re-parse
|
||||||
|
rest = line[len(location_part) + 2:] # after ": "
|
||||||
|
if rest.startswith("error "):
|
||||||
|
severity = "error"
|
||||||
|
rest = rest[len("error "):]
|
||||||
|
elif rest.startswith("warning "):
|
||||||
|
severity = "warning"
|
||||||
|
rest = rest[len("warning "):]
|
||||||
|
# rest now starts with "TS1234: message"
|
||||||
|
ts_parts = rest.split(": ", 1)
|
||||||
|
if ts_parts:
|
||||||
|
rule_id = ts_parts[0].strip() or None
|
||||||
|
if len(ts_parts) > 1:
|
||||||
|
message = ts_parts[1].strip()
|
||||||
|
|
||||||
|
return AnalysisResult(
|
||||||
|
tool="tsc",
|
||||||
|
file=file_part,
|
||||||
|
line=ln,
|
||||||
|
column=col,
|
||||||
|
severity=severity,
|
||||||
|
message=message,
|
||||||
|
rule_id=rule_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AnalysisDiff
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AnalysisDiff:
|
||||||
|
"""Compare two lists of AnalysisResult and classify findings as
|
||||||
|
new, resolved, or unchanged."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _key(r: AnalysisResult) -> tuple[str, int | None, str | None]:
|
||||||
|
"""Dedup key: (file, line, rule_id)."""
|
||||||
|
return (r.file, r.line, r.rule_id)
|
||||||
|
|
||||||
|
def diff(
|
||||||
|
self,
|
||||||
|
before: list[AnalysisResult],
|
||||||
|
after: list[AnalysisResult],
|
||||||
|
) -> dict:
|
||||||
|
before_keys = {self._key(r): r for r in before}
|
||||||
|
after_keys = {self._key(r): r for r in after}
|
||||||
|
|
||||||
|
before_set = set(before_keys.keys())
|
||||||
|
after_set = set(after_keys.keys())
|
||||||
|
|
||||||
|
new_keys = after_set - before_set
|
||||||
|
resolved_keys = before_set - after_set
|
||||||
|
unchanged_keys = before_set & after_set
|
||||||
|
|
||||||
|
return {
|
||||||
|
"new": [after_keys[k] for k in new_keys],
|
||||||
|
"resolved": [before_keys[k] for k in resolved_keys],
|
||||||
|
"unchanged": [before_keys[k] for k in unchanged_keys],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Convenience registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BACKENDS: dict[str, StaticAnalyzerBackend] = {
|
||||||
|
"cppcheck": CppcheckBackend(),
|
||||||
|
"clang-tidy": ClangTidyBackend(),
|
||||||
|
"clippy": RustClippyBackend(),
|
||||||
|
"go-vet": GoVetBackend(),
|
||||||
|
"tsc": TypeScriptBackend(),
|
||||||
|
}
|
||||||
175
lib/air_runtime/sec_runtime.py
Normal file
175
lib/air_runtime/sec_runtime.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""
|
||||||
|
AirSec 安全扫描运行时 — V2 新增组件。
|
||||||
|
制品敏感数据扫描 + 自动脱敏 + 误报白名单 + 确认流程 + advisory/blocking 模式。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SECRET_PATTERNS: list[tuple[str, str]] = [
|
||||||
|
("api_key", r'(?:api[_-]?key|apikey)\s*[:=]\s*["\']?([A-Za-z0-9_\-]{16,})["\']?'),
|
||||||
|
("aws_key", r'AKIA[0-9A-Z]{16}'),
|
||||||
|
("private_key", r'-----BEGIN (?:RSA|EC|DSA|OPENSSH) PRIVATE KEY-----'),
|
||||||
|
("token", r'(?:token|secret|password)\s*[:=]\s*["\']?([^\s"\']{8,})["\']?'),
|
||||||
|
("jwt", r'eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+'),
|
||||||
|
("url_credential", r'https?://[^:@]+:([^@]+)@'),
|
||||||
|
]
|
||||||
|
|
||||||
|
ALLOWLIST_PATTERNS: list[str] = [
|
||||||
|
r'EXAMPLE',
|
||||||
|
r'example',
|
||||||
|
r'YOUR_API_KEY',
|
||||||
|
r'TODO',
|
||||||
|
r'<your-',
|
||||||
|
r'placeholder',
|
||||||
|
r'xxxxxxxx',
|
||||||
|
]
|
||||||
|
|
||||||
|
# 文件名白名单:命中则跳过该文件的所有发现
|
||||||
|
WHITELIST_FILE_PATTERNS: list[str] = [
|
||||||
|
r"\.example\.",
|
||||||
|
r"^test_",
|
||||||
|
r"^mock_",
|
||||||
|
r"_test\.py$",
|
||||||
|
r"\.fixture\.",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 用户确认记录(首次发现需确认,后续同模式自动放行)
|
||||||
|
USER_CONFIRMATIONS: dict[str, dict] = {} # {fingerprint: {pattern, confirmed_at, user}}
|
||||||
|
|
||||||
|
|
||||||
|
class ScanMode:
|
||||||
|
"""扫描模式:advisory(只报告,不阻止)/ blocking(阻止合并)"""
|
||||||
|
ADVISORY = "advisory"
|
||||||
|
BLOCKING = "blocking"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanFinding:
|
||||||
|
rule: str
|
||||||
|
file: str
|
||||||
|
line: int
|
||||||
|
match: str # 截断显示,不包含完整密钥
|
||||||
|
severity: str = "high" # high | medium | low
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanReport:
|
||||||
|
task_id: str
|
||||||
|
findings: list[ScanFinding] = field(default_factory=list)
|
||||||
|
whitelisted: int = 0
|
||||||
|
clean: bool = True
|
||||||
|
advisory_blocked: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def scan_file(file_path: Path, task_id: str = "") -> ScanReport:
|
||||||
|
findings: list[ScanFinding] = []
|
||||||
|
whitelisted = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
return ScanReport(task_id=task_id, clean=True)
|
||||||
|
|
||||||
|
for rule, pattern in SECRET_PATTERNS:
|
||||||
|
for match in re.finditer(pattern, content, re.IGNORECASE):
|
||||||
|
matched_text = match.group(0)
|
||||||
|
if any(re.search(ap, matched_text) for ap in ALLOWLIST_PATTERNS):
|
||||||
|
whitelisted += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
line_no = content[:match.start()].count("\n") + 1
|
||||||
|
display = matched_text[:60] + "..." if len(matched_text) > 60 else matched_text
|
||||||
|
findings.append(ScanFinding(
|
||||||
|
rule=rule, file=str(file_path), line=line_no, match=display,
|
||||||
|
))
|
||||||
|
|
||||||
|
return ScanReport(
|
||||||
|
task_id=task_id,
|
||||||
|
findings=findings,
|
||||||
|
whitelisted=whitelisted,
|
||||||
|
clean=len(findings) == 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_result_data(result: dict, task_id: str = "") -> ScanReport:
|
||||||
|
"""扫描 Worker result.json 中的敏感数据。"""
|
||||||
|
import json
|
||||||
|
text = json.dumps(result, ensure_ascii=False)
|
||||||
|
findings: list[ScanFinding] = []
|
||||||
|
whitelisted = 0
|
||||||
|
|
||||||
|
for rule, pattern in SECRET_PATTERNS:
|
||||||
|
for match in re.finditer(pattern, text, re.IGNORECASE):
|
||||||
|
matched_text = match.group(0)
|
||||||
|
if any(re.search(ap, matched_text) for ap in ALLOWLIST_PATTERNS):
|
||||||
|
whitelisted += 1
|
||||||
|
continue
|
||||||
|
display = matched_text[:60] + "..." if len(matched_text) > 60 else matched_text
|
||||||
|
findings.append(ScanFinding(
|
||||||
|
rule=rule, file="result.json", line=0, match=display,
|
||||||
|
))
|
||||||
|
|
||||||
|
return ScanReport(
|
||||||
|
task_id=task_id,
|
||||||
|
findings=findings,
|
||||||
|
whitelisted=whitelisted,
|
||||||
|
clean=len(findings) == 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_file_with_mode(
|
||||||
|
file_path: Path,
|
||||||
|
task_id: str = "",
|
||||||
|
mode: str = ScanMode.BLOCKING,
|
||||||
|
confirm_callback=None, # 可选:首次发现时调用此回调询问用户
|
||||||
|
) -> ScanReport:
|
||||||
|
"""
|
||||||
|
增强版扫描:
|
||||||
|
1. 基础扫描(已有逻辑)
|
||||||
|
2. 文件名白名单过滤
|
||||||
|
3. 模式判断(advisory vs blocking)
|
||||||
|
"""
|
||||||
|
report = scan_file(file_path, task_id) # 原有逻辑
|
||||||
|
|
||||||
|
# 文件名白名单过滤
|
||||||
|
filtered_findings = []
|
||||||
|
for f in report.findings:
|
||||||
|
filename = file_path.name
|
||||||
|
if any(re.search(p, filename) for p in WHITELIST_FILE_PATTERNS):
|
||||||
|
report.whitelisted += 1
|
||||||
|
continue
|
||||||
|
filtered_findings.append(f)
|
||||||
|
|
||||||
|
report.findings = filtered_findings
|
||||||
|
report.clean = len(filtered_findings) == 0
|
||||||
|
|
||||||
|
# 模式处理
|
||||||
|
if not report.clean and mode == ScanMode.ADVISORY:
|
||||||
|
# advisory 模式:只记录,不阻止
|
||||||
|
report.advisory_blocked = False
|
||||||
|
elif not report.clean and mode == ScanMode.BLOCKING:
|
||||||
|
# blocking 模式:默认阻止
|
||||||
|
report.advisory_blocked = True
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_pattern(task_id: str, pattern: str, user: str = "unknown") -> None:
|
||||||
|
"""用户确认某模式为安全后,记录下来"""
|
||||||
|
from air_runtime.utils import now_iso
|
||||||
|
fingerprint = f"{task_id}:{pattern}"
|
||||||
|
USER_CONFIRMATIONS[fingerprint] = {
|
||||||
|
"pattern": pattern,
|
||||||
|
"confirmed_at": now_iso(),
|
||||||
|
"user": user,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_confirmed(task_id: str, pattern: str) -> bool:
|
||||||
|
"""检查某模式是否已被用户确认"""
|
||||||
|
fingerprint = f"{task_id}:{pattern}"
|
||||||
|
return fingerprint in USER_CONFIRMATIONS
|
||||||
222
lib/air_runtime/task_graph.py
Normal file
222
lib/air_runtime/task_graph.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
动态任务依赖图(DAG)— V2 P1-14 修复。
|
||||||
|
替代 V1 静态 todo.md 表格,支持 Arc 增量重规划,Eng 增量吸收。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskNode:
|
||||||
|
id: str
|
||||||
|
status: str = "TODO" # TODO | DISPATCHED | DONE | BLOCKED
|
||||||
|
task: str = ""
|
||||||
|
files_dirs: str = ""
|
||||||
|
done_when: str = ""
|
||||||
|
in_degree: int = 0
|
||||||
|
out_edges: list[str] = field(default_factory=list)
|
||||||
|
write_set: list[str] = field(default_factory=list)
|
||||||
|
meta: dict[str, Any] = field(default_factory=dict)
|
||||||
|
test_required: bool = False # P1-19.1: 边界测试强制标记
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Edge:
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
kind: str = "dependency" # dependency | conflict | sync
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EdgeChange:
|
||||||
|
added: list[Edge] = field(default_factory=list)
|
||||||
|
removed: list[Edge] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlanDelta:
|
||||||
|
"""Arc 重规划产出的增量差异,替代全量覆盖 todo.md。"""
|
||||||
|
removed_tasks: list[str] = field(default_factory=list)
|
||||||
|
added_tasks: list[TaskNode] = field(default_factory=list)
|
||||||
|
modified_tasks: list[TaskNode] = field(default_factory=list)
|
||||||
|
edge_changes: EdgeChange = field(default_factory=EdgeChange)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskGraph:
|
||||||
|
"""动态任务依赖图,支持增量更新和全量替换。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.nodes: dict[str, TaskNode] = {}
|
||||||
|
self.edges: list[Edge] = []
|
||||||
|
|
||||||
|
def add_node(self, node: TaskNode) -> None:
|
||||||
|
self.nodes[node.id] = node
|
||||||
|
|
||||||
|
def add_edge(self, edge: Edge) -> None:
|
||||||
|
self.edges.append(edge)
|
||||||
|
if edge.target in self.nodes:
|
||||||
|
self.nodes[edge.target].in_degree += 1
|
||||||
|
if edge.source in self.nodes:
|
||||||
|
self.nodes[edge.source].out_edges.append(edge.target)
|
||||||
|
|
||||||
|
def apply_delta(self, delta: PlanDelta) -> None:
|
||||||
|
"""增量吸收 Arc 的重规划结果,保留已调度任务不受影响。"""
|
||||||
|
for task_id in delta.removed_tasks:
|
||||||
|
self._remove_node(task_id)
|
||||||
|
for node in delta.added_tasks:
|
||||||
|
self._add_node(node)
|
||||||
|
for node in delta.modified_tasks:
|
||||||
|
self._update_node(node)
|
||||||
|
for edge in delta.edge_changes.removed:
|
||||||
|
self._remove_edge(edge)
|
||||||
|
for edge in delta.edge_changes.added:
|
||||||
|
self._add_edge(edge)
|
||||||
|
|
||||||
|
def apply_full_replace(self, nodes: list[TaskNode], edges: list[Edge]) -> None:
|
||||||
|
"""全量替换模式:Arc 产出完整 DAG,保留已完成任务状态。"""
|
||||||
|
done_status = {tid: n.status for tid, n in self.nodes.items() if n.status == "DONE"}
|
||||||
|
self.nodes = {n.id: n for n in nodes}
|
||||||
|
self.edges = list(edges)
|
||||||
|
for tid, status in done_status.items():
|
||||||
|
if tid in self.nodes:
|
||||||
|
self.nodes[tid].status = status
|
||||||
|
for edge in self.edges:
|
||||||
|
if edge.target in self.nodes:
|
||||||
|
self.nodes[edge.target].in_degree += 1
|
||||||
|
if edge.source in self.nodes:
|
||||||
|
self.nodes[edge.source].out_edges.append(edge.target)
|
||||||
|
|
||||||
|
def ready_tasks(self) -> list[str]:
|
||||||
|
"""返回当前入度为 0 且状态为 TODO 的任务。"""
|
||||||
|
return [nid for nid, n in self.nodes.items() if n.in_degree == 0 and n.status == "TODO"]
|
||||||
|
|
||||||
|
def diff(self, other: TaskGraph) -> PlanDelta:
|
||||||
|
"""对比自身与 other,产出 PlanDelta(add/remove/modify node + edge changes)。
|
||||||
|
|
||||||
|
self = 新图, other = 旧图(before replan)。
|
||||||
|
"""
|
||||||
|
delta = PlanDelta()
|
||||||
|
old_ids = set(other.nodes.keys())
|
||||||
|
new_ids = set(self.nodes.keys())
|
||||||
|
|
||||||
|
# 移除
|
||||||
|
delta.removed_tasks = list(old_ids - new_ids)
|
||||||
|
|
||||||
|
# 新增
|
||||||
|
delta.added_tasks = [self.nodes[tid] for tid in (new_ids - old_ids)]
|
||||||
|
|
||||||
|
# 修改
|
||||||
|
for tid in old_ids & new_ids:
|
||||||
|
old_n = other.nodes[tid]
|
||||||
|
new_n = self.nodes[tid]
|
||||||
|
if (old_n.task != new_n.task
|
||||||
|
or old_n.files_dirs != new_n.files_dirs
|
||||||
|
or old_n.done_when != new_n.done_when
|
||||||
|
or old_n.write_set != new_n.write_set):
|
||||||
|
delta.modified_tasks.append(new_n)
|
||||||
|
|
||||||
|
# Edge 差异
|
||||||
|
old_edges = {(e.source, e.target, e.kind) for e in other.edges}
|
||||||
|
new_edges = {(e.source, e.target, e.kind) for e in self.edges}
|
||||||
|
for s, t, k in (new_edges - old_edges):
|
||||||
|
delta.edge_changes.added.append(Edge(source=s, target=t, kind=k))
|
||||||
|
for s, t, k in (old_edges - new_edges):
|
||||||
|
delta.edge_changes.removed.append(Edge(source=s, target=t, kind=k))
|
||||||
|
|
||||||
|
return delta
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path) -> TaskGraph:
|
||||||
|
"""从 _export_task_graph_json 写的格式还原 TaskGraph。"""
|
||||||
|
from pathlib import Path
|
||||||
|
from air_runtime.io import safe_json_load
|
||||||
|
p = Path(path)
|
||||||
|
data = safe_json_load(p)
|
||||||
|
graph = cls()
|
||||||
|
if not data or not isinstance(data, dict):
|
||||||
|
return graph
|
||||||
|
for nid, nd in data.get("nodes", {}).items():
|
||||||
|
graph.nodes[nid] = TaskNode(
|
||||||
|
id=nd.get("id", nid),
|
||||||
|
status=nd.get("status", "TODO"),
|
||||||
|
task=nd.get("task", ""),
|
||||||
|
files_dirs=nd.get("filesDirs", ""),
|
||||||
|
done_when=nd.get("doneWhen", ""),
|
||||||
|
in_degree=nd.get("inDegree", 0),
|
||||||
|
out_edges=list(nd.get("outEdges", [])),
|
||||||
|
write_set=list(nd.get("writeSet", [])),
|
||||||
|
test_required=nd.get("testRequired", False),
|
||||||
|
)
|
||||||
|
for ed in data.get("edges", []):
|
||||||
|
graph.edges.append(Edge(
|
||||||
|
source=ed["source"], target=ed["target"],
|
||||||
|
kind=ed.get("kind", "dependency"),
|
||||||
|
))
|
||||||
|
return graph
|
||||||
|
|
||||||
|
def task_ids_by_status(self, status: str) -> list[str]:
|
||||||
|
return [nid for nid, n in self.nodes.items() if n.status == status]
|
||||||
|
|
||||||
|
def find_cycles(self) -> list[list[str]]:
|
||||||
|
"""检测依赖环(DFS)。"""
|
||||||
|
visited: set[str] = set()
|
||||||
|
rec_stack: set[str] = set()
|
||||||
|
cycles: list[list[str]] = []
|
||||||
|
|
||||||
|
def dfs(node_id: str, path: list[str]) -> None:
|
||||||
|
visited.add(node_id)
|
||||||
|
rec_stack.add(node_id)
|
||||||
|
path.append(node_id)
|
||||||
|
for target in self.nodes.get(node_id, TaskNode(id=node_id)).out_edges:
|
||||||
|
if target not in visited:
|
||||||
|
dfs(target, path.copy())
|
||||||
|
elif target in rec_stack:
|
||||||
|
cycle_start = path.index(target)
|
||||||
|
cycles.append(path[cycle_start:])
|
||||||
|
rec_stack.discard(node_id)
|
||||||
|
|
||||||
|
for nid in self.nodes:
|
||||||
|
if nid not in visited:
|
||||||
|
dfs(nid, [])
|
||||||
|
|
||||||
|
return cycles
|
||||||
|
|
||||||
|
def export_todo_md(self) -> str:
|
||||||
|
"""导出为人可读的 todo.md 表格,保留 V1 的可见性优势。"""
|
||||||
|
lines = ["| Task | Status | Files/Dirs | Done When | Validation | ADR |",
|
||||||
|
"|------|--------|------------|-----------|------------|-----|"]
|
||||||
|
for nid, node in self.nodes.items():
|
||||||
|
lines.append(f"| {node.task} | {node.status} | {node.files_dirs} | "
|
||||||
|
f"{node.done_when} | | |")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
def _remove_node(self, task_id: str) -> None:
|
||||||
|
if task_id in self.nodes:
|
||||||
|
del self.nodes[task_id]
|
||||||
|
self.edges = [e for e in self.edges if e.source != task_id and e.target != task_id]
|
||||||
|
|
||||||
|
def _add_node(self, node: TaskNode) -> None:
|
||||||
|
self.nodes[node.id] = node
|
||||||
|
|
||||||
|
def _update_node(self, node: TaskNode) -> None:
|
||||||
|
if node.id in self.nodes:
|
||||||
|
existing_status = self.nodes[node.id].status
|
||||||
|
self.nodes[node.id] = node
|
||||||
|
if existing_status in ("DISPATCHED", "DONE"):
|
||||||
|
self.nodes[node.id].status = existing_status
|
||||||
|
|
||||||
|
def _remove_edge(self, edge: Edge) -> None:
|
||||||
|
self.edges = [e for e in self.edges
|
||||||
|
if not (e.source == edge.source and e.target == edge.target)]
|
||||||
|
if edge.target in self.nodes:
|
||||||
|
self.nodes[edge.target].in_degree = max(0, self.nodes[edge.target].in_degree - 1)
|
||||||
|
|
||||||
|
def _add_edge(self, edge: Edge) -> None:
|
||||||
|
self.edges.append(edge)
|
||||||
|
if edge.target in self.nodes:
|
||||||
|
self.nodes[edge.target].in_degree += 1
|
||||||
|
if edge.source in self.nodes:
|
||||||
|
self.nodes[edge.source].out_edges.append(edge.target)
|
||||||
183
lib/air_runtime/test_runtime.py
Normal file
183
lib/air_runtime/test_runtime.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
AirTst 测试运行器运行时 — V2 新增组件。
|
||||||
|
统一测试执行接口,支持多框架,产出结构化结果。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from air_runtime.io import atomic_json_write
|
||||||
|
from air_runtime.paths import tst_state_path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TestCase:
|
||||||
|
name: str
|
||||||
|
status: str # passed | failed | skipped
|
||||||
|
duration: str = ""
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TestSuite:
|
||||||
|
name: str
|
||||||
|
total: int = 0
|
||||||
|
passed: int = 0
|
||||||
|
failed: int = 0
|
||||||
|
skipped: int = 0
|
||||||
|
cases: list[TestCase] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TestRunResult:
|
||||||
|
framework: str
|
||||||
|
total: int = 0
|
||||||
|
passed: int = 0
|
||||||
|
failed: int = 0
|
||||||
|
disabled: int = 0
|
||||||
|
duration: str = ""
|
||||||
|
suites: list[TestSuite] = field(default_factory=list)
|
||||||
|
failures: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunner:
|
||||||
|
"""统一测试执行器。"""
|
||||||
|
|
||||||
|
FRAMEWORKS = {
|
||||||
|
"pytest": ["python", "-m", "pytest", "--json-report", "-q"],
|
||||||
|
"googletest": ["ctest", "--output-on-failure"],
|
||||||
|
"jest": ["npx", "jest", "--json"],
|
||||||
|
"vitest": ["npx", "vitest", "run", "--reporter=json"],
|
||||||
|
"go": ["go", "test", "-json", "./..."],
|
||||||
|
"cargo": ["cargo", "test", "--", "--format=json"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def run(self, task_id: str, project_root: Path, framework: str,
|
||||||
|
target_path: Path | None = None,
|
||||||
|
extra_args: list[str] | None = None) -> TestRunResult:
|
||||||
|
if framework not in self.FRAMEWORKS:
|
||||||
|
return TestRunResult(framework=framework, failures=[{"error": f"unsupported framework: {framework}"}])
|
||||||
|
|
||||||
|
cmd = list(self.FRAMEWORKS[framework])
|
||||||
|
if target_path:
|
||||||
|
cmd.append(str(target_path))
|
||||||
|
if extra_args:
|
||||||
|
cmd.extend(extra_args)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True,
|
||||||
|
timeout=600, cwd=str(project_root))
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return TestRunResult(framework=framework, failures=[{"error": "timeout"}])
|
||||||
|
|
||||||
|
run_result = self._parse_result(framework, result.stdout)
|
||||||
|
self._save_report(task_id, project_root, run_result)
|
||||||
|
return run_result
|
||||||
|
|
||||||
|
def _parse_result(self, framework: str, stdout: str) -> TestRunResult:
|
||||||
|
if framework == "pytest":
|
||||||
|
return self._parse_pytest(stdout)
|
||||||
|
if framework in ("jest", "vitest"):
|
||||||
|
return self._parse_jest(stdout)
|
||||||
|
if framework == "googletest":
|
||||||
|
return self._parse_googletest(stdout)
|
||||||
|
if framework == "go":
|
||||||
|
return self._parse_go(stdout)
|
||||||
|
if framework == "cargo":
|
||||||
|
return self._parse_cargo(stdout)
|
||||||
|
return TestRunResult(framework=framework, total=0)
|
||||||
|
|
||||||
|
def _parse_pytest(self, stdout: str) -> TestRunResult:
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return TestRunResult(framework="pytest", failures=[{"error": "json parse failed"}])
|
||||||
|
return TestRunResult(
|
||||||
|
framework="pytest",
|
||||||
|
total=data.get("summary", {}).get("total", 0),
|
||||||
|
passed=data.get("summary", {}).get("passed", 0),
|
||||||
|
failed=data.get("summary", {}).get("failed", 0),
|
||||||
|
duration=str(data.get("duration", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_jest(self, stdout: str) -> TestRunResult:
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return TestRunResult(framework="jest", failures=[{"error": "json parse failed"}])
|
||||||
|
return TestRunResult(
|
||||||
|
framework="jest",
|
||||||
|
total=data.get("numTotalTests", 0),
|
||||||
|
passed=data.get("numPassedTests", 0),
|
||||||
|
failed=data.get("numFailedTests", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_googletest(self, stdout: str) -> TestRunResult:
|
||||||
|
"""解析 ctest 输出。ctest 不输出 JSON,从文本提取统计。"""
|
||||||
|
import re
|
||||||
|
total = passed = failed = disabled = 0
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
m = re.match(r"(\d+)% tests passed, (\d+) tests failed out of (\d+)", line)
|
||||||
|
if m:
|
||||||
|
failed = int(m.group(2))
|
||||||
|
total = int(m.group(3))
|
||||||
|
passed = total - failed
|
||||||
|
# GoogleTest 也支持 --gtest_output=json
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
total = sum(s.get("tests", 0) for s in data.get("testsuites", []))
|
||||||
|
failed = sum(s.get("failures", 0) for s in data.get("testsuites", []))
|
||||||
|
passed = total - failed
|
||||||
|
disabled = sum(s.get("disabled", 0) for s in data.get("testsuites", []))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return TestRunResult(
|
||||||
|
framework="googletest", total=total, passed=passed, failed=failed, disabled=disabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_go(self, stdout: str) -> TestRunResult:
|
||||||
|
"""解析 go test -json 输出(JSONL 格式,每行一个事件)。"""
|
||||||
|
total = passed = failed = 0
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
try:
|
||||||
|
ev = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
action = ev.get("Action", "")
|
||||||
|
if action == "pass":
|
||||||
|
passed += 1
|
||||||
|
total += 1
|
||||||
|
elif action == "fail":
|
||||||
|
failed += 1
|
||||||
|
total += 1
|
||||||
|
elif action == "skip":
|
||||||
|
total += 1
|
||||||
|
return TestRunResult(framework="go", total=total, passed=passed, failed=failed)
|
||||||
|
|
||||||
|
def _parse_cargo(self, stdout: str) -> TestRunResult:
|
||||||
|
"""解析 cargo test --format=json 输出(JSONL 格式)。"""
|
||||||
|
total = passed = failed = 0
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
try:
|
||||||
|
ev = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if ev.get("type") == "test":
|
||||||
|
total += 1
|
||||||
|
if ev.get("event") == "ok":
|
||||||
|
passed += 1
|
||||||
|
elif ev.get("event") == "failed":
|
||||||
|
failed += 1
|
||||||
|
return TestRunResult(framework="cargo", total=total, passed=passed, failed=failed)
|
||||||
|
|
||||||
|
def _save_report(self, task_id: str, project_root: Path, result: TestRunResult) -> None:
|
||||||
|
report_dir = tst_state_path(project_root).parent / "reports"
|
||||||
|
report_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
from air_runtime.utils import session_stamp
|
||||||
|
report_path = report_dir / f"{task_id}-{session_stamp()}.json"
|
||||||
|
atomic_json_write(report_path, result.__dict__)
|
||||||
96
lib/air_runtime/todo_parser.py
Normal file
96
lib/air_runtime/todo_parser.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
TODO 解析器 — V2 修复 P1-8:列索引从表头推导,不再硬编码 cells[1]/cells[6]/cells[7]。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TodoTask:
|
||||||
|
task_id: str
|
||||||
|
task: str
|
||||||
|
files_dirs: str = ""
|
||||||
|
status: str = "TODO"
|
||||||
|
done_when: str = ""
|
||||||
|
validations: str = ""
|
||||||
|
adr: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tasks(todo_path: Path) -> list[TodoTask]:
|
||||||
|
"""从 todo.md 解析任务列表,动态检测列索引。"""
|
||||||
|
if not todo_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
content = todo_path.read_text(encoding="utf-8")
|
||||||
|
lines = [l.strip() for l in content.splitlines() if l.strip()]
|
||||||
|
|
||||||
|
# 找到 Markdown 表格头
|
||||||
|
header_idx = -1
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.startswith("|") and "Task" in line and "Status" in line:
|
||||||
|
header_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_idx < 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 解析列名
|
||||||
|
header_line = lines[header_idx]
|
||||||
|
header_cols = [c.strip() for c in header_line.split("|") if c.strip()]
|
||||||
|
|
||||||
|
# 建立列名 → 索引映射
|
||||||
|
col_map = {}
|
||||||
|
for idx, col_name in enumerate(header_cols):
|
||||||
|
col_name_lower = col_name.lower()
|
||||||
|
if "task" in col_name_lower:
|
||||||
|
col_map["task"] = idx
|
||||||
|
elif "status" in col_name_lower:
|
||||||
|
col_map["status"] = idx
|
||||||
|
elif "files" in col_name_lower or "dir" in col_name_lower:
|
||||||
|
col_map["files_dirs"] = idx
|
||||||
|
elif "done" in col_name_lower or "when" in col_name_lower:
|
||||||
|
col_map["done_when"] = idx
|
||||||
|
elif "valid" in col_name_lower:
|
||||||
|
col_map["validations"] = idx
|
||||||
|
elif "adr" in col_name_lower:
|
||||||
|
col_map["adr"] = idx
|
||||||
|
|
||||||
|
# 跳过表头和分隔符
|
||||||
|
tasks = []
|
||||||
|
for line in lines[header_idx + 2:]:
|
||||||
|
if not line.startswith("|"):
|
||||||
|
continue
|
||||||
|
cells = [c.strip() for c in line.split("|") if len(c.strip()) > 0]
|
||||||
|
if not cells:
|
||||||
|
continue
|
||||||
|
|
||||||
|
task_cell = cells[col_map.get("task", 0)] if col_map.get("task", 0) < len(cells) else ""
|
||||||
|
# 提取 [T-xxx] 格式的任务 ID
|
||||||
|
tid_match = re.match(r"\[([A-Za-z0-9_\-\.]+)\]", task_cell)
|
||||||
|
task_id = tid_match.group(1) if tid_match else task_cell
|
||||||
|
task = task_cell
|
||||||
|
status = cells[col_map.get("status", 1)] if col_map.get("status", 1) < len(cells) else "TODO"
|
||||||
|
files_dirs = cells[col_map.get("files_dirs", 2)] if col_map.get("files_dirs", 2) < len(cells) else ""
|
||||||
|
done_when = cells[col_map.get("done_when", 3)] if col_map.get("done_when", 3) < len(cells) else ""
|
||||||
|
validations = cells[col_map.get("validations", 4)] if col_map.get("validations", 4) < len(cells) else ""
|
||||||
|
adr = cells[col_map.get("adr", 5)] if col_map.get("adr", 5) < len(cells) else ""
|
||||||
|
|
||||||
|
# 清理标记
|
||||||
|
task_id = re.sub(r"^\[|\]$", "", task_id).strip()
|
||||||
|
|
||||||
|
if task_id and task_id != "---":
|
||||||
|
tasks.append(TodoTask(
|
||||||
|
task_id=task_id,
|
||||||
|
task=task,
|
||||||
|
files_dirs=files_dirs,
|
||||||
|
status=status.upper() if status else "TODO",
|
||||||
|
done_when=done_when,
|
||||||
|
validations=validations,
|
||||||
|
adr=adr,
|
||||||
|
))
|
||||||
|
|
||||||
|
return tasks
|
||||||
65
lib/air_runtime/utils.py
Normal file
65
lib/air_runtime/utils.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
公共工具函数 — 消除 V1 中 _ordered_unique、_session_stamp、policy normalization 等
|
||||||
|
在各模块中 3~5 份重复定义的代码。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_unique(items: list) -> list:
|
||||||
|
"""保序去重。支持字符串列表和带 id 字段的字典列表。"""
|
||||||
|
seen: set[str] = set()
|
||||||
|
result = []
|
||||||
|
for item in items:
|
||||||
|
key = item if isinstance(item, str) else item.get("id", str(item))
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def session_stamp() -> str:
|
||||||
|
"""统一的文件系统安全时间戳,所有模块共用。"""
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace(":", "-").replace(".", "-").replace("+", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
"""ISO 格式 UTC 时间戳,用于 JSON state 文件。"""
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_policy(defaults: dict[str, Any], overrides: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""通用的策略合并:overrides 覆盖 defaults,类型自动转换。"""
|
||||||
|
merged = {**defaults}
|
||||||
|
if overrides:
|
||||||
|
for k, v in overrides.items():
|
||||||
|
if k in merged:
|
||||||
|
expected_type = type(defaults[k])
|
||||||
|
try:
|
||||||
|
merged[k] = expected_type(v) if not isinstance(v, expected_type) else v
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
merged[k] = v
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_task_id(task_id: str) -> str:
|
||||||
|
"""防止路径注入:仅允许字母数字、下划线、连字符、点号。"""
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_\-\.]+", task_id):
|
||||||
|
raise ValueError(f"invalid task_id: {task_id!r}")
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_marker(marker: str) -> str:
|
||||||
|
"""防止 HTML 注释注入。"""
|
||||||
|
if "-->" in marker or "<!--" in marker:
|
||||||
|
raise ValueError(f"marker contains comment delimiters: {marker!r}")
|
||||||
|
return marker
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_history(data: list, max_items: int = 100) -> list:
|
||||||
|
"""截断历史列表,防止无界增长(P2-2)。"""
|
||||||
|
return data[-max_items:] if len(data) > max_items else data
|
||||||
157
lib/air_runtime/worktree.py
Normal file
157
lib/air_runtime/worktree.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
Worktree 隔离并行模块 — V2 P1-15 修复。
|
||||||
|
区域级冲突检测 + git worktree 隔离,允许同文件不同区域任务安全并行。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictLevel(enum.Enum):
|
||||||
|
NONE = "none" # 无文件重叠,直接并行
|
||||||
|
SOFT = "soft" # 同文件不同区域,worktree 隔离并行
|
||||||
|
HARD = "hard" # 同文件同区域,必须串行
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Conflict:
|
||||||
|
task_a: str
|
||||||
|
task_b: str
|
||||||
|
level: ConflictLevel # NONE / SOFT / HARD
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"taskA": self.task_a,
|
||||||
|
"taskB": self.task_b,
|
||||||
|
"level": self.level.value,
|
||||||
|
"reason": self.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MergeResult:
|
||||||
|
task_id: str
|
||||||
|
successful: bool
|
||||||
|
conflicts: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class RegionConflictDetector:
|
||||||
|
"""区域级写集冲突检测。
|
||||||
|
|
||||||
|
区域定义(按优先级):
|
||||||
|
1. todo.md 中的区域标注 [region: xxx]
|
||||||
|
2. 函数/类边界(AST 分析或标记注释)
|
||||||
|
3. 行号区间(从 diff 或任务元数据获取)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def detect(self, task_a_write_set: list[str], task_b_write_set: list[str],
|
||||||
|
regions_a: dict[str, list[tuple[int, int]]] | None = None,
|
||||||
|
regions_b: dict[str, list[tuple[int, int]]] | None = None) -> ConflictLevel:
|
||||||
|
file_overlap = set(task_a_write_set) & set(task_b_write_set)
|
||||||
|
if not file_overlap:
|
||||||
|
return ConflictLevel.NONE
|
||||||
|
|
||||||
|
if regions_a is None or regions_b is None:
|
||||||
|
return ConflictLevel.SOFT
|
||||||
|
|
||||||
|
for fpath in file_overlap:
|
||||||
|
ra = regions_a.get(fpath, [(0, 999999)])
|
||||||
|
rb = regions_b.get(fpath, [(0, 999999)])
|
||||||
|
for a_start, a_end in ra:
|
||||||
|
for b_start, b_end in rb:
|
||||||
|
if a_start < b_end and b_start < a_end:
|
||||||
|
return ConflictLevel.HARD
|
||||||
|
return ConflictLevel.SOFT
|
||||||
|
|
||||||
|
def detect_batch(self, task_write_sets: dict[str, list[str]],
|
||||||
|
task_regions: dict[str, dict[str, list[tuple[int, int]]]] | None = None,
|
||||||
|
) -> list[Conflict]:
|
||||||
|
"""批量检测:接收 {task_id: [file, ...]} 映射,返回所有冲突对。
|
||||||
|
|
||||||
|
逐对调用 detect(),仅返回 level != NONE 的冲突。
|
||||||
|
"""
|
||||||
|
conflicts: list[Conflict] = []
|
||||||
|
task_ids = list(task_write_sets.keys())
|
||||||
|
for i in range(len(task_ids)):
|
||||||
|
for j in range(i + 1, len(task_ids)):
|
||||||
|
a, b = task_ids[i], task_ids[j]
|
||||||
|
ra = task_regions.get(a) if task_regions else None
|
||||||
|
rb = task_regions.get(b) if task_regions else None
|
||||||
|
level = self.detect(task_write_sets[a], task_write_sets[b], ra, rb)
|
||||||
|
if level != ConflictLevel.NONE:
|
||||||
|
overlap = sorted(set(task_write_sets[a]) & set(task_write_sets[b]))
|
||||||
|
reason = f"file overlap: {', '.join(overlap)}" if overlap else ""
|
||||||
|
if level == ConflictLevel.HARD:
|
||||||
|
reason = f"region overlap: {', '.join(overlap)}" if overlap else "same region"
|
||||||
|
conflicts.append(Conflict(task_a=a, task_b=b, level=level, reason=reason))
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
def extract_regions_from_task(self, file_path: Path, region_markers: list[tuple[int, int]]) -> dict[str, list[tuple[int, int]]]:
|
||||||
|
"""从任务的区域标注提取行号区间。"""
|
||||||
|
return {str(file_path): region_markers}
|
||||||
|
|
||||||
|
|
||||||
|
class WorktreeIsolation:
|
||||||
|
"""为 SOFT 冲突任务创建 git worktree 隔离,完成后合并回主分支。"""
|
||||||
|
|
||||||
|
def __init__(self, repo_root: Path):
|
||||||
|
self._repo_root = repo_root
|
||||||
|
|
||||||
|
def create_worktree(self, task_id: str, base_ref: str = "HEAD") -> Path:
|
||||||
|
branch = f"air-{task_id}"
|
||||||
|
wt_path = self._repo_root.parent / f"{self._repo_root.name}-air-{task_id}"
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self._repo_root), "worktree", "add", "-b", branch, str(wt_path), base_ref],
|
||||||
|
check=True, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return wt_path
|
||||||
|
|
||||||
|
def merge_back(self, task_id: str, wt_path: Path) -> MergeResult:
|
||||||
|
branch = f"air-{task_id}"
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self._repo_root), "merge", "--no-ff", branch],
|
||||||
|
check=True, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return MergeResult(task_id=task_id, successful=True, conflicts=[])
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
conflicts = self._parse_conflicts(exc.stderr)
|
||||||
|
self._abort_merge()
|
||||||
|
return MergeResult(task_id=task_id, successful=False, conflicts=conflicts)
|
||||||
|
|
||||||
|
def cleanup(self, task_id: str, wt_path: Path) -> None:
|
||||||
|
branch = f"air-{task_id}"
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self._repo_root), "worktree", "remove", str(wt_path), "--force"],
|
||||||
|
check=True, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self._repo_root), "branch", "-D", branch],
|
||||||
|
check=True, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _abort_merge(self) -> None:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self._repo_root), "merge", "--abort"],
|
||||||
|
check=True, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_conflicts(stderr: str) -> list[str]:
|
||||||
|
conflicts: list[str] = []
|
||||||
|
for line in stderr.splitlines():
|
||||||
|
if "CONFLICT" in line:
|
||||||
|
conflicts.append(line.strip())
|
||||||
|
return conflicts
|
||||||
161
lib/air_runtime/xdb_capture.py
Normal file
161
lib/air_runtime/xdb_capture.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""AirXDB screenshot capture backends with automatic fallback chain.
|
||||||
|
|
||||||
|
Three capture methods:
|
||||||
|
- KmsGrabCapture: DRM/KMS native screenshot via ffmpeg (no sudo)
|
||||||
|
- XvfbCapture: Xvfb virtual framebuffer screenshot
|
||||||
|
- FallbackCapture: text placeholder when no GUI capture is available
|
||||||
|
|
||||||
|
CaptureManager tries them in order (kms -> xvfb -> fallback) unless
|
||||||
|
a specific method is requested.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CaptureResult:
|
||||||
|
success: bool
|
||||||
|
output_path: Path | None
|
||||||
|
method: str # kms, xvfb, fallback
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class KmsGrabCapture:
|
||||||
|
"""KMS/DRM screenshot via ffmpeg -f kmsgrab.
|
||||||
|
|
||||||
|
No sudo is ever used. The caller must have read access to
|
||||||
|
/dev/dri/cardX for this to work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def capture(self, output_path: Path) -> CaptureResult:
|
||||||
|
# 1. Detect whether the current user can access a DRI device
|
||||||
|
dri_dev = Path("/dev/dri/card0")
|
||||||
|
if dri_dev.exists():
|
||||||
|
try:
|
||||||
|
# Test read permission (no sudo)
|
||||||
|
open(dri_dev).close()
|
||||||
|
except PermissionError:
|
||||||
|
return CaptureResult(False, None, "kms", "no /dev/dri permission")
|
||||||
|
else:
|
||||||
|
return CaptureResult(False, None, "kms", "no GPU")
|
||||||
|
|
||||||
|
# 2. Use ffmpeg directly (no sudo)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg", "-y", "-f", "kmsgrab", "-i", "-",
|
||||||
|
"-frames:v", "1",
|
||||||
|
"-vf", "hwdownload,format=bgr0",
|
||||||
|
"-f", "image2", str(output_path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and output_path.exists():
|
||||||
|
return CaptureResult(True, output_path, "kms", None)
|
||||||
|
return CaptureResult(False, None, "kms", result.stderr)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return CaptureResult(False, None, "kms", "ffmpeg not found")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return CaptureResult(False, None, "kms", "ffmpeg timed out")
|
||||||
|
except Exception as e:
|
||||||
|
return CaptureResult(False, None, "kms", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class XvfbCapture:
|
||||||
|
"""Xvfb virtual display screenshot via xvfb-run + scrot."""
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
self,
|
||||||
|
output_path: Path,
|
||||||
|
width: int = 1920,
|
||||||
|
height: int = 1080,
|
||||||
|
) -> CaptureResult:
|
||||||
|
# Check xvfb-run availability
|
||||||
|
if not shutil.which("xvfb-run"):
|
||||||
|
return CaptureResult(False, None, "xvfb", "xvfb-run not found")
|
||||||
|
|
||||||
|
# Use scrot as the screenshot tool inside Xvfb
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"xvfb-run",
|
||||||
|
"--auto-servernum",
|
||||||
|
"--server-args",
|
||||||
|
f"-screen 0 {width}x{height}x24",
|
||||||
|
"--",
|
||||||
|
"scrot",
|
||||||
|
str(output_path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and output_path.exists():
|
||||||
|
return CaptureResult(True, output_path, "xvfb", None)
|
||||||
|
return CaptureResult(False, None, "xvfb", result.stderr)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return CaptureResult(False, None, "xvfb", "xvfb-run not found")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return CaptureResult(False, None, "xvfb", "xvfb-run timed out")
|
||||||
|
except Exception as e:
|
||||||
|
return CaptureResult(False, None, "xvfb", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class FallbackCapture:
|
||||||
|
"""Fallback: write a text placeholder when no GUI capture is available."""
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
self,
|
||||||
|
output_path: Path,
|
||||||
|
description: str = "",
|
||||||
|
) -> CaptureResult:
|
||||||
|
output_path.write_text(
|
||||||
|
f"[GUI capture not available]\n{description}\n"
|
||||||
|
)
|
||||||
|
return CaptureResult(True, output_path, "fallback", None)
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureManager:
|
||||||
|
"""Auto-select the best capture method with fallback chain.
|
||||||
|
|
||||||
|
Order: kms -> xvfb -> fallback.
|
||||||
|
Use ``prefer`` to force a specific method or "auto" for the chain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.kms = KmsGrabCapture()
|
||||||
|
self.xvfb = XvfbCapture()
|
||||||
|
self.fallback = FallbackCapture()
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
self,
|
||||||
|
output_path: Path,
|
||||||
|
prefer: str = "auto",
|
||||||
|
) -> CaptureResult:
|
||||||
|
"""
|
||||||
|
prefer: "kms" | "xvfb" | "fallback" | "auto"
|
||||||
|
auto mode tries kms -> xvfb -> fallback in order.
|
||||||
|
"""
|
||||||
|
if prefer in ("kms", "auto"):
|
||||||
|
result = self.kms.capture(output_path)
|
||||||
|
if result.success:
|
||||||
|
return result
|
||||||
|
if prefer == "kms":
|
||||||
|
return result # forced kms failed, return failure
|
||||||
|
|
||||||
|
if prefer in ("xvfb", "auto"):
|
||||||
|
result = self.xvfb.capture(output_path)
|
||||||
|
if result.success:
|
||||||
|
return result
|
||||||
|
if prefer == "xvfb":
|
||||||
|
return result # forced xvfb failed, return failure
|
||||||
|
|
||||||
|
# fallback
|
||||||
|
return self.fallback.capture(output_path, "all capture methods failed")
|
||||||
191
scripts/airplan.py
Normal file
191
scripts/airplan.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
AirPlan V2 — 统一 CLI 入口。
|
||||||
|
V1 的 8 个独立插件合并为一个命令,通过 --mode 参数路由到对应子模式。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
airplan --mode arc --project <project-root> [--todo <todo-md>]
|
||||||
|
airplan --mode eng --project <project-root> [--sub enter|status|plan|dispatch|monitor|merge|run|intervene]
|
||||||
|
airplan --mode do --project <project-root> --task-id <id>
|
||||||
|
airplan --mode dbg --project <project-root> [--task-id <id>]
|
||||||
|
airplan --mode sdb --project <project-root> [--backend cppcheck|clang-tidy|clippy|go-vet|tsc] [--target <path>]
|
||||||
|
airplan --mode ctx --project <project-root> [--sub now|status|pause|init]
|
||||||
|
airplan --mode dep --project <project-root> --task-id <id> --host <host> [--binary <path>]
|
||||||
|
airplan --mode tst --project <project-root> --task-id <id> [--framework <name>]
|
||||||
|
airplan --mode sec --project <project-root> --task-id <id> [--scan-path <path>]
|
||||||
|
airplan --mode rvr --project <project-root> --task-id <id>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _add_lib_path() -> None:
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
lib_path = root / "lib"
|
||||||
|
if str(lib_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(lib_path))
|
||||||
|
|
||||||
|
|
||||||
|
_add_lib_path()
|
||||||
|
|
||||||
|
from air_runtime.project_bootstrap import ensure_project_bootstrap
|
||||||
|
|
||||||
|
|
||||||
|
def _run_arc(args) -> None:
|
||||||
|
from air_runtime.modes.arc_mode import main as arc_main
|
||||||
|
arc_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_eng(args) -> None:
|
||||||
|
from air_runtime.modes.eng_mode import main as eng_main
|
||||||
|
eng_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_do(args) -> None:
|
||||||
|
from air_runtime.modes.do_mode import main as do_main
|
||||||
|
do_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_dbg(args) -> None:
|
||||||
|
from air_runtime.modes.dbg_mode import main as dbg_main
|
||||||
|
dbg_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_xdb(args) -> None:
|
||||||
|
from air_runtime.modes.xdb_mode import main as xdb_main
|
||||||
|
xdb_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sdb(args) -> None:
|
||||||
|
from air_runtime.modes.sdb_mode import main as sdb_main
|
||||||
|
sdb_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ndb(args) -> None:
|
||||||
|
from air_runtime.modes.ndb_mode import main as ndb_main
|
||||||
|
ndb_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ctx(args) -> None:
|
||||||
|
from air_runtime.modes.ctx_mode import main as ctx_main
|
||||||
|
ctx_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_dep(args) -> None:
|
||||||
|
from air_runtime.modes.dep_mode import main as dep_main
|
||||||
|
dep_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_tst(args) -> None:
|
||||||
|
from air_runtime.modes.tst_mode import main as tst_main
|
||||||
|
tst_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sec(args) -> None:
|
||||||
|
from air_runtime.modes.sec_mode import main as sec_main
|
||||||
|
sec_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_rvr(args) -> None:
|
||||||
|
from air_runtime.modes.rvr_mode import main as rvr_main
|
||||||
|
rvr_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_eng_orchestrator(args) -> None:
|
||||||
|
from air_runtime.modes.eng_orchestrator import main as eng_orch_main
|
||||||
|
eng_orch_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
MODE_ROUTER = {
|
||||||
|
"arc": _run_arc,
|
||||||
|
"eng": _run_eng,
|
||||||
|
"do": _run_do,
|
||||||
|
"dbg": _run_dbg,
|
||||||
|
"xdb": _run_xdb,
|
||||||
|
"sdb": _run_sdb,
|
||||||
|
"ndb": _run_ndb,
|
||||||
|
"ctx": _run_ctx,
|
||||||
|
"dep": _run_dep,
|
||||||
|
"tst": _run_tst,
|
||||||
|
"sec": _run_sec,
|
||||||
|
"rvr": _run_rvr,
|
||||||
|
"eng_orchestrator": _run_eng_orchestrator,
|
||||||
|
}
|
||||||
|
|
||||||
|
MODE_HELP = {
|
||||||
|
"arc": "架构规划器 — 分析依赖、写集冲突、产出执行计划",
|
||||||
|
"eng": "调度引擎 — 波次派发、监控、合并、修复编排",
|
||||||
|
"do": "任务执行器 — 单任务切片执行与结果生成",
|
||||||
|
"dbg": "调试器 — 7步调试工作流、根因分析、修复回滚",
|
||||||
|
"xdb": "GUI验证器 — 截图取证、GUI操作验证",
|
||||||
|
"sdb": "静态分析器 — 多语言静态分析、diff模式",
|
||||||
|
"ndb": "网络调试器 — 抓包分析、TLS解密",
|
||||||
|
"ctx": "上下文管理器 — 压缩、Token估算、锁检测",
|
||||||
|
"dep": "部署器 — SSH远程构建、部署、systemd管理",
|
||||||
|
"tst": "测试运行器 — 统一测试执行与结果报告",
|
||||||
|
"sec": "安全扫描器 — 敏感数据检测与脱敏",
|
||||||
|
"rvr": "需求审查器 — 交付物与需求一致性验证",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="AirPlan V2 — 统一制品驱动开发调度器",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="\n".join(f" {k:6s} {v}" for k, v in MODE_HELP.items()),
|
||||||
|
)
|
||||||
|
parser.add_argument("--mode", choices=list(MODE_ROUTER.keys()), required=True,
|
||||||
|
help="子模式选择")
|
||||||
|
parser.add_argument("--project", default=".",
|
||||||
|
help="项目根目录 (默认: .)")
|
||||||
|
parser.add_argument("--sub", default="",
|
||||||
|
help="子模式内的二级操作(如 eng 的 enter|status|plan 等)")
|
||||||
|
parser.add_argument("--todo", default="",
|
||||||
|
help="todo.md 路径(arc/eng 使用)")
|
||||||
|
parser.add_argument("--task-id", default="",
|
||||||
|
help="任务 ID(do/dbg/xdb/sdb/ndb/dep/tst/sec/rvr 使用)")
|
||||||
|
parser.add_argument("--result", default="",
|
||||||
|
help="Worker result.json 路径(eng merge 使用)")
|
||||||
|
parser.add_argument("--dispatch-group", default="",
|
||||||
|
help="派发组名(eng dispatch 使用)")
|
||||||
|
parser.add_argument("--host", default="",
|
||||||
|
help="远程主机(dep 使用)")
|
||||||
|
parser.add_argument("--binary", default="",
|
||||||
|
help="本地二进制路径(dep 使用)")
|
||||||
|
parser.add_argument("--framework", default="",
|
||||||
|
help="测试框架(tst 使用)")
|
||||||
|
parser.add_argument("--scan-path", default="",
|
||||||
|
help="扫描路径(sec 使用)")
|
||||||
|
parser.add_argument("--sec-mode", default="blocking", choices=["advisory", "blocking"],
|
||||||
|
help="安全扫描模式(sec 使用): advisory(只报告)/ blocking(阻止合并)")
|
||||||
|
parser.add_argument("--backend", default="",
|
||||||
|
help="静态分析后端(sdb 使用): cppcheck|clang-tidy|clippy|go-vet|tsc")
|
||||||
|
parser.add_argument("--target", default="",
|
||||||
|
help="分析目标路径(sdb 使用)")
|
||||||
|
parser.add_argument("--prefer", default="auto",
|
||||||
|
help="截图后端偏好(xdb 使用): kms|xvfb|fallback|auto")
|
||||||
|
parser.add_argument("--output", default="",
|
||||||
|
help="截图输出文件名(xdb 使用,默认 screenshot.png)")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
args = parse_args(argv)
|
||||||
|
project_root = Path(args.project).expanduser().resolve()
|
||||||
|
ensure_project_bootstrap(project_root)
|
||||||
|
|
||||||
|
handler = MODE_ROUTER.get(args.mode)
|
||||||
|
if handler is None:
|
||||||
|
print(f"Unknown mode: {args.mode}", file=sys.stderr)
|
||||||
|
print(f"Available modes: {', '.join(MODE_ROUTER.keys())}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
handler(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
185
skills/airplan/SKILL.md
Normal file
185
skills/airplan/SKILL.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
---
|
||||||
|
name: airplan
|
||||||
|
description: AirPlan V2 — 统一制品驱动开发调度器。单一插件整合规划(arc)、执行(do)、调试(dbg)、GUI验证(xdb)、静态分析(sdb)、网络调试(ndb)、部署(dep)、测试(tst)、安全扫描(sec)、需求审查(rvr)。L1代码级保障:不依赖LLM自觉。
|
||||||
|
allowed-tools: [Read, Glob, Grep, Bash, Write, Edit]
|
||||||
|
---
|
||||||
|
|
||||||
|
# AirPlan V2
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
AirPlan V2 是 V1 的 8 个独立插件(airarc, aireng, airdo, airdbg, airxdb, airsdb, airndb, aircontext)合并为 **1 个统一插件**,新增 4 个组件(airdep, airtst, airsec, airrvr),共 **12 个子模式**。
|
||||||
|
|
||||||
|
核心设计原则:
|
||||||
|
1. **制品驱动通信** — 插件间通过 AirPlan/ 文件通信
|
||||||
|
2. **上下文隔离** — Worker `fork_context=false`
|
||||||
|
3. **L1 代码级保障** — 关键逻辑不依赖 LLM 自觉,由引擎代码强制执行
|
||||||
|
|
||||||
|
## 子模式速查
|
||||||
|
|
||||||
|
| 子模式 | 角色 | 关键文件 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| arc | 架构规划器 — 产出执行计划、DAG | execution-plan.json, task-graph.json |
|
||||||
|
| eng | 调度引擎 — 波次派发、监控、合并 | state/aireng/state.json |
|
||||||
|
| do | 任务执行器 — 单任务切片<E58887><E78987><EFBFBD>行 | state/airdo/tasks/{task_id}/result.json |
|
||||||
|
| dbg | 调试器 — 7 步工作流强制追踪 | state/airdbg/sessions/*.json |
|
||||||
|
| xdb | GUI 验证器 — 截图取证、DRM/KMS | state/airxdb/artifacts/ |
|
||||||
|
| sdb | 静态分析器 — 多语言分析 | state/airsdb/reports/ |
|
||||||
|
| ndb | 网络调试器 — 抓包分析 | state/airndb/captures/ |
|
||||||
|
| ctx | 上下文管理器 — 压缩、Token 估算 | state/aircontext/ |
|
||||||
|
| dep | 部署器 — SSH 远程构建 + systemd | state/airdep/sessions/ |
|
||||||
|
| tst | 测试运行器 — 统一多框架测试 | state/airtst/reports/ |
|
||||||
|
| sec | 安全扫描器 — 敏感数据检测 | state/airsec/ |
|
||||||
|
| rvr | 需求审查器 — 交付物与需求一致性 | state/airrvr/reviews/ |
|
||||||
|
|
||||||
|
## 关键不变量(INV-)
|
||||||
|
|
||||||
|
### INV-1 制品驱动通信
|
||||||
|
插件间**不直接调用**,而是通过 AirPlan/ 目录下的 JSON/MD 文件通信。状态文件是唯一真相来源。
|
||||||
|
|
||||||
|
### INV-2 上下文隔离
|
||||||
|
所有 Worker 派发使用 `fork_context=false`,确保子任务不继承父线程的完整上下文,仅传递任务相关的必要信息。
|
||||||
|
|
||||||
|
### INV-3 架构同步强制
|
||||||
|
不更新架构文档(ADR、C4、plan.md)不能标记任务为 DONE。AirEng merge 时强制检查 documentUpdates。
|
||||||
|
|
||||||
|
### INV-4 证据先于修复
|
||||||
|
GUI 任务必须先有截图/验证证据,网络任务必须先有抓包/连通性证据,代码任务必须先有静态分析/测试结果。EvidenceGatePolicy 根据任务类型自动判断需要哪种证据。
|
||||||
|
|
||||||
|
### INV-5 闭环自动修复
|
||||||
|
执行 → 失败 → 调试 → 修复 → 重执行 的闭环由 AirEng 的修复预算机制自动驱动。
|
||||||
|
|
||||||
|
### INV-6 调度器不写代码
|
||||||
|
AirEng 是调度器,不是执行器。它只做 plan/dispatch/monitor/merge/intervene/doc-sync,绝不直接实现任务代码。所有任务实现必须通过 /do 子代理(fork_context=false)完成。调度线程直接写代码是严重违规,唯一例外是 Worker 硬阻塞无法自恢复时的紧急干预,干预后必须立即回到调度模式。
|
||||||
|
|
||||||
|
### INV-7 架构器只读不写
|
||||||
|
AirArc 是纯规划器,只做分析依赖、写集冲突、产出调度计划。禁止编写代码、修改源代码文件、执行构建命令。allowed-tools 仅含 Read/Glob/Grep,不含 Write/Edit/Bash。
|
||||||
|
|
||||||
|
### INV-8 架构三阶段门控
|
||||||
|
AirArc 必须遵循三阶段流程:discussing(需求探讨)→ proposing(架构提议)→ confirmed(用户确认后)。execution-plan.json 仅在 phase=confirmed 时允许写入。
|
||||||
|
|
||||||
|
### INV-9 调试先读后写
|
||||||
|
AirDbg 修改代码前必须至少完成一项取证行为(截图/抓包/静态分析/日志分析/代码追踪/复现步骤)。未取证就修改代码是严重违规。
|
||||||
|
|
||||||
|
### INV-10 中文锁定
|
||||||
|
AirEng 必须始终使用中文与用户交流。自主决策不停下来问用户。
|
||||||
|
|
||||||
|
### INV-11 项目日志标准
|
||||||
|
所有 C++ 项目必须集成 spdlog,禁止 std::cout/qDebug/printf。AirArc 规划时强制首任务为 spdlog 集成(如未存在),AirRvr 审查时检查日志完备性。
|
||||||
|
|
||||||
|
### INV-12 边界测试强制
|
||||||
|
AirArc 规划时必须为每个模块边界自动生成测试任务:
|
||||||
|
- 接口测试任务(模块边界)
|
||||||
|
- 单元测试任务(核心逻辑)
|
||||||
|
- 标记 test_required=True
|
||||||
|
- Done When 必须包含"测试通过"
|
||||||
|
|
||||||
|
### INV-13 终审高风险审计
|
||||||
|
AirRvr 终审必须包含 highRiskAudit 报告,强制检查:
|
||||||
|
- lifecycle: 资源申请释放配对、RAII 覆盖、异步回调保证
|
||||||
|
- nullPointer: 指针使用前判空、智能指针生命周期、回调闭包指针有效性
|
||||||
|
- danglingPointer: 悬垂指针风险
|
||||||
|
- exceptionSafety: 异常<E5BC82><E5B8B8>径资源释放、异常吞没、跨模块异常传播
|
||||||
|
- concurrency: 共享状态锁保护、锁顺序(防死锁)、条件变量虚假唤醒
|
||||||
|
|
||||||
|
deliveryVerdict = block-release 时阻止 AirEng 所有后续派发。
|
||||||
|
|
||||||
|
### INV-14 界面设计 Skill 强制
|
||||||
|
AirDo 处理 UI 任务时必须使用 frontend-design Skill:
|
||||||
|
- 任务涉及 UI/前端/界面时,检测并路由
|
||||||
|
- frontend-design Skill 不存在则自动安装
|
||||||
|
- 安装失败时阻止任务执行
|
||||||
|
|
||||||
|
## L1 代码级保障(不依赖 LLM 自觉)
|
||||||
|
|
||||||
|
以下功能由引擎代码强制执行,SKILL.md 指令仅作辅助:
|
||||||
|
|
||||||
|
1. **原子写入** — `air_runtime/io.py:atomic_json_write()` 使用 tempfile + os.replace()
|
||||||
|
2. **文件锁** — `air_runtime/lock.py:FileLock` 持有者才能读写 state.json
|
||||||
|
3. **证据门控** — `air_runtime/evidence_gate.py:EvidenceGatePolicy` 分类任务类型,强制要求对应证据
|
||||||
|
4. **强制 AirDbg 路由** — `air_runtime/modes/do_mode.py:finish_worker()` 中 blocked/failed/done无证据 时强制路由到 airdbg
|
||||||
|
5. **硬编码轮询循环** — `air_runtime/modes/eng_mode.py:monitor_engine()` 每 5 分钟检测所有 Worker 状态
|
||||||
|
6. **Worker 超时** — 超过 2 小时硬上限自动 terminate
|
||||||
|
7. **部署一致性验证** — merge 时检测 deployRequired 字段是否有对应验证证据
|
||||||
|
8. **任务 ID 校验** — `air_runtime/utils.py:sanitize_task_id()` 防止路径注入
|
||||||
|
9. **Arc 三阶段门控** — `air_runtime/modes/arc_mode.py:ArcPhaseGate` 仅 confirmed 阶段可写 execution-plan.json
|
||||||
|
10. **Dbg 先读后写门控** — `air_runtime/modes/dbg_mode.py:WorkflowViolation` fix 步骤前必须有 collectedEvidence
|
||||||
|
11. **Arc 工具白名单** — commands/arc.md allowed-tools 仅 [Read, Glob, Grep],deny-plan-mode=true
|
||||||
|
12. **边界测试强制** — `air_runtime/modes/arc_mode.py:_inject_boundary_tests()` 为每个模块注入测试任务
|
||||||
|
13. **block-release 阻止派发** — `air_runtime/modes/eng_mode.py:dispatch_worker_group()` 检查 deliveryVerdict
|
||||||
|
14. **UI Skill 路由** — `air_runtime/modes/do_mode.py:route_ui_task()` 检测并确保 frontend-design Skill
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 架构规划
|
||||||
|
airplan --mode arc --project /path/to/project --sub enter
|
||||||
|
airplan --mode arc --project /path/to/project --sub parallel-review --todo AirPlan/todo.md
|
||||||
|
|
||||||
|
# 调度引擎
|
||||||
|
airplan --mode eng --project /path/to/project --sub enter
|
||||||
|
airplan --mode eng --project /path/to/project --sub plan --todo AirPlan/todo.md
|
||||||
|
airplan --mode eng --project /path/to/project --sub dispatch
|
||||||
|
airplan --mode eng --project /path/to/project --sub monitor
|
||||||
|
airplan --mode eng --project /path/to/project --sub merge --result /path/to/result.json
|
||||||
|
|
||||||
|
# 任务执行
|
||||||
|
airplan --mode do --project /path/to/project --task-id T-001 --sub enter
|
||||||
|
airplan --mode do --project /path/to/project --task-id T-001 --sub finish --result /path/to/result.json
|
||||||
|
|
||||||
|
# 调试
|
||||||
|
airplan --mode dbg --project /path/to/project --task-id T-001 --sub start
|
||||||
|
airplan --mode dbg --project /path/to/project --task-id T-001 --sub snapshot
|
||||||
|
|
||||||
|
# 部署
|
||||||
|
airplan --mode dep --project /path/to/project --task-id T-001 --host 192.168.1.100 --binary ./build/myapp
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
airplan --mode tst --project /path/to/project --task-id T-001 --framework pytest
|
||||||
|
|
||||||
|
# 安全扫描
|
||||||
|
airplan --mode sec --project /path/to/project --task-id T-001 --scan-path ./src
|
||||||
|
|
||||||
|
# 需求审查
|
||||||
|
airplan --mode rvr --project /path/to/project --task-id T-001 --sub review
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
AirPlan/
|
||||||
|
├── AGENTS.md # Agent 行为规范(各子模式入口)
|
||||||
|
├── plan.md # 执行计划摘要
|
||||||
|
├── todo.md # 任务列表(人类可读)
|
||||||
|
├── docs/
|
||||||
|
│ ├── architecture/
|
||||||
|
│ │ ├── adr/ # ADR 记录
|
||||||
|
│ │ └── c4/module.md # C4 模块文档
|
||||||
|
│ ├── debug/
|
||||||
|
│ │ ├── debug-log.md # 调试日志
|
||||||
|
│ │ └── gui-debug-log.md # GUI 调试日志
|
||||||
|
│ ├── staticanalysis.md # 静态分析报告
|
||||||
|
│ └── network/ # 网络抓包
|
||||||
|
└── state/
|
||||||
|
├── airarc/reviews/ # 架构规划输出
|
||||||
|
├── aireng/ # 调度引擎状态
|
||||||
|
├── airdo/tasks/ # Worker 结果
|
||||||
|
├── airdbg/sessions/ # 调试会话
|
||||||
|
├── airxdb/artifacts/ # 截图/验证证据
|
||||||
|
├── airsdb/reports/ # 静态分析报告
|
||||||
|
├── airndb/captures/ # 网络抓包
|
||||||
|
├── aircontext/ # 上下文压缩状态
|
||||||
|
├── airdep/sessions/ # 部署记录
|
||||||
|
├── airtst/reports/ # 测试报告
|
||||||
|
├── airsec/ # 安全扫描<E689AB><E68F8F>果
|
||||||
|
└── airrvr/reviews/ # 需求审查报告
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
| 症状 | 排查步骤 |
|
||||||
|
|------|----------|
|
||||||
|
| Worker 假阳性阻塞 | 检查 `state/airxdb/` 是否存在对应截图证据,确认任务类型是否被 EvidenceGatePolicy 误分类 |
|
||||||
|
| 部署后未生效 | 检查 merge 时是否包含 deploy 验证证据(remote-deploy-verify / remote-binary-md5) |
|
||||||
|
| 任务状态卡在 DISPATCHED | 运行 `airplan --mode eng --project . --sub monitor` 查看 Worker 是否超时或停滞 |
|
||||||
|
| 敏感数据泄露 | 检查 AirSec 扫描结果,查看 `state/airsec/` 是否有 finding |
|
||||||
Reference in New Issue
Block a user