From 6130478c96581d623a3870876e8ff005ac0df8bf Mon Sep 17 00:00:00 2001 From: AirPlan Date: Fri, 12 Jun 2026 15:56:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AirPlan=20V2=20=E2=80=94=20=E5=85=A8?= =?UTF-8?q?=E4=B8=93=E5=AE=B6=E6=8F=92=E4=BB=B6=E5=BC=BA=E5=88=B6=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=20+=20=E4=BA=8B=E4=BB=B6=E7=B3=BB=E7=BB=9F=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-8 扩大: do_mode.py finish_worker 全专家插件强制路由 - GUI→XDB, network→NDB, C/C++→SDB, done→Rvr, blocked/failed→Dbg - 证据去重: 已有 xdbSessions/ndbSessions/sdbReports/rvrReviewed 则跳过 P1-GAP17: 事件 emit 规范化 - 新增 7 个事件常量 (TASK_ENTERED, TASK_FINISHED, ENGINE_ENTERED 等) - 全部 emit 调用替换字符串字面量为常量,零残留 - 30 个事件类型常量全部定义且唯一 P1-GAP18: 事件日志原子轮转 - emit 计数器每 128 次检查轮转,避免每次 emit 读文件 - 清除未使用的 _emit_with_completion/_pending_merge_complete - 原子轮转: tempfile+os.replace 保证不损坏 eng 极端接管: 强制调用全部专家插件 (Dbg/XDB/NDB/SDB/Rvr) commands/do.md: 更新为全专家插件路由文档 全量测试: 69 通过, 0 失败 Co-Authored-By: Claude Sonnet 4.6 --- .claude-plugin/plugin.json | 25 + .gitignore | 7 + README.md | 87 ++ SKILL.md | 10 + commands/airplan.md | 46 ++ commands/arc.md | 70 ++ commands/ctx.md | 65 ++ commands/dbg.md | 63 ++ commands/dep.md | 45 ++ commands/do.md | 131 +++ commands/eng.md | 169 ++++ commands/ndb.md | 31 + commands/rvr.md | 53 ++ commands/sdb.md | 49 ++ commands/sec.md | 57 ++ commands/tst.md | 53 ++ commands/xdb.md | 43 + do_mode.py | 260 ++++++ install.sh | 78 ++ lib/air_runtime/__init__.py | 20 + lib/air_runtime/adr_watcher.py | 109 +++ lib/air_runtime/contracts.py | 109 +++ lib/air_runtime/deploy_runtime.py | 133 +++ lib/air_runtime/events.py | 108 +++ lib/air_runtime/evidence_gate.py | 74 ++ lib/air_runtime/installer.py | 121 +++ lib/air_runtime/io.py | 53 ++ lib/air_runtime/lock.py | 44 + lib/air_runtime/modes/__init__.py | 11 + lib/air_runtime/modes/arc_mode.py | 382 +++++++++ lib/air_runtime/modes/ctx_mode.py | 232 ++++++ lib/air_runtime/modes/dbg_mode.py | 279 +++++++ lib/air_runtime/modes/dep_mode.py | 39 + lib/air_runtime/modes/do_mode.py | 370 +++++++++ lib/air_runtime/modes/eng_mode.py | 898 +++++++++++++++++++++ lib/air_runtime/modes/eng_orchestrator.py | 247 ++++++ lib/air_runtime/modes/merge_pipeline.py | 238 ++++++ lib/air_runtime/modes/ndb_mode.py | 1 + lib/air_runtime/modes/rvr_mode.py | 32 + lib/air_runtime/modes/sdb_mode.py | 63 ++ lib/air_runtime/modes/sec_mode.py | 62 ++ lib/air_runtime/modes/tst_mode.py | 35 + lib/air_runtime/modes/xdb_mode.py | 44 + lib/air_runtime/modes/xdb_sdb_ndb_modes.py | 97 +++ lib/air_runtime/partial_replanner.py | 106 +++ lib/air_runtime/paths.py | 99 +++ lib/air_runtime/project_bootstrap.py | 59 ++ lib/air_runtime/review.py | 126 +++ lib/air_runtime/review_runtime.py | 214 +++++ lib/air_runtime/sdb_backends.py | 527 ++++++++++++ lib/air_runtime/sec_runtime.py | 175 ++++ lib/air_runtime/task_graph.py | 315 ++++++++ lib/air_runtime/test_runtime.py | 183 +++++ lib/air_runtime/todo_parser.py | 100 +++ lib/air_runtime/utils.py | 65 ++ lib/air_runtime/worktree.py | 157 ++++ lib/air_runtime/xdb_capture.py | 161 ++++ marketplace.json | 100 +++ scripts/airplan.py | 193 +++++ scripts/install.sh | 78 ++ scripts/test_install.sh | 73 ++ skills/airplan/SKILL.md | 201 +++++ test_arc.py | 143 ++++ test_ctx.py | 248 ++++++ test_dbg.py | 159 ++++ test_dep_tst_sec_rvr.py | 166 ++++ test_do.py | 254 ++++++ test_eng.py | 198 +++++ test_p1_19_20.py | 152 ++++ test_p1_21.py | 491 +++++++++++ test_p1_21_supplement.py | 358 ++++++++ test_t_121_122.py | 288 +++++++ test_xdb_sdb_ndb.py | 90 +++ 73 files changed, 10622 insertions(+) create mode 100755 .claude-plugin/plugin.json create mode 100755 .gitignore create mode 100755 README.md create mode 100755 SKILL.md create mode 100755 commands/airplan.md create mode 100755 commands/arc.md create mode 100755 commands/ctx.md create mode 100755 commands/dbg.md create mode 100755 commands/dep.md create mode 100755 commands/do.md create mode 100755 commands/eng.md create mode 100755 commands/ndb.md create mode 100755 commands/rvr.md create mode 100755 commands/sdb.md create mode 100755 commands/sec.md create mode 100755 commands/tst.md create mode 100755 commands/xdb.md create mode 100755 do_mode.py create mode 100755 install.sh create mode 100755 lib/air_runtime/__init__.py create mode 100644 lib/air_runtime/adr_watcher.py create mode 100755 lib/air_runtime/contracts.py create mode 100755 lib/air_runtime/deploy_runtime.py create mode 100755 lib/air_runtime/events.py create mode 100755 lib/air_runtime/evidence_gate.py create mode 100755 lib/air_runtime/installer.py create mode 100755 lib/air_runtime/io.py create mode 100755 lib/air_runtime/lock.py create mode 100755 lib/air_runtime/modes/__init__.py create mode 100755 lib/air_runtime/modes/arc_mode.py create mode 100755 lib/air_runtime/modes/ctx_mode.py create mode 100755 lib/air_runtime/modes/dbg_mode.py create mode 100755 lib/air_runtime/modes/dep_mode.py create mode 100755 lib/air_runtime/modes/do_mode.py create mode 100755 lib/air_runtime/modes/eng_mode.py create mode 100755 lib/air_runtime/modes/eng_orchestrator.py create mode 100755 lib/air_runtime/modes/merge_pipeline.py create mode 100755 lib/air_runtime/modes/ndb_mode.py create mode 100755 lib/air_runtime/modes/rvr_mode.py create mode 100755 lib/air_runtime/modes/sdb_mode.py create mode 100755 lib/air_runtime/modes/sec_mode.py create mode 100755 lib/air_runtime/modes/tst_mode.py create mode 100755 lib/air_runtime/modes/xdb_mode.py create mode 100755 lib/air_runtime/modes/xdb_sdb_ndb_modes.py create mode 100644 lib/air_runtime/partial_replanner.py create mode 100755 lib/air_runtime/paths.py create mode 100755 lib/air_runtime/project_bootstrap.py create mode 100755 lib/air_runtime/review.py create mode 100755 lib/air_runtime/review_runtime.py create mode 100755 lib/air_runtime/sdb_backends.py create mode 100755 lib/air_runtime/sec_runtime.py create mode 100755 lib/air_runtime/task_graph.py create mode 100755 lib/air_runtime/test_runtime.py create mode 100755 lib/air_runtime/todo_parser.py create mode 100755 lib/air_runtime/utils.py create mode 100755 lib/air_runtime/worktree.py create mode 100755 lib/air_runtime/xdb_capture.py create mode 100755 marketplace.json create mode 100755 scripts/airplan.py create mode 100755 scripts/install.sh create mode 100755 scripts/test_install.sh create mode 100755 skills/airplan/SKILL.md create mode 100755 test_arc.py create mode 100755 test_ctx.py create mode 100755 test_dbg.py create mode 100755 test_dep_tst_sec_rvr.py create mode 100755 test_do.py create mode 100755 test_eng.py create mode 100755 test_p1_19_20.py create mode 100644 test_p1_21.py create mode 100644 test_p1_21_supplement.py create mode 100644 test_t_121_122.py create mode 100755 test_xdb_sdb_ndb.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100755 index 0000000..8bafe35 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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/" + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..be20257 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +.bak +*.tmp +*.lock +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..60cb7e3 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# AirPlan V2 + +Claude Code 统一开发调度插件,整合 8 个 V1 插件为 1 个,支持 12 种开发模式。 + +## 功能特性 + +- **Arc** - 架构规划器,产出执行计划和 DAG +- **Eng** - 调度引擎,波次派发、监控、合并 +- **Do** - 任务执行器,单任务切片运行 +- **Dbg** - 调试器,7 步工作流强制追踪 +- **Xdb** - GUI 验证器,截图取证 +- **Sdb** - 静态分析器,多语言支持 +- **Ndb** - 网络调试器,抓包分析 +- **Ctx** - 上下文管理器,Token 估算 +- **Dep** - 部署器,SSH 远程构建 +- **Tst** - 测试运行器,统一多框架 +- **Sec** - 安全扫描器,敏感数据检测 +- **Rvr** - 需求审查器,交付物一致性 + +## L1 代码级保障 + +- 原子写入 + 文件锁 +- 证据门控强制 +- 三阶段架构门控 +- 调试先读后写 +- 边界测试强制 +- 高风险审计 +- UI Skill 路由 + +## 安装 + +```bash +# 克隆插件 +cd ~/.claude/skills +git clone http://git.airlongdian.fun/admin/AirPlan-V2.git airplan-v2 + +# 或使用安装脚本 +bash ~/.claude/skills/airplan-v2/scripts/install.sh +``` + +## 使用 + +```bash +# 架构规划 +/arc + +# 调度引擎 +/eng + +# 任务执行 +/do + +# 调试模式 +/dbg + +# GUI 验证 +/xdb + +# 静态分析 +/sdb + +# 网络调试 +/ndb + +# 上下文管理 +/ctx + +# 部署 +/dep + +# 测试 +/tst + +# 安全扫描 +/sec + +# 需求审查 +/rvr +``` + +## 版本 + +v2.0.0 - 统一插件版本 + +## 许可证 + +MIT \ No newline at end of file diff --git a/SKILL.md b/SKILL.md new file mode 100755 index 0000000..ea37bf1 --- /dev/null +++ b/SKILL.md @@ -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. diff --git a/commands/airplan.md b/commands/airplan.md new file mode 100755 index 0000000..0a8a013 --- /dev/null +++ b/commands/airplan.md @@ -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. \ No newline at end of file diff --git a/commands/arc.md b/commands/arc.md new file mode 100755 index 0000000..290ebf1 --- /dev/null +++ b/commands/arc.md @@ -0,0 +1,70 @@ +--- +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 scripts/airplan.py --mode arc --project . --sub enter +``` + +### status + +```bash +python scripts/airplan.py --mode arc --project . --sub status +``` + +### parallel-review + +```bash +python scripts/airplan.py --mode arc --project . --sub parallel-review --todo AirPlan/todo.md +``` + +### incremental-replan + +```bash +python scripts/airplan.py --mode arc --project . --sub incremental-replan --todo AirPlan/todo.md +``` + +## 三阶段 → 命令映射 + +ArcPhaseGate 控制 execution-plan.json 写入权限。phase 默认 `discussing`,必须推进到 `confirmed` 才能生成规划。 + +| 阶段 | phase 值 | 操作 | +|------|---------|------| +| 需求探讨 | `discussing` | 与用户对话讨论,不需要命令 | +| 架构确认 | `proposing` | 向用户呈现方案,等待确认 | +| 生成规划 | `confirmed` | `parallel-review` 或 `incremental-replan` | + +**推进方式**:用户说"确认"/"可以"/"同意"后,`ArcPhaseGate.confirm_architecture()` 自动推进。不丢失(已修复)。 + +## 弱模型安全(INV-16) + +Do Worker 可能是廉价模型/本地小模型,字面理解任务无推断能力。产出每个任务时必须: + +1. **禁止歧义词** — 不用"清理"、"重构"、"优化"等宽泛动词,指明具体改什么 +2. **否定约束显式化** — 写明**不做什么**(如"不删除 src/ 下现有模块") +3. **文件范围精确化** — `files_dirs` 精确到文件级,不写 `src/` 目录级 +4. **完成标准可验证** — `done_when` 能用 `grep`/`diff`/`cmake --build` 客观验证 + +## 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. \ No newline at end of file diff --git a/commands/ctx.md b/commands/ctx.md new file mode 100755 index 0000000..f166c0a --- /dev/null +++ b/commands/ctx.md @@ -0,0 +1,65 @@ +--- +description: "AirPlan ctx mode - context manager: compression, token estimation, stale lock detection" +argument-hint: "[enter|compress|validate|estimate|status]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /ctx + +AirContext 管理上下文压缩、Token 估算、陈旧锁检测。三级降级压缩策略:重试 → 换模型 → 截断。 + +## 硬规则 + +1. **压缩质量校验** — 压缩后必须调用 `--sub validate` 验证关键信息(ADR 引用、TODO、文件路径)保留率 ≥70% +2. **压缩前备份** — 每次压缩自动备份原文件为 `.bak` +3. **陈旧锁清理** — 检测到锁持有进程已死则自动清理 + +## 三级降级压缩 + +1. **RETRY** — 重新压缩(换 prompt) +2. **FALLBACK_MODEL** — 换模型压缩 +3. **TRUNCATE** — 放弃压缩,保留原始上下文,通知用户 + +## 子命令 + +### enter — 初始化上下文管理器 + +```bash +python scripts/airplan.py --mode ctx --project . --sub enter +``` + +### compress — 压缩上下文 + +```bash +python scripts/airplan.py --mode ctx --project . --sub compress +``` + +读取 `AirPlan/context.md`,执行三级降级压缩。emit `context.compacted` 事件。 + +### validate — 校验压缩质量 + +```bash +python scripts/airplan.py --mode ctx --project . --sub validate +``` + +检查压缩是否保留关键模式:文件路径、ADR-XXXX、TODO/FIXME、INV-X。返回 `{validation_ok, level, tokens}`。 + +### estimate — Token 估算 + +```bash +python scripts/airplan.py --mode ctx --project . --sub estimate +``` + +按内容类型(中文/英文/代码/Markdown)分比率估算 token 数。 + +### status — 查看状态 + +```bash +python scripts/airplan.py --mode ctx --project . --sub status +``` + +## 集成路径 + +**Eng 压缩检查**:每轮 `monitor_engine()` 后可调 `--sub validate` 检查上下文质量。 + +**Do Worker 压缩**:长任务 Worker 可在执行前调 `--sub compress` 精简上下文。 diff --git a/commands/dbg.md b/commands/dbg.md new file mode 100755 index 0000000..6d6f19c --- /dev/null +++ b/commands/dbg.md @@ -0,0 +1,63 @@ +--- +description: "AirPlan dbg mode - 7-step debug workflow with evidence-first gate" +argument-hint: "[start|snapshot|status]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /dbg + +AirDbg 是调试器,强制 7 步证据先于修复流程。EvidenceFirstGate 硬门控——未取证不可改代码(INV-9)。 + +## 硬规则 + +1. **先读后写** — 修改代码前必须至少完成一项取证(截图/抓包/静态分析/日志分析/代码追踪/复现) +2. **修复前快照** — 修改代码前必须 `--sub snapshot` 创建回滚点 +3. **步骤不可跳过** — 7 步工作流按顺序推进,每步需要对应证据 + +## 7 步工作流 + +| 步 | 名称 | 需要证据 | 操作 | +|----|------|---------|------| +| 1 | confirm_symptoms | 症状描述 | `--sub start` 初始化会话 | +| 2 | load_context | | 读取相关文件、日志、配置 | +| 3 | reproduce | (可跳过) | 复现或标记为不可复现 | +| 4 | locate_root_cause | ≥1 种证据 | 分析证据定位根因 | +| 5 | fix | 根因分析结论 | 先 `--sub snapshot`,再改代码 | +| 6 | verify_again | 测试结果 | 验证修复有效 | +| 7 | document | | 写 debug-log.md,关闭会话 | + +## 子命令 + +### start — 启动调试会话 + +```bash +python scripts/airplan.py --mode dbg --project . --sub start --task-id T-001 +``` + +返回 `{sessionId, currentStep, steps}`。会话状态写入 `AirPlan/state/airdbg/sessions/`。 + +### snapshot — 修复前创建回滚点 + +```bash +python scripts/airplan.py --mode dbg --project . --sub snapshot --task-id T-001 +``` + +在当前 HEAD 创建 git tag,返回 `{snapshot_ref}`。 + +### status — 查看调试状态 + +```bash +python scripts/airplan.py --mode dbg --project . --sub status +``` + +## 集成路径 + +**Eng → Dbg**:`monitor_engine()` 检测到 stalled/blocked Worker 且 action=`"upgraded-to-airdbg"` 时: +1. 读 `interventions[].sessionId` 获取 Dbg 会话 ID +2. 调 `--sub start --task-id {tid}` 开始 Dbg 会话 +3. Dbg 完成后:finish Worker,然后 merge + +**Dbg → Do finish**:Dbg 修复完成后回到原任务: +```bash +python scripts/airplan.py --mode do --project . --sub finish --task-id T-001 --result AirPlan/state/airdo/tasks/T-001/result.json +``` diff --git a/commands/dep.md b/commands/dep.md new file mode 100755 index 0000000..e7d284b --- /dev/null +++ b/commands/dep.md @@ -0,0 +1,45 @@ +--- +description: "AirPlan dep mode - SSH remote build + deploy + systemd management" +argument-hint: "[deploy|status]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /dep + +AirDep 是部署器。SSH 远程构建 → 二进制传输(scp + MD5 校验)→ systemd 生命周期管理 → 部署验证。 + +## 硬规则 + +1. **部署前必须已有构建产物** — 本地 `--binary` 路径文件必须存在 +2. **传输后 MD5 校验** — scp 后远程 MD5 必须与本地一致 +3. **部署后验证** — systemd `is-active` 检查服务状态 + +## 子命令 + +### deploy — 执行部署 + +```bash +python scripts/airplan.py --mode dep --project . --sub deploy --task-id T-001 --host 192.168.1.100 --binary ./build/myapp +``` + +参数: +- `--task-id`:关联任务 ID +- `--host`:远程主机地址 +- `--binary`:本地二进制文件路径 + +返回 `{task_id, success, md5, service_status}`。emit `deploy.completed` 事件。 + +制品:`AirPlan/state/airdep/sessions/{session_id}.json` + +### status — 查看部署状态 + +```bash +python scripts/airplan.py --mode dep --project . --sub status +``` + +## 集成路径 + +**Do Worker 完成后部署**:Worker finish → Eng merge 检测 `deployRequired` 字段 → 调 dep deploy: +```bash +python scripts/airplan.py --mode dep --project . --sub deploy --task-id T-001 --host --binary +``` diff --git a/commands/do.md b/commands/do.md new file mode 100755 index 0000000..c1d76bd --- /dev/null +++ b/commands/do.md @@ -0,0 +1,131 @@ +--- +description: "AirPlan do mode - task executor with UI skill routing and full expert plugin routing" +argument-hint: "[enter|status|finish]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /do + +AirDo 是任务执行器。运行单个任务,强制执行全专家插件路由规则。 + +## 硬规则 + +1. **UI Task Handling (P1-20)** — 当任务涉及 UI/前端/界面时,检测并确保 frontend-design Skill 可用。不可用时阻止执行 +2. **Evidence Gate (INV-4)** — GUI 任务需截图,网络任务需抓包,代码任务需静态分析/测试 +3. **全专家插件强制路由** — finish 时按任务类型和状态强制路由到对应专家插件,不可跳过 + +## 子命令 + +### enter — 进入 Worker + +```bash +python scripts/airplan.py --mode do --project . --sub enter --task-id T-001 --task-text "实现登录界面UI" +``` + +参数: +- `--task-id`:任务 ID(必须) +- `--task-text`:任务描述文本(用于 UI 检测和路由,必须) + +创建 `AirPlan/state/airdo/tasks/{task_id}/worker-state.json`。返回 `{taskId, briefPath, resultPath, workerStatePath, uiRouting}`。 + +### status — 查看 Worker 状态 + +```bash +python scripts/airplan.py --mode do --project . --sub status +``` + +### finish — 完成 Worker(含全专家插件路由决策) + +```bash +python scripts/airplan.py --mode do --project . --sub finish --task-id T-001 --result AirPlan/state/airdo/tasks/T-001/result.json +``` + +finish 执行强制路由(按优先级): + +| 条件 | 路由目标 | 强制 | +|------|---------|------| +| blocked / failed | `airdbg` | 是 | +| done + 无 validations + 无 files_changed | `airdbg` | 是 | +| GUI 任务(gui/ui/render/widget/dialog 等) | `airxdb` | 是 | +| 网络任务(network/rtsp/http/tcp/socket 等) | `airndb` | 是 | +| C/C++ 文件变更(.cpp/.h/.hpp 等) | `airsdb` | 是 | +| 所有 done 任务 | `airrvr` | 是 | +| 无强制路由触发 | `merge` | 否 | + +返回 `routingDecisions` 列表(可能有多个路由目标,依次处理)。 + +## AirDbg 路由 — 调试修复 + +当 finish 返回 `routingDecisions` 含 `airdbg` 时: + +```bash +# 1. 启动调试会话 +python scripts/airplan.py --mode dbg --project . --sub start --task-id {tid} + +# 2. Dbg 按 7 步工作流调试(取证→定位→修复→验证) +# 3. Dbg 完成后,重新 finish: +python scripts/airplan.py --mode do --project . --sub finish --task-id {tid} --result AirPlan/state/airdo/tasks/{tid}/result.json +``` + +## AirXDB 路由 — GUI 截图取证 + +当 finish 返回 `routingDecisions` 含 `airxdb` 时: + +```bash +python scripts/airplan.py --mode xdb --project . --prefer auto --output task-{tid}-screenshot.png +``` +截图路径写入 result.json 的 `xdbSessions` 字段后重新 finish。 + +## AirNDB 路由 — 网络抓包取证 + +当 finish 返回 `routingDecisions` 含 `airndb` 时: + +```bash +python scripts/airplan.py --mode ndb --project . --sub enter +# 然后 tcpdump/tshark 抓包,存入 AirPlan/state/airndb/captures/ +``` +抓包路径写入 result.json 的 `ndbSessions` 字段后重新 finish。 + +## AirSDB 路由 — C/C++ 静态分析 + +当 finish 返回 `routingDecisions` 含 `airsdb` 时: + +```bash +python scripts/airplan.py --mode sdb --project . --backend cppcheck --target ./src +``` +分析报告路径写入 result.json 的 `sdbReports` 字段后重新 finish。 + +## AirRvr 路由 — 需求一致性审查 + +当 finish 返回 `routingDecisions` 含 `airrvr` 时: + +```bash +python scripts/airplan.py --mode rvr --project . --task-id {tid} --sub review +``` +审查通过后重新 finish,此时 Rvr 证据已写入 validations。 + +## 证据采集 — 辅助命令 + +**GUI 任务截图**: +```bash +python scripts/airplan.py --mode xdb --project . --prefer auto --output task-{tid}-screenshot.png +``` + +**网络任务抓包**: +```bash +python scripts/airplan.py --mode ndb --project . --sub enter +``` + +**代码任务静态分析**: +```bash +python scripts/airplan.py --mode sdb --project . --backend cppcheck --target ./src +``` + +**代码任务测试**: +```bash +python scripts/airplan.py --mode tst --project . --sub run --task-id {tid} --framework googletest +``` + +## UI 任务检测关键词(中英文) + +gui, ui, render, layout, dialog, osd, overlay, visual, widget, pane, toolbar, canvas, button, window, popup, menu, drm, kms, opengl, vulkan, frontend, react, vue, angular, web, css, html, 界面, 按钮, 对话框, 窗口, 菜单, 控件, 渲染, 布局 diff --git a/commands/eng.md b/commands/eng.md new file mode 100755 index 0000000..843a846 --- /dev/null +++ b/commands/eng.md @@ -0,0 +1,169 @@ +--- +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 +``` + +然后: +- 无活跃波次 → 执行 dispatch 流程(见下方 dispatch 段) +- 有活跃 Worker → 执行 monitor,不要盲目重新派发 +- 就绪结果出现时:merge through `--sub merge --result ` + +### 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 +``` + +dispatch 返回 `{waveId, taskIds, dispatchPath}`。**然后按以下步骤操作,不可跳过**: + +1. 从 dispatch 返回值中取 `taskIds` 列表 +2. 对 `taskIds` 中的**每个** `tid`,在同一条消息中并发调用 `Agent` 工具(**必须** `run_in_background: true`,否则 Eng 会被阻塞等待子 Agent 完成): + ``` + Agent( + description: "Do Worker: {tid}", + subagent_type: "general-purpose", + run_in_background: true, + prompt: """你是 AirDo Worker,任务 ID: {tid}。 + 项目路径: {project_root} + + ## 任务 + {task_text} + + ## 文件范围 + {files} + + ## 完成标准 + {done_when} + + ## 工作流程 + 1. 先运行 `python scripts/airplan.py --mode do --sub enter --task-id {tid} --task-text '{task_text}' --project .` 初始化 + 2. 读取项目文件,理解现有代码结构 + 3. 实现任务需求,修改/创建源代码文件 + 4. 完成后运行 `python scripts/airplan.py --mode do --sub finish --task-id {tid} --result ` + + ## 约束 + - 只修改属于此任务的文件 + - 完成后必须运行 finish 命令 + - 遇到无法解决的问题时返回 blocked 状态 + """ + ) + ``` +3. 每个 `Agent` 创建**独立子 Agent**,上下文不继承 Eng 对话(满足 INV-2 `fork_context=false`) +4. 所有 Worker spawn 完成后,进入 monitor 状态 +5. Worker 完成后,对其 `result.json` 调用 `--sub merge --result ` +6. merge 后 `task-graph.json` 节点状态自动同步为 DONE,不会被重复派发 + +**注意**: +- 使用 `Agent` 工具,不是 `Skill` 工具——`Skill` 只加载指令到当前上下文,不创建子 Agent +- `Agent` 创建的每个子 Agent 拥有独立上下文,天然满足上下文隔离 +- 多个 `Agent` 调用可以在同一条消息中并发发起 +- prompt 中的 task-text/files/done_when 从 `task-graph.json` 获取 + +### monitor + +spawn Worker 后,**不自己循环**。用 `ScheduleWakeup` 让系统每 5 分钟唤醒你一次。 + +``` +1. 所有 Worker 以 Agent(run_in_background: true) 启动后,立即返回当前波次状态给用户 +2. 调用 ScheduleWakeup(delaySeconds: 300, prompt: "检查 Worker 状态并处理") +3. 系统 5 分钟后唤醒你,唤醒时执行: + a. 运行 `python scripts/airplan.py --mode eng --project . --sub monitor` + b. 如果 readyToMergeCount > 0: 对每个完成的 Worker 运行 merge + c. 如果 stalledCount > 0: 检查 interventions,action=upgraded-to-airdbg 则启动 Dbg + d. 如果 activeWorkerCount > 0: 再次 ScheduleWakeup(300, ...) + e. 如果 activeWorkerCount == 0: 检查是否需要下一波 dispatch,不需要则结束 +4. 后台 Worker 完成时系统会自动 推送——收到后也可以立即处理 merge,不一定要等 5 分钟 +``` + +**ScheduleWakeup 调用格式**: +``` +ScheduleWakeup( + delaySeconds: 300, + reason: "Eng monitor: 检查 {n} 个活跃 Worker 状态", + prompt: "/eng monitor" +) +``` + +**停滞检测**(monitor_engine 代码自动执行): +- Worker state 文件 mtime > 5 分钟未更新 → 标记 stalled +- Worker 存活时间 > 2 小时 → 标记 wall-time-exceeded +- 资源压力(loadavg > 2× CPU 数)→ 暂停派发 + +### merge + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub merge --result +``` + +### intervene + +```bash +python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub intervene +``` + +仅用于无法通过常规监控或重新派发解决的硬阻塞。 + +**极端接管条件(全部满足才可介入)**: +1. 子代理陷入循环阻塞,修复预算已耗尽 +2. 问题已通过 AirDbg 定位到明确的根因 +3. 修复范围极小(≤5 行改动,如配置修正、路径修复) +4. 继续等待 Worker 重派发已无意义(至少尝试过 2 次) + +进入极端接管前,必须在引擎日志中记录: +`EXTREME_TAKEOVER: taskId=X, reason=Y, changes=Z` + +**极端接管时的专家插件调用(强制)**: +即使进入极端接管,也必须像 AirDo 一样调用相关专家插件: +- 修改代码前:**必须**调用 AirDbg 定位根因 +- GUI 相关变更:**必须**调用 AirXDB 采集修改前后截图 +- 网络相关变更:**必须**调用 AirNDB 采集抓包证据 +- C/C++ 代码变更:**必须**调用 AirSDB 执行静态分析 +- 修改完成后:**必须**调用 AirRvr 进行需求一致性审查 +- **禁止**跳过专家插件直接修改代码 + +**违规判定**:如果在正常调度流程中(Worker 可用且未阻塞)、修复预算未耗尽时、改动超 5 行、或未调用相关专家插件就猜测修复,均视为违规。 + +## 无人值守模式 + +- 每 300 秒重新检查活跃 Worker +- 当前波次收敛后自动派发下一波次 +- 仅在状态达到 completed 或用户决策阻塞时停止 \ No newline at end of file diff --git a/commands/ndb.md b/commands/ndb.md new file mode 100755 index 0000000..6049666 --- /dev/null +++ b/commands/ndb.md @@ -0,0 +1,31 @@ +--- +description: "AirPlan ndb mode - network debugger for packet capture and analysis" +argument-hint: "[enter|status]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /ndb + +AirNDB 是网络调试器。抓包分析、TLS 解密、pcapng 支持。 + +## 子命令 + +### enter — 初始化网络调试器 + +```bash +python scripts/airplan.py --mode ndb --project . --sub enter +``` + +返回 `{state_path}`。初始化 `AirPlan/state/airndb/` 目录结构。 + +### status — 查看调试器状态 + +```bash +python scripts/airplan.py --mode ndb --project . --sub status +``` + +制品:`AirPlan/state/airndb/captures/` + +## 集成路径 + +**Dbg 取证**:Dbg 在 `locate_root_cause` 步骤可调 ndb 获取网络证据。涉及 network/rtsp/http/tcp 的任务需要抓包证据。 diff --git a/commands/rvr.md b/commands/rvr.md new file mode 100755 index 0000000..c5a05c0 --- /dev/null +++ b/commands/rvr.md @@ -0,0 +1,53 @@ +--- +description: "AirPlan rvr mode - requirement reviewer with highRiskAudit and code-to-design" +argument-hint: "[review|status]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /rvr + +AirRvr 是需求审查器。基于原始需求文档对已完成任务进行独立审查,验证交付物与需求的一致性。含 highRiskAudit(生命周期/空指针/悬垂指针/异常安全/并发)和 Code-to-Design 逐行对照。 + +## 硬规则 + +1. **code-to-design 对照** — 每次审查必须逐行对照代码实现与设计文档(ADR、C4、需求) +2. **高风险审计** — 终审必须包含 lifecycle/nullPointer/danglingPointer/exceptionSafety/concurrency 五项检查 +3. **verdict 控制合并** — fail 阻止合并,conditional-pass 记录遗留项,pass 正常合并 +4. **deliveryVerdict = block-release** — eng dispatch 阻止所有后续派发 + +## 审查模式 + +| 模式 | 触发时机 | 审查范围 | +|------|---------|---------| +| per-task | 单个任务完成后 | 单任务 | +| per-wave | 波次所有任务完成后 | 整波 | +| per-milestone | 项目阶段结束时 | 全量(比对 requirements.md) | + +## 子命令 + +### review — 执行审查 + +```bash +python scripts/airplan.py --mode rvr --project . --sub review --task-id T-001 +``` + +返回 `{task_id, verdict, report_path}`。审查报告含 coverage/intentAlignment/regressionRisk/codeQuality/lifecycleHealth/runtimeStability/codeToDesignTable/highRiskAudit。 + +制品:`AirPlan/state/airrvr/reviews/{task_id}-{ts}.json` + +### status — 查看审查状态 + +```bash +python scripts/airplan.py --mode rvr --project . --sub status +``` + +## 集成路径 + +**Eng merge 时自动检查**:merge_worker_result Phase 1.5 自动调用 `get_verdict_for_task(task_id)`: +- `verdict=fail` → 抛出 `ValueError` 阻止合并 +- `verdict=conditional-pass` → 记录 residualItems 但允许合并 +- `deliveryVerdict=block-release` → dispatch_worker_group 阻止所有后续派发 + +**触发方式**:设置 `reviewPolicy.requireBeforeMerge=true` 或在 result 中设 `requireReview=true`。 + +**INVALIDATED 任务清理检查**(P1-21):ADR 变更级联失效后,手动调 `check_invalidated_cleanup()` 验证旧代码已清理。 diff --git a/commands/sdb.md b/commands/sdb.md new file mode 100755 index 0000000..2a21fc4 --- /dev/null +++ b/commands/sdb.md @@ -0,0 +1,49 @@ +--- +description: "AirPlan sdb mode - multi-language static analyzer (cppcheck/clang-tidy/clippy/go-vet/tsc/mypy)" +argument-hint: "[analyze]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /sdb + +AirSDB 是多语言静态分析器。支持六种后端和 diff 模式。 + +## 支持的后端 + +| 后端 | --backend 值 | 语言 | +|------|-------------|------| +| Cppcheck | `cppcheck` | C/C++ | +| Clang-Tidy | `clang-tidy` | C/C++ | +| Clippy | `clippy` | Rust | +| go vet + staticcheck | `go-vet` | Go | +| tsc --noEmit | `tsc` | TypeScript | +| mypy + ruff | `mypy` | Python | + +## 子命令 + +### 执行静态分析 + +```bash +python scripts/airplan.py --mode sdb --project . --backend cppcheck --target ./src +``` + +参数: +- `--backend`:分析后端(默认 `cppcheck`) +- `--target`:分析目标路径 + +返回每行一个 finding:`{file}:{line}: {severity}: {message}`。最多显示 10 个。 + +## diff 模式 + +两次扫描结果对比,高亮新增/消除的 finding: +```bash +# 第一次扫描 → 保存基线 +python scripts/airplan.py --mode sdb --project . --backend cppcheck --target ./src > baseline.txt +# 修改代码后第二次扫描 → Agent 读取两次结果对比 diff +``` + +## 集成路径 + +**Dbg 取证**:Dbg 在 `locate_root_cause` 步骤可调 sdb 获取静态分析证据。 + +**Do Worker 完成前**:Worker finish 前可调 sdb 验证代码质量。 diff --git a/commands/sec.md b/commands/sec.md new file mode 100755 index 0000000..c7d2616 --- /dev/null +++ b/commands/sec.md @@ -0,0 +1,57 @@ +--- +description: "AirPlan sec mode - security scanner for sensitive data detection" +argument-hint: "[scan|status]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /sec + +AirSec 是安全扫描器。扫描制品中的敏感数据(API 密钥、令牌、密码),支持 advisory(只报告)和 blocking(阻止合并)两种模式。 + +## 两种扫描模式 + +| 模式 | --sec-mode | 行为 | +|------|-----------|------| +| advisory | `advisory` | 只报告,不阻止 | +| blocking | `blocking` | 发现敏感数据时阻止合并 | + +## 子命令 + +### scan — 执行安全扫描 + +扫描单个文件: +```bash +python scripts/airplan.py --mode sec --project . --sub scan --task-id T-001 --scan-path ./src/config.cpp --sec-mode blocking +``` + +扫描整个目录: +```bash +python scripts/airplan.py --mode sec --project . --sub scan --task-id T-001 --scan-path ./src --sec-mode advisory +``` + +不传 `--scan-path` 时扫描 Worker result: +```bash +python scripts/airplan.py --mode sec --project . --sub scan --task-id T-001 --sec-mode blocking +``` + +参数: +- `--task-id`:关联任务 ID +- `--scan-path`:扫描目标路径(文件或目录,可选) +- `--sec-mode`:`advisory`(默认)或 `blocking` + +返回 `{task_id, clean, findings, whitelisted, mode}`。emit `sec.scan` 事件。 + +### status — 查看扫描状态 + +```bash +python scripts/airplan.py --mode sec --project . --sub status +``` + +## 集成路径 + +**Do Worker finish 前**:Worker 完成代码后、调 finish 前,先调 sec scan: +```bash +python scripts/airplan.py --mode sec --project . --sub scan --task-id {tid} --sec-mode blocking +``` + +**Eng merge 前**:merge_worker_result Phase 1 验证后检查 sec 扫描结果。`clean=false` 时阻止合并。 diff --git a/commands/tst.md b/commands/tst.md new file mode 100755 index 0000000..2a7a1b1 --- /dev/null +++ b/commands/tst.md @@ -0,0 +1,53 @@ +--- +description: "AirPlan tst mode - unified test runner for CTest/GoogleTest/pytest/jest/go test/cargo test" +argument-hint: "[run|status]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /tst + +AirTst 是测试运行器。支持多框架统一执行和结构化结果报告。 + +## 支持的框架 + +| 框架 | --framework 值 | 语言 | +|------|---------------|------| +| CTest / GoogleTest | `googletest` | C++ | +| pytest | `pytest` | Python | +| jest / vitest | `jest` | TypeScript | +| go test | `go` | Go | +| cargo test | `cargo` | Rust | + +## 子命令 + +### run — 执行测试 + +```bash +python scripts/airplan.py --mode tst --project . --sub run --task-id T-001 --framework googletest +``` + +参数: +- `--task-id`:关联任务 ID +- `--framework`:测试框架(见上表) + +返回 `{task_id, framework, total, passed, failed}`。emit `test.run` 事件。 + +制品:`AirPlan/state/airtst/reports/{task_id}-{ts}.json` + +### status — 查看测试状态 + +```bash +python scripts/airplan.py --mode tst --project . --sub status +``` + +## 集成路径 + +**Arc 注入的边界测试任务执行**:Arc 规划的 `T-TEST-*` 任务执行时调 tst run: +```bash +python scripts/airplan.py --mode tst --project . --sub run --task-id T-TEST-001 --framework googletest +``` + +**Do Worker 完成前自测**:Worker finish 前可调 tst 验证: +```bash +python scripts/airplan.py --mode tst --project . --sub run --task-id {tid} --framework {fw} +``` diff --git a/commands/xdb.md b/commands/xdb.md new file mode 100755 index 0000000..b8535cb --- /dev/null +++ b/commands/xdb.md @@ -0,0 +1,43 @@ +--- +description: "AirPlan xdb mode - GUI validator with DRM/KMS native screenshot and headless CI" +argument-hint: "[capture]" +allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]" +--- + +# /xdb + +AirXDB 是 GUI 验证器。支持 DRM/KMS 原生截图(kmsgrab)、Xvfb headless 截图、多后端自动降级。 + +## 截图后端 + +| 后端 | --prefer 值 | 适用场景 | +|------|------------|---------| +| KMS grab | `kms` | 嵌入式 DRM/KMS 显示 | +| Xvfb | `xvfb` | CI headless 环境 | +| fallback | `fallback` | 通用桌面环境 | +| auto | `auto` | 自动检测(默认,按 kms → xvfb → fallback 尝试) | + +## 子命令 + +### 截图采集 + +```bash +python scripts/airplan.py --mode xdb --project . --prefer auto --output screenshot.png +``` + +参数: +- `--prefer`:截图后端偏好(默认 `auto`) +- `--output`:输出文件名(默认 `screenshot.png`) + +返回 `{success, method, output, error}`。 + +## 集成路径 + +**Dbg 取证**:Dbg 在 `confirm_symptoms` 步骤调 xdb 获取截图证据: +```bash +python scripts/airplan.py --mode xdb --project . --prefer auto --output debug-screenshot.png +``` + +**Do Worker GUI 任务验证**:涉及 GUI/UI/Render 的任务,finish 前必须有截图证据。Worker 调 xdb 采集后,将截图路径写入 result.json 的 validations 字段。 + +**证据门控**:`EvidenceGatePolicy` 检测到任务含 GUI 关键词时,自动要求 xdb 截图证据。 diff --git a/do_mode.py b/do_mode.py new file mode 100755 index 0000000..9bc7d7d --- /dev/null +++ b/do_mode.py @@ -0,0 +1,260 @@ +""" +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", + # 中文关键词 + "界面", "UI", "界面设计", "前端", "界面开发", + "按钮", "对话框", "窗口", "菜单", "控件", + "渲染", "布局", "登录界面", "界面组件", +) + + +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']}") diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4f9cd78 --- /dev/null +++ b/install.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# AirPlan V2 安装脚本 +# 自动安装依赖并配置 Claude Code 插件 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_NAME="airplan-v2" +SKILLS_DIR="$HOME/.claude/skills" + +echo "==========================================" +echo "AirPlan V2 安装脚本" +echo "==========================================" + +# 检查 Python 版本 +echo "[1/6] 检查 Python 版本..." +python3 --version || { echo "错误: 需要 Python 3"; exit 1; } + +# 检查必要工具 +echo "[2/6] 检查必要工具..." +command -v git >/dev/null 2>&1 || { echo "错误: 需要 git"; exit 1; } + +# 创建 skills 目录 +echo "[3/6] 配置插件目录..." +mkdir -p "$SKILLS_DIR" + +# 创建符号链接或克隆 +if [ -L "$SKILLS_DIR/$PLUGIN_NAME" ]; then + echo "插件已存在: $SKILLS_DIR/$PLUGIN_NAME" +elif [ -d "$SKILLS_DIR/$PLUGIN_NAME" ]; then + echo "插件目录已存在,更新中..." + cd "$SKILLS_DIR/$PLUGIN_NAME" + git pull origin master +else + echo "从远程克隆插件..." + git clone http://git.airlongdian.fun/admin/AirPlan-V2.git "$SKILLS_DIR/$PLUGIN_NAME" +fi + +# 验证安装 +echo "[4/6] 验证安装..." +if [ ! -f "$SKILLS_DIR/$PLUGIN_NAME/.claude-plugin/plugin.json" ]; then + echo "错误: plugin.json 不存在" + exit 1 +fi + +if [ ! -f "$SKILLS_DIR/$PLUGIN_NAME/skills/airplan/SKILL.md" ]; then + echo "错误: SKILL.md 不存在" + exit 1 +fi + +# 检查依赖 +echo "[5/6] 检查依赖..." +# 检查系统依赖(可选) +command -v xvfb-run >/dev/null 2>&1 && echo " - xvfb-run: ✓" || echo " - xvfb-run: ✗ (可选,用于无头 GUI 测试)" +command -v ffmpeg >/dev/null 2>&1 && echo " - ffmpeg: ✓" || echo " - ffmpeg: ✗ (可选,用于视频处理)" +command -v cmake >/dev/null 2>&1 && echo " - cmake: ✓" || echo " - cmake: ✗ (可选,用于 C++ 项目构建)" + +echo "[6/6] 安装完成!" +echo "" +echo "==========================================" +echo "使用方法:" +echo " /arc - 架构规划" +echo " /eng - 调度引擎" +echo " /do - 任务执行" +echo " /dbg - 调试模式" +echo " /xdb - GUI 验证" +echo " /sdb - 静态分析" +echo " /ndb - 网络调试" +echo " /ctx - 上下文管理" +echo " /dep - 部署" +echo " /tst - 测试" +echo " /sec - 安全扫描" +echo " /rvr - 需求审查" +echo "==========================================" + +# 提示重启 Claude Code +echo "" +echo "提示: 请重启 Claude Code 以加载新插件" \ No newline at end of file diff --git a/lib/air_runtime/__init__.py b/lib/air_runtime/__init__.py new file mode 100755 index 0000000..3567c45 --- /dev/null +++ b/lib/air_runtime/__init__.py @@ -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" diff --git a/lib/air_runtime/adr_watcher.py b/lib/air_runtime/adr_watcher.py new file mode 100644 index 0000000..9b6061e --- /dev/null +++ b/lib/air_runtime/adr_watcher.py @@ -0,0 +1,109 @@ +""" +ADR 文件变更监控 — P1-21 ADR 变更自动检测。 +基于 SHA256 hash 对比检测 ADR 文件变更,自动触发级联失效。 +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class ADRChange: + """ADR 文件变更记录。""" + adr_id: str + kind: str # "new" | "superseded" | "modified" + path: str = "" + old_hash: str = "" + new_hash: str = "" + + +class ADRWatcher: + """监控 ADR 文件变更,自动触发级联失效。 + + AirEng 在每轮轮询时调用 detect_changes(), + 发现 superseded 或 modified 变更时自动触发 invalidate_by_adr()。 + """ + + def __init__(self, adr_dir: Path): + self._adr_dir = adr_dir + self._known_hashes: dict[str, str] = {} + + def snapshot(self) -> None: + """启动时记录所有 ADR 的内容 hash。""" + if not self._adr_dir.exists(): + return + for adr_file in sorted(self._adr_dir.glob("ADR-*.md")): + adr_id = self._extract_adr_id(adr_file) + self._known_hashes[adr_id] = hashlib.sha256( + adr_file.read_bytes() + ).hexdigest() + + def detect_changes(self) -> list[ADRChange]: + """对比当前 ADR hash 与已知 hash,返回变更列表。""" + if not self._adr_dir.exists(): + return [] + + changes: list[ADRChange] = [] + seen_ids: set[str] = set() + + for adr_file in sorted(self._adr_dir.glob("ADR-*.md")): + adr_id = self._extract_adr_id(adr_file) + seen_ids.add(adr_id) + current_hash = hashlib.sha256(adr_file.read_bytes()).hexdigest() + old_hash = self._known_hashes.get(adr_id) + + if old_hash is None: + changes.append(ADRChange( + adr_id=adr_id, kind="new", + path=str(adr_file), old_hash="", new_hash=current_hash, + )) + elif current_hash != old_hash: + status = self._parse_status(adr_file) + if status == "superseded": + changes.append(ADRChange( + adr_id=adr_id, kind="superseded", + path=str(adr_file), old_hash=old_hash, new_hash=current_hash, + )) + else: + changes.append(ADRChange( + adr_id=adr_id, kind="modified", + path=str(adr_file), old_hash=old_hash, new_hash=current_hash, + )) + self._known_hashes[adr_id] = current_hash + + # 检查被删除的 ADR + for adr_id in list(self._known_hashes.keys()): + if adr_id not in seen_ids: + changes.append(ADRChange( + adr_id=adr_id, kind="deleted", + path="", old_hash=self._known_hashes[adr_id], new_hash="", + )) + del self._known_hashes[adr_id] + + return changes + + @staticmethod + def _extract_adr_id(adr_file: Path) -> str: + """从文件名提取 ADR ID,如 'ADR-0005-ffmpeg-decode.md' → 'ADR-0005'。""" + match = re.match(r"(ADR-\d+)", adr_file.stem) + if match: + return match.group(1) + return adr_file.stem + + @staticmethod + def _parse_status(adr_file: Path) -> str: + """解析 ADR 文件中的 Status 字段。""" + try: + text = adr_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "unknown" + for line in text.splitlines(): + lower = line.lower().strip() + if lower.startswith("status:") or lower.startswith("status :"): + status = line.split(":", 1)[1].strip().lower() + return status + return "unknown" diff --git a/lib/air_runtime/contracts.py b/lib/air_runtime/contracts.py new file mode 100755 index 0000000..1506585 --- /dev/null +++ b/lib/air_runtime/contracts.py @@ -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 diff --git a/lib/air_runtime/deploy_runtime.py b/lib/air_runtime/deploy_runtime.py new file mode 100755 index 0000000..1ff7524 --- /dev/null +++ b/lib/air_runtime/deploy_runtime.py @@ -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)) diff --git a/lib/air_runtime/events.py b/lib/air_runtime/events.py new file mode 100755 index 0000000..b47baf7 --- /dev/null +++ b/lib/air_runtime/events.py @@ -0,0 +1,108 @@ +""" +事件日志模块 — V2 可观测性基础设施。 +JSONL 格式无限流式追加,与 state.json 互补:state.json 是当前快照,事件日志是完整时间线。 +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import os +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +logger = logging.getLogger(__name__) + +# 事件类型常量 — 所有 emit 调用必须使用常量,禁止字符串字面量 +TASK_DISPATCHED = "task.dispatched" +TASK_ENTERED = "task.entered" +TASK_FINISHED = "task.finished" +TASK_COMPLETED = "task.completed" +TASK_BLOCKED = "task.blocked" +MERGE_STARTED = "merge.started" +MERGE_COMPLETED = "merge.completed" +REPAIR_CREATED = "repair.created" +REPAIR_RESOLVED = "repair.resolved" +INTERVENTION_STALL = "intervention.stall" +XDB_CAPTURED = "xdb.captured" +DEBUG_SESSION = "debug.session" +CONTEXT_COMPACTED = "context.compacted" +DEPLOY_COMPLETED = "deploy.completed" +TEST_RUN = "test.run" +SEC_SCAN = "sec.scan" +REVIEW_SESSION = "review.session" +ENGINE_ENTERED = "engine.entered" +ENGINE_CYCLE = "engine.cycle" +ENG_REPLAN_TRIGGERED = "eng.replan.triggered" +ENG_BLOCKED = "eng.blocked" +WORKER_TIMEOUT = "worker.timeout" +WORKTREE_MERGE_CONFLICT = "worktree.merge.conflict" +ARC_REPLANNED = "arc.replanned" +ADR_CHANGE_DETECTED = "adr.change.detected" +ADR_INVALIDATION = "adr.invalidation" +ADR_UNFREEZED = "adr.unfreezed" +LOCK_ACQUIRED = "lock.acquired" +LOCK_RELEASED = "lock.released" +STALE_LOCK_CLEANED = "stale_lock.cleaned" + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class EventLog: + """结构化事件日志(JSONL 格式),支持轮转截断。 + + P1-GAP18: 原子轮转 — open(tmp)+os.replace,每 N 次 emit 检查一次避免每次读文件。 + """ + + MAX_LINES = 10000 + ROTATE_CHECK_EVERY = 128 # 每 128 次 emit 检查一次轮转 + + def __init__(self, path: Path, max_lines: int = MAX_LINES): + self._path = path + self._max_lines = max_lines + self._emit_count = 0 # P1-GAP18: 计数器,避免每次 emit 都读文件 + + def emit(self, event_type: str, payload: dict | None = None) -> None: + entry = { + "ts": now_iso(), + "type": event_type, + **(payload or {}), + } + self._path.parent.mkdir(parents=True, exist_ok=True) + with open(self._path, "a") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + self._emit_count += 1 + if self._emit_count % self.ROTATE_CHECK_EVERY == 0: + self._maybe_rotate() + + def _maybe_rotate(self) -> None: + """原子轮转:先写临时文件,再 os.replace。""" + if not self._path.exists(): + return + try: + with open(self._path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + if len(lines) <= self._max_lines: + return + + keep_count = self._max_lines // 2 + kept_lines = lines[-keep_count:] if len(lines) > keep_count else lines + + fd, tmp_path = tempfile.mkstemp(dir=self._path.parent, suffix=".tmp") + os.close(fd) + + try: + with open(tmp_path, "w", encoding="utf-8") as f: + f.writelines(kept_lines) + os.replace(tmp_path, self._path) + logger.info("rotated event log %s, kept %d/%d lines", self._path, len(kept_lines), len(lines)) + except Exception: + with contextlib.suppress(Exception): + os.unlink(tmp_path) + raise + except OSError: + pass diff --git a/lib/air_runtime/evidence_gate.py b/lib/air_runtime/evidence_gate.py new file mode 100755 index 0000000..4201124 --- /dev/null +++ b/lib/air_runtime/evidence_gate.py @@ -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"]) diff --git a/lib/air_runtime/installer.py b/lib/air_runtime/installer.py new file mode 100755 index 0000000..f39cbfa --- /dev/null +++ b/lib/air_runtime/installer.py @@ -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(), + } diff --git a/lib/air_runtime/io.py b/lib/air_runtime/io.py new file mode 100755 index 0000000..fbbd693 --- /dev/null +++ b/lib/air_runtime/io.py @@ -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 diff --git a/lib/air_runtime/lock.py b/lib/air_runtime/lock.py new file mode 100755 index 0000000..a90185d --- /dev/null +++ b/lib/air_runtime/lock.py @@ -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 diff --git a/lib/air_runtime/modes/__init__.py b/lib/air_runtime/modes/__init__.py new file mode 100755 index 0000000..fa3c9fb --- /dev/null +++ b/lib/air_runtime/modes/__init__.py @@ -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", +] diff --git a/lib/air_runtime/modes/arc_mode.py b/lib/air_runtime/modes/arc_mode.py new file mode 100755 index 0000000..c9d53d5 --- /dev/null +++ b/lib/air_runtime/modes/arc_mode.py @@ -0,0 +1,382 @@ +#!/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, ARC_REPLANNED +from air_runtime.paths import event_log_path +from air_runtime.utils import ordered_unique + + +# INV-16: 弱模型安全 — 危险词列表 +DANGEROUS_TASK_WORDS = [ + "清理", "清除", "删除所有", "重构整个", "重写全部", + "clear", "delete all", "remove all", "rewrite entire", "refactor whole", +] +DIR_LEVEL_FILE_SCOPE_PATTERNS = [ + "src/", "lib/", "include/", "tests/", "modules/", +] + +def _audit_task_safety(task_id: str, task_text: str, files_dirs: str, done_when: str) -> list[str]: + """INV-16: 扫描单个任务的危险词和宽泛文件范围,返回警告列表。""" + warnings = [] + text_lower = task_text.lower() + for word in DANGEROUS_TASK_WORDS: + if word.lower() in text_lower: + warnings.append(f"[{task_id}] 含危险词 '{word}' — 建议改为精确描述(如'修改 CMakeLists.txt 去掉 sipclient 依赖'而非'清理{word}')") + for pattern in DIR_LEVEL_FILE_SCOPE_PATTERNS: + if pattern in files_dirs and not any(f.endswith(ext) for ext in [".cpp", ".hpp", ".h", ".py", ".ts", ".md", ".json", ".txt", ".cmake"] for f in files_dirs.split(",")): + if not done_when or "不" not in done_when: + warnings.append(f"[{task_id}] 文件范围含目录级 '{pattern}' 但 done_when 无否定约束 — Worker 可能误解文件范围") + break + return warnings + + +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 = { + "dispatchFrozen": graph.dispatch_frozen, # P1-21 + "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, + "adrRefs": n.adr_refs} # P1-21 + 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: + # P1-21: 从 todo.md ADR 列提取 adr_refs + adr_refs = [] + if hasattr(t, "adr") and t.adr: + adr_refs = [a.strip() for a in t.adr.split(",") if a.strip()] + node = TaskNode( + id=t.task_id, status=t.status, task=t.task, + files_dirs=t.files_dirs, done_when=t.done_when, + adr_refs=adr_refs, + ) + 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"))) + + # INV-16: 弱模型安全审计 — 扫描所有 TODO 任务的危险词和宽泛文件范围 + safety_warnings = [] + for nid, node in graph.nodes.items(): + if node.status == "TODO": + safety_warnings.extend( + _audit_task_safety(nid, node.task, node.files_dirs, node.done_when) + ) + + _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], + "safetyWarnings": safety_warnings, # INV-16: 弱模型安全警告 + } + 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") + + # 保留 arcPhase 字段,避免 phase 被 reset 为 discussing + existing_state = safe_json_load(paths["state"]) or {} + state_payload = {**existing_state, "enabled": True, "updatedAt": now_iso(), + "projectRoot": str(project_root)} + atomic_json_write(paths["state"], state_payload) + 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)}") diff --git a/lib/air_runtime/modes/ctx_mode.py b/lib/air_runtime/modes/ctx_mode.py new file mode 100755 index 0000000..a1d26d5 --- /dev/null +++ b/lib/air_runtime/modes/ctx_mode.py @@ -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)}") diff --git a/lib/air_runtime/modes/dbg_mode.py b/lib/air_runtime/modes/dbg_mode.py new file mode 100755 index 0000000..cb74689 --- /dev/null +++ b/lib/air_runtime/modes/dbg_mode.py @@ -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 + 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)}") diff --git a/lib/air_runtime/modes/dep_mode.py b/lib/air_runtime/modes/dep_mode.py new file mode 100755 index 0000000..5418de8 --- /dev/null +++ b/lib/air_runtime/modes/dep_mode.py @@ -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)}") diff --git a/lib/air_runtime/modes/do_mode.py b/lib/air_runtime/modes/do_mode.py new file mode 100755 index 0000000..f6a60aa --- /dev/null +++ b/lib/air_runtime/modes/do_mode.py @@ -0,0 +1,370 @@ +""" +AirDo mode — V2 任务执行器。 +V2 改进:全专家插件强制路由(L1 代码级),task_id 注入防护,UI 任务 frontend-design Skill 路由(P1-20)。 +路由规则:GUI→XDB, network→NDB, C/C++→SDB, blocked/failed→Dbg, done→Rvr +""" + +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, TASK_ENTERED, TASK_FINISHED +from air_runtime.contracts import WorkerResult, now_iso +from air_runtime.utils import sanitize_task_id, session_stamp + + +# GUI 任务检测关键词 +GUI_INDICATORS = { + "gui", "ui", "render", "layout", "dialog", "osd", + "overlay", "visual", "screenshot", "display", + "widget", "pane", "toolbar", "settings_dialog", + "界面", "渲染", "布局", "按钮", "对话框", "窗口", "菜单", "控件", +} + +# 网络任务检测关键词 +NETWORK_INDICATORS = { + "network", "rtsp", "http", "tcp", "udp", "tls", + "dns", "proxy", "socket", "stream", "port", + "网络", "抓包", "rtmp", "webrtc", "sip", +} + +# C/C++ 文件扩展名 +CPP_EXTENSIONS = {".cpp", ".cxx", ".cc", ".c", ".hpp", ".hxx", ".h", ".h++"} + + +def _has_gui_indicators(task_text: str, files_dirs: str) -> bool: + text = f"{task_text} {files_dirs}".lower() + return any(kw in text for kw in GUI_INDICATORS) + + +def _has_network_indicators(task_text: str, files_dirs: str) -> bool: + text = f"{task_text} {files_dirs}".lower() + return any(kw in text for kw in NETWORK_INDICATORS) + + +def _has_cpp_files(files_changed: list[str]) -> bool: + return any( + any(f.endswith(ext) for ext in CPP_EXTENSIONS) + for f in files_changed + ) + + +# 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", + # 中文关键词 + "界面", "UI", "界面设计", "前端", "界面开发", + "按钮", "对话框", "窗口", "菜单", "控件", + "渲染", "布局", "登录界面", "界面组件", +) + + +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 核心改进:全专家插件强制路由。 + + 路由规则(按优先级): + 1. blocked/failed → AirDbg(调试定位根因) + 2. done 无证据 → AirDbg(审查验证) + 3. GUI 任务 → AirXDB(截图取证) + 4. 网络任务 → AirNDB(抓包取证) + 5. C/C++ 任务 → AirSDB(静态分析) + 6. 所有 done 任务 → AirRvr(需求一致性审查) + 无强制路由时才允许 merge。 + """ + 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 + + # 从 task-graph.json 获取任务描述用于分类 + task_text = "" + files_dirs = "" + tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json" + if tg_json.exists(): + from air_runtime.task_graph import TaskGraph + graph = TaskGraph.load(tg_json) + node = graph.nodes.get(tid) + if node: + task_text = node.task + files_dirs = node.files_dirs + + decisions = [] + + # 1. blocked/failed → 强制 AirDbg(最高优先级) + if status in ("blocked", "failed"): + decisions.append({ + "target": "airdbg", "forced": True, + "reason": f"status={status} — AirDbg mandatory before return", + }) + + # 2. done 但无实质验证 → 强制 AirDbg + elif status == "done": + if not result.validations and not result.files_changed: + decisions.append({ + "target": "airdbg", "forced": True, + "reason": "done without evidence — mandatory debug review", + }) + + # 3. GUI 任务 → 强制 AirXDB 截图 + if _has_gui_indicators(task_text, files_dirs): + xdb_sessions = result_data.get("xdbSessions") or result_data.get("xdb_sessions") or [] + if not xdb_sessions: + decisions.append({ + "target": "airxdb", "forced": True, + "reason": "GUI task requires screenshot evidence", + }) + + # 4. 网络任务 → 强制 AirNDB 抓包 + if _has_network_indicators(task_text, files_dirs): + ndb_sessions = result_data.get("ndbSessions") or result_data.get("ndb_sessions") or [] + if not ndb_sessions: + decisions.append({ + "target": "airndb", "forced": True, + "reason": "network task requires packet capture evidence", + }) + + # 5. C/C++ 任务 → 强制 AirSDB 静态分析 + if _has_cpp_files(result.files_changed): + sdb_reports = result_data.get("sdbReports") or result_data.get("sdb_reports") or [] + if not sdb_reports: + decisions.append({ + "target": "airsdb", "forced": True, + "reason": "C/C++ task requires static analysis", + }) + + # 6. 所有 done 任务 → 强制 AirRvr 审查(已完成则跳过) + rvr_reviewed = ( + result_data.get("rvrReviewed") or + result_data.get("rvr_reviewed") or + result_data.get("rvrReviews") or + result_data.get("rvr_reviews") or + [] + ) + if not rvr_reviewed: + decisions.append({ + "target": "airrvr", "forced": True, + "reason": "completed task requires requirements review", + }) + + # 无强制路由时才允许 merge + if not decisions: + decisions.append({"target": "merge", "forced": False}) + + # 持久化 + finalized = result.to_dict() + finalized["routingDecisions"] = decisions + finalized["routingDecision"] = decisions[0] # 向后兼容:主路由决策 + finalized["finalizedAt"] = now_iso() + atomic_json_write(paths["result"], finalized) + atomic_json_write(paths["worker_state"], { + "taskId": tid, "status": "finished", + "resultPath": str(paths["result"]), + "routingDecisions": decisions, + "routingDecision": decisions[0], + }) + + log = EventLog(event_log_path(project_root)) + log.emit(TASK_FINISHED, { + "taskId": tid, "status": status, + "routingTargets": [d["target"] for d in decisions], + }) + + # emit task.completed / task.blocked based on final status + if status == "done": + log.emit(TASK_COMPLETED, {"taskId": tid, + "routingTargets": [d["target"] for d in decisions]}) + 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"]), + "routingDecisions": decisions, + "routingDecision": decisions[0], + } + + +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": + task_text = getattr(args, "task_text", "") or "" + result = enter_worker(project_root, tid, task_text=task_text) + 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) + targets = [d["target"] for d in finalized.get("routingDecisions", [])] + print("airplan_mode=do") + print(f"task_id={finalized['taskId']}") + print(f"status={finalized['status']}") + print(f"routing_targets={','.join(targets)}") + print(f"routing_forced={any(d.get('forced') for d in finalized.get('routingDecisions', []))}") diff --git a/lib/air_runtime/modes/eng_mode.py b/lib/air_runtime/modes/eng_mode.py new file mode 100755 index 0000000..904c7ab --- /dev/null +++ b/lib/air_runtime/modes/eng_mode.py @@ -0,0 +1,898 @@ +""" +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, ENGINE_ENTERED, ENG_REPLAN_TRIGGERED, ENG_BLOCKED, \ + WORKER_TIMEOUT, REPAIR_CREATED, REPAIR_RESOLVED, \ + ADR_CHANGE_DETECTED, ADR_INVALIDATION, ADR_UNFREEZED, WORKTREE_MERGE_CONFLICT +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, CascadeReport, PlanDelta +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-21: 检查调度冻结(ADR 级联失效期间) + tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json" + if tg_json.exists(): + try: + graph = TaskGraph.load(tg_json) + if graph.dispatch_frozen: + log = EventLog(event_log_path(project_root)) + log.emit(ENG_BLOCKED, {"reason": "dispatch frozen — ADR cascade invalidation in progress"}) + return { + "blocked": True, + "reason": "dispatch frozen — ADR cascade invalidation in progress", + "waveId": "", + "taskIds": [], + } + except Exception: + pass + + # 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 _detect_adr_changes(project_root: Path, state: dict) -> list: + """P1-21: 检查 ADR 文件变更,返回需要级联失效的变更列表。""" + from air_runtime.adr_watcher import ADRWatcher, ADRChange + adr_dir = project_root / "AirPlan" / "docs" / "architecture" / "adr" + if not adr_dir.exists(): + return [] + + watcher = ADRWatcher(adr_dir) + # 从引擎状态恢复已知 hash + known = state.get("adrWatcherHashes", {}) + watcher._known_hashes = known + + # 首次无 snapshot → 先初始化 + if not known: + watcher.snapshot() + state["adrWatcherHashes"] = dict(watcher._known_hashes) + return [] + + changes = watcher.detect_changes() + # 持久化更新后的 hash + state["adrWatcherHashes"] = dict(watcher._known_hashes) + + # 只返回需要级联失效的变更 + return [c for c in changes if c.kind in ("superseded", "modified")] + + +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 + + # P1-21: ADR 变更自动检测 + adr_changes = _detect_adr_changes(project_root, state) + if adr_changes: + for change in adr_changes: + if change.kind in ("superseded", "modified"): + interventions.append({ + "adrId": change.adr_id, + "reason": f"adr-{change.kind}", + "action": "invalidate-by-adr", + }) + log = EventLog(event_log_path(project_root)) + log.emit(ADR_CHANGE_DETECTED, { + "adrId": change.adr_id, "kind": change.kind, + }) + + # 新增:检查 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) + + # Phase 6.5: 同步 task-graph.json 节点状态(P1-24) + tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json" + if tg_json.exists(): + from air_runtime.modes.arc_mode import _export_task_graph_json + graph = TaskGraph.load(tg_json) + if task_id in graph.nodes: + new_status = "DONE" if status == "done" else status.upper() + graph.nodes[task_id].status = new_status + _export_task_graph_json(graph, tg_json) + + 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。 + DAG 中 ready 为空意味着无任务可派发(全部完成或全部被依赖阻塞),不应 fallback 到 todo.md。""" + 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() + return ready[:max_count] # 空列表也是正确答案,不 fallback + except Exception: + pass + # fallback:仅在 task-graph.json 不存在时使用 todo.md + 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 spawn_workers(project_root: Path, task_ids: list[str]) -> list[dict]: + """T-1.21: 为每个 ready 任务准备 Agent 派发指令。 + + 使用 Agent 工具(非 Skill 工具)spawn 隔离子 Agent。 + 每个子 Agent 有自己的上下文,不继承 Eng 的完整对话。这正是 INV-2(fork_context=false)。 + + 返回 Agent 调用参数列表,Eng Agent 遍历列表逐个调用。 + """ + tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json" + graph = TaskGraph.load(tg_json) if tg_json.exists() else TaskGraph() + instructions = [] + for tid in task_ids: + node = graph.nodes.get(tid) + task_text = node.task if node else "" + files = node.files_dirs if node else "" + done_when = node.done_when if node else "" + + prompt_parts = [ + f"你是 AirDo Worker,任务 ID: {tid}。", + f"项目路径: {project_root}", + "", + f"## 任务", + f"{task_text}", + "", + f"## 文件范围", + f"{files}" if files else "(无限制)", + "", + f"## 完成标准", + f"{done_when}" if done_when else "编译通过,无回归", + "", + "## 工作流程", + "1. 先运行 `python scripts/airplan.py --mode do --sub enter --task-id {tid} --task-text '{task_text}' --project {project_root}` 初始化 Worker 状态", + "2. 读取项目文件,理解现有代码结构", + "3. 实现任务需求,修改/创建源代码文件", + "4. 完成后运行 `python scripts/airplan.py --mode do --sub finish --task-id {tid} --result AirPlan/state/airdo/tasks/{tid}/result.json`", + "", + "## 约束", + "- 只修改属于此任务的文件", + "- 完成后必须运行 finish 命令", + "- 遇到无法解决的问题时返回 blocked 状态", + ] + prompt = "\n".join(prompt_parts).format(tid=tid, task_text=task_text, project_root=project_root) + + instructions.append({ + "description": f"Do Worker: {tid}", + "subagent_type": "general-purpose", + "prompt": prompt, + "run_in_background": True, # 关键:后台运行,Eng 不阻塞 + "taskId": tid, + "taskText": task_text, + }) + return instructions + + +def handle_adr_invalidation(project_root: Path, adr_id: str) -> dict: + """P1-21: ADR 变更级联失效处理。 + + 10步流程: + 1. 加载 task-graph.json + 2. 调用 invalidate_by_adr() 级联失效 + 3. 冻结调度 + 4. 中止进行中的相关 Worker + 5. 创建回滚快照(git tag) + 6. git revert 已合并的旧代码 + 7. 写回更新后的 task-graph.json + 8. 等待 Arc 重新生成受影响部分的任务 + 9. apply_delta() 吸收新任务 + 10. 解冻调度 + """ + tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json" + if not tg_json.exists(): + return {"error": "task-graph.json not found", "adrId": adr_id} + + graph = TaskGraph.load(tg_json) + delta = PlanDelta() + + # 2-4: 级联失效 + report = graph.invalidate_by_adr(adr_id, delta) + log = EventLog(event_log_path(project_root)) + log.emit(ADR_INVALIDATION, { + "adrId": adr_id, + "invalidatedCompleted": report.invalidated_completed, + "terminatedInProgress": report.terminated_in_progress, + "cascadedDownstream": report.cascaded_downstream, + }) + + # 4: 中止进行中的相关 Worker + paths = _paths(project_root) + _ensure_dirs(paths) + state = safe_json_load(paths["state"]) or _init_state(project_root) + terminated_workers = [] + for worker in list(state.get("activeWorkers", [])): + if worker.get("taskId") in report.invalidated_task_ids: + terminated_workers.append(worker["taskId"]) + state["activeWorkers"] = [ + w for w in state.get("activeWorkers", []) + if w.get("taskId") not in report.invalidated_task_ids + ] + + # 5: 创建回滚快照 + rollback_ref = _create_rollback_snapshot(project_root, report.invalidated_task_ids) + report.rollback_ref = rollback_ref + delta.rollback_ref = rollback_ref + + # 6: git revert 已合并的旧代码(按 task_id 查找对应 commit) + revert_results = _git_revert_invalidated(project_root, report.invalidated_task_ids) + + # 6.5: 生成局部重规划请求(PartialReplanner) + from air_runtime.partial_replanner import PartialReplanner + replanner = PartialReplanner() + partial_delta = replanner.replan(graph, report.invalidated_task_ids) + replan_request_path = paths["plan_dir"] / f"replan-request-{session_stamp()}.json" + atomic_json_write(replan_request_path, partial_delta.replan_request) + + # 7: 写回更新后的 task-graph.json + from air_runtime.modes.arc_mode import _export_task_graph_json + _export_task_graph_json(graph, tg_json) + + # 更新引擎状态 + state["dispatchFrozen"] = True + state["adrInvalidationInProgress"] = { + "adrId": adr_id, + "startedAt": now_iso(), + "invalidatedTaskIds": report.invalidated_task_ids, + "rollbackRef": rollback_ref, + } + atomic_json_write(paths["state"], state) + + return { + "adrId": adr_id, + "cascadeReport": { + "invalidatedCompleted": report.invalidated_completed, + "terminatedInProgress": report.terminated_in_progress, + "cascadedDownstream": report.cascaded_downstream, + "rollbackRef": rollback_ref, + "invalidatedTaskIds": report.invalidated_task_ids, + }, + "terminatedWorkers": terminated_workers, + "revertResults": revert_results, + "replanRequestPath": str(replan_request_path), + "nextStep": "arc-replan-then-unfreeze", + } + + +def _create_rollback_snapshot(project_root: Path, invalidated_task_ids: list[str]) -> str: + """P1-21: 为失效任务创建 git tag 回滚点。""" + import subprocess + ref = f"airplan/adr-invalidate-{session_stamp()}" + try: + subprocess.run( + ["git", "tag", ref], + cwd=project_root, capture_output=True, timeout=30, + ) + except Exception: + pass + return ref + + +def _git_revert_invalidated(project_root: Path, invalidated_task_ids: list[str]) -> list[dict]: + """P1-21: 尝试 git revert 已合并的失效任务对应的 commit。""" + import subprocess + results = [] + for tid in invalidated_task_ids: + try: + # 查找包含 task_id 的 commit + r = subprocess.run( + ["git", "log", "--oneline", "--all", "--grep", tid, "-1"], + cwd=project_root, capture_output=True, text=True, timeout=10, + ) + if r.returncode == 0 and r.stdout.strip(): + commit_hash = r.stdout.strip().split()[0] + rv = subprocess.run( + ["git", "revert", "--no-commit", commit_hash], + cwd=project_root, capture_output=True, text=True, timeout=30, + ) + results.append({"taskId": tid, "commit": commit_hash, "reverted": rv.returncode == 0}) + if rv.returncode == 0: + subprocess.run( + ["git", "commit", "-m", f"AirPlan: revert invalidated task {tid}"], + cwd=project_root, capture_output=True, timeout=10, + ) + else: + results.append({"taskId": tid, "commit": None, "reverted": False, "reason": "no commit found"}) + except Exception as e: + results.append({"taskId": tid, "commit": None, "reverted": False, "reason": str(e)}) + return results + + +def unfreeze_after_replan(project_root: Path, new_task_graph_path: Path | None = None) -> dict: + """P1-21: Arc 重新生成受影响部分后,apply_delta + 解冻调度。""" + paths = _paths(project_root) + _ensure_dirs(paths) + + tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json" + if not tg_json.exists(): + return {"error": "task-graph.json not found"} + + graph = TaskGraph.load(tg_json) + + # 如果 Arc 生成了新的任务图,增量合并 + if new_task_graph_path and new_task_graph_path.exists(): + new_graph = TaskGraph.load(new_task_graph_path) + delta = new_graph.diff(graph) + graph.apply_delta(delta) + + # 解冻 + graph.unfreeze_dispatch() + from air_runtime.modes.arc_mode import _export_task_graph_json + _export_task_graph_json(graph, tg_json) + + # 更新引擎状态 + state = safe_json_load(paths["state"]) or _init_state(project_root) + state["dispatchFrozen"] = False + adr_info = state.pop("adrInvalidationInProgress", {}) + atomic_json_write(paths["state"], state) + + log = EventLog(event_log_path(project_root)) + log.emit(ADR_UNFREEZED, {"previousAdrInvalidation": adr_info}) + + return {"frozen": False, "readyTasks": graph.ready_tasks()} + + +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']}") diff --git a/lib/air_runtime/modes/eng_orchestrator.py b/lib/air_runtime/modes/eng_orchestrator.py new file mode 100755 index 0000000..81da883 --- /dev/null +++ b/lib/air_runtime/modes/eng_orchestrator.py @@ -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']}") diff --git a/lib/air_runtime/modes/merge_pipeline.py b/lib/air_runtime/modes/merge_pipeline.py new file mode 100755 index 0000000..e842938 --- /dev/null +++ b/lib/air_runtime/modes/merge_pipeline.py @@ -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*") + + +def _strip_merged_refs(row: str) -> str: + """去除行内所有已存在的 引用,避免重复 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" " + + 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 diff --git a/lib/air_runtime/modes/ndb_mode.py b/lib/air_runtime/modes/ndb_mode.py new file mode 100755 index 0000000..79c47f5 --- /dev/null +++ b/lib/air_runtime/modes/ndb_mode.py @@ -0,0 +1 @@ +from air_runtime.modes.xdb_sdb_ndb_modes import ndb_main as main diff --git a/lib/air_runtime/modes/rvr_mode.py b/lib/air_runtime/modes/rvr_mode.py new file mode 100755 index 0000000..02c4a3f --- /dev/null +++ b/lib/air_runtime/modes/rvr_mode.py @@ -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)}") diff --git a/lib/air_runtime/modes/sdb_mode.py b/lib/air_runtime/modes/sdb_mode.py new file mode 100755 index 0000000..9adc98a --- /dev/null +++ b/lib/air_runtime/modes/sdb_mode.py @@ -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}") diff --git a/lib/air_runtime/modes/sec_mode.py b/lib/air_runtime/modes/sec_mode.py new file mode 100755 index 0000000..8778faa --- /dev/null +++ b/lib/air_runtime/modes/sec_mode.py @@ -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)}") diff --git a/lib/air_runtime/modes/tst_mode.py b/lib/air_runtime/modes/tst_mode.py new file mode 100755 index 0000000..97cd6bd --- /dev/null +++ b/lib/air_runtime/modes/tst_mode.py @@ -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)}") diff --git a/lib/air_runtime/modes/xdb_mode.py b/lib/air_runtime/modes/xdb_mode.py new file mode 100755 index 0000000..a35e781 --- /dev/null +++ b/lib/air_runtime/modes/xdb_mode.py @@ -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}") \ No newline at end of file diff --git a/lib/air_runtime/modes/xdb_sdb_ndb_modes.py b/lib/air_runtime/modes/xdb_sdb_ndb_modes.py new file mode 100755 index 0000000..3b5b441 --- /dev/null +++ b/lib/air_runtime/modes/xdb_sdb_ndb_modes.py @@ -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)}") diff --git a/lib/air_runtime/partial_replanner.py b/lib/air_runtime/partial_replanner.py new file mode 100644 index 0000000..7f8b01f --- /dev/null +++ b/lib/air_runtime/partial_replanner.py @@ -0,0 +1,106 @@ +""" +局部重规划 — P1-21 仅重新生成受 ADR 变更影响的任务子集。 +替代全量重规划,保留未受影响任务的接口约束。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from air_runtime.task_graph import TaskGraph, TaskNode, PlanDelta + + +@dataclass +class Interface: + """未受影响任务暴露的公共接口约束。""" + task_id: str + write_set: list[str] = field(default_factory=list) + adr_refs: list[str] = field(default_factory=list) + + +@dataclass +class ReplanContext: + """受影响任务的上下文信息,供 Arc 局部重规划使用。""" + task_id: str + task: str + files_dirs: str + done_when: str + write_set: list[str] = field(default_factory=list) + adr_refs: list[str] = field(default_factory=list) + status: str = "" + + +class PartialReplanner: + """仅重新生成受 ADR 变更影响的任务子集。 + + 与 incremental_replan_mode 的区别: + - incremental_replan_mode: 全量重建 DAG 再 diff + - PartialReplanner: 只对受影响部分重新规划,保留稳定接口约束 + """ + + def replan(self, graph: TaskGraph, invalidated_ids: list[str], + new_adr_path: Path | None = None) -> PlanDelta: + """局部重规划:仅生成受影响任务的替代任务。 + + Args: + graph: 当前任务图(已包含 INVALIDATED 标记) + invalidated_ids: 被 ADR 变更级联失效的任务 ID 列表 + new_adr_path: 新 ADR 文件路径(可选,供 Arc 参考) + """ + delta = PlanDelta() + + # 1. 收集受影响任务的上下文 + affected_context = self._collect_affected_context(graph, invalidated_ids) + + # 2. 提取未受影响任务的稳定接口 + stable_interfaces = self._extract_stable_interfaces(graph, set(invalidated_ids)) + + # 3. 生成局部重规划指令文件(供 Arc 读取) + replan_request = { + "type": "partial-replan", + "invalidatedTaskIds": invalidated_ids, + "affectedContext": [ctx.__dict__ for ctx in affected_context], + "stableInterfaces": [iface.__dict__ for iface in stable_interfaces], + "newAdrPath": str(new_adr_path) if new_adr_path else None, + } + + # 4. 构建增量 delta + # removed_tasks 已在 invalidate_by_adr 中填充 + # added_tasks 留空——由 Arc 读取 replan-request.json 后生成新任务 + delta.replan_request = replan_request + + return delta + + def _collect_affected_context(self, graph: TaskGraph, + invalidated_ids: list[str]) -> list[ReplanContext]: + """收集受影响任务的上下文。""" + contexts = [] + for tid in invalidated_ids: + node = graph.nodes.get(tid) + if node: + contexts.append(ReplanContext( + task_id=node.id, + task=node.task, + files_dirs=node.files_dirs, + done_when=node.done_when, + write_set=list(node.write_set), + adr_refs=list(node.adr_refs), + status=node.status, + )) + return contexts + + def _extract_stable_interfaces(self, graph: TaskGraph, + invalidated_ids: set) -> list[Interface]: + """提取未受影响 DONE 任务的接口约束,确保重规划不破坏依赖。""" + interfaces = [] + for nid, node in graph.nodes.items(): + if nid not in invalidated_ids and node.status == "DONE": + if node.write_set or node.adr_refs: + interfaces.append(Interface( + task_id=node.id, + write_set=list(node.write_set), + adr_refs=list(node.adr_refs), + )) + return interfaces diff --git a/lib/air_runtime/paths.py b/lib/air_runtime/paths.py new file mode 100755 index 0000000..00834c2 --- /dev/null +++ b/lib/air_runtime/paths.py @@ -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", + ] diff --git a/lib/air_runtime/project_bootstrap.py b/lib/air_runtime/project_bootstrap.py new file mode 100755 index 0000000..1e31d45 --- /dev/null +++ b/lib/air_runtime/project_bootstrap.py @@ -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} \ No newline at end of file diff --git a/lib/air_runtime/review.py b/lib/air_runtime/review.py new file mode 100755 index 0000000..aec1713 --- /dev/null +++ b/lib/air_runtime/review.py @@ -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) \ No newline at end of file diff --git a/lib/air_runtime/review_runtime.py b/lib/air_runtime/review_runtime.py new file mode 100755 index 0000000..1195234 --- /dev/null +++ b/lib/air_runtime/review_runtime.py @@ -0,0 +1,214 @@ +""" +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 + } + + def check_invalidated_cleanup(self, invalidated_task_ids: list[str]) -> dict: + """P1-21: 检查 INVALIDATED 任务的代码是否已清理(无残留)。""" + from air_runtime.io import safe_json_load + residual = [] + for tid in invalidated_task_ids: + # 检查是否有残留的 result 文件(说明旧代码未被 revert) + result_dir = self._state_dir.parent / "airdo" / "tasks" / tid + if result_dir.exists(): + result_file = result_dir / "result.json" + if result_file.exists(): + data = safe_json_load(result_file) + if data and data.get("status") == "done": + residual.append({"taskId": tid, "reason": "done result still exists — code may not be reverted"}) + return { + "cleaned": len(residual) == 0, + "residualCount": len(residual), + "residualDetails": residual, + } + + @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", []), + ) diff --git a/lib/air_runtime/sdb_backends.py b/lib/air_runtime/sdb_backends.py new file mode 100755 index 0000000..f169428 --- /dev/null +++ b/lib/air_runtime/sdb_backends.py @@ -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: ::: warning: [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): + :: + """ + 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: (,): error TS: + 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: (,): error TS1234: + """ + 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(), +} diff --git a/lib/air_runtime/sec_runtime.py b/lib/air_runtime/sec_runtime.py new file mode 100755 index 0000000..b3ad2a8 --- /dev/null +++ b/lib/air_runtime/sec_runtime.py @@ -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' 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 diff --git a/lib/air_runtime/task_graph.py b/lib/air_runtime/task_graph.py new file mode 100755 index 0000000..3c1d120 --- /dev/null +++ b/lib/air_runtime/task_graph.py @@ -0,0 +1,315 @@ +""" +动态任务依赖图(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 | INVALIDATED + 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: 边界测试强制标记 + adr_refs: list[str] = field(default_factory=list) # P1-21: ADR→任务溯源链 + + +@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 CascadeReport: + """P1-21: ADR 变更级联失效报告。""" + invalidated_completed: int = 0 + terminated_in_progress: int = 0 + cascaded_downstream: int = 0 + rollback_ref: str = "" + invalidated_task_ids: list[str] = 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) + rollback_ref: str = "" # P1-21: 回滚快照引用 + + +class TaskGraph: + """动态任务依赖图,支持增量更新和全量替换。""" + + def __init__(self): + self.nodes: dict[str, TaskNode] = {} + self.edges: list[Edge] = [] + self.dispatch_frozen: bool = False # P1-21: 调度冻结 + + 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,保留已完成任务状态。 + INVALIDATED 状态不保留(已被级联失效标记的任务在全量替换时重置)。""" + done_status = {tid: n.status for tid, n in self.nodes.items() + if n.status in ("DONE", "DISPATCHED")} + 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 的任务。调度冻结时返回空。""" + if self.dispatch_frozen: + return [] + 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 + graph.dispatch_frozen = data.get("dispatchFrozen", False) + 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), + adr_refs=list(nd.get("adrRefs", [])), + ) + 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 + # INVALIDATED 可覆盖 DONE/DISPATCHED(P1-21: ADR 级联失效) + if existing_status in ("DISPATCHED", "DONE") and node.status != "INVALIDATED": + 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) + + # P1-21: ADR 级联失效 + + def tasks_by_adr(self, adr_id: str) -> list[TaskNode]: + """查找所有引用指定 ADR 的任务(含已完成)。""" + return [n for n in self.nodes.values() if adr_id in n.adr_refs] + + def _find_downstream(self, task_ids: list[str]) -> list[str]: + """BFS 遍历下游依赖任务。""" + visited: set[str] = set() + queue = list(task_ids) + while queue: + current = queue.pop(0) + if current in visited: + continue + visited.add(current) + node = self.nodes.get(current) + if node: + for target in node.out_edges: + if target not in visited: + queue.append(target) + # 排除起点自身 + return [tid for tid in visited if tid not in set(task_ids)] + + def invalidate_by_adr(self, adr_id: str, delta: PlanDelta) -> CascadeReport: + """P1-21: ADR 变更时级联失效所有相关任务。""" + affected = self.tasks_by_adr(adr_id) + completed = [t for t in affected if t.status == "DONE"] + in_progress = [t for t in affected if t.status == "DISPATCHED"] + pending = [t for t in affected if t.status == "TODO"] + + # 1. 冻结调度 + self.dispatch_frozen = True + + # 2. 标记已完成任务为 INVALIDATED + for t in completed: + t.status = "INVALIDATED" + delta.removed_tasks.append(t.id) + + # 3. 标记进行中任务为 INVALIDATED(调用方负责中止 Worker) + for t in in_progress: + t.status = "INVALIDATED" + delta.removed_tasks.append(t.id) + + # 4. 标记 ADR 直接关联的 TODO 任务为 INVALIDATED + for t in pending: + t.status = "INVALIDATED" + delta.removed_tasks.append(t.id) + + # 5. 级联失效下游 + downstream_ids = self._find_downstream([t.id for t in completed + in_progress + pending]) + cascaded = [] + for tid in downstream_ids: + node = self.nodes.get(tid) + if node and node.status in ("TODO", "DISPATCHED"): + node.status = "INVALIDATED" + delta.removed_tasks.append(tid) + cascaded.append(tid) + + # 6. 回滚快照引用(由调用方在 git revert 后填入) + all_invalidated = [t.id for t in completed + in_progress + pending] + cascaded + + return CascadeReport( + invalidated_completed=len(completed), + terminated_in_progress=len(in_progress), + cascaded_downstream=len(cascaded), + rollback_ref=delta.rollback_ref, + invalidated_task_ids=all_invalidated, + ) + + def unfreeze_dispatch(self) -> None: + """P1-21: 解冻调度,在 Arc 重新生成受影响任务后调用。""" + self.dispatch_frozen = False diff --git a/lib/air_runtime/test_runtime.py b/lib/air_runtime/test_runtime.py new file mode 100755 index 0000000..130ba3b --- /dev/null +++ b/lib/air_runtime/test_runtime.py @@ -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__) diff --git a/lib/air_runtime/todo_parser.py b/lib/air_runtime/todo_parser.py new file mode 100755 index 0000000..f70b4b5 --- /dev/null +++ b/lib/air_runtime/todo_parser.py @@ -0,0 +1,100 @@ +""" +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;若没有则尝试从开头提取 G-001/H-000 类短 ID + tid_match = re.match(r"\[([A-Za-z0-9_\-\.]+)\]", task_cell) + if tid_match: + task_id = tid_match.group(1) + else: + short_match = re.match(r"^([A-Z]+-\d+[a-z]*)", task_cell) + task_id = short_match.group(1) if short_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 \ No newline at end of file diff --git a/lib/air_runtime/utils.py b/lib/air_runtime/utils.py new file mode 100755 index 0000000..a42ad9b --- /dev/null +++ b/lib/air_runtime/utils.py @@ -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 "