From 4486b09b73ebc2e07372d1f84a9ba6c5ef2a204c Mon Sep 17 00:00:00 2001 From: tzhouam Date: Fri, 28 Aug 2026 15:24:01 +0800 Subject: [PATCH] doc(SPEC): cover the mixed-mode contract merge; main goes green again The agent/pr2-public-mixed-mode-contract merge (d47a0fa) landed without its SPEC updates, leaving main's freshness gate red: 18 stale pages and 4 uncovered modules. This re-verifies every page against the merged code: - Four new pages: contract.md (the one-way public consumer surface), direct_routing.md (tables+mechanism out of thin_mcp_server, known debt named), engine/worktrees.md (identity/verification/liveness, each a fixed real incident), idempotency.md (one run AND one execution per key; narrow scope by design). - Substantial corrections: mcp_policy (authorize_repo_path's identity+containment, post refused-not-restored, FULL_SHA_RE), mcp_server (unconditional ALLOW_POST=0, (run_id,created) enqueue, configure_strict_repo removal, reap, unknown-run poll contract), cli (claim-based at-most-once, frozen repo_path precedence), run_status (CAS claim vs single-writer, the narrow interrupted re-arm exception), thin_mcp_server (halved; split has happened), engine/steps/pr (one-head-governs-everything, expected_head_sha hard gate), task_spec, engine/steps/_common (repo_path is no longer side-effect-free), review/planner + engine/steps/review (planner_error causes, review_verdict as a state field), engine/lifecycle (require_file_locking fail-closed split), engine/agent_runtime (member_unreachable vs outcome_blocked routing predicate), config (allowed_repo_roots, idem_retention_days). - Date-bump re-verification where content already matched: intent, providers/{base,deepseek,harness_llm,registry}. check_spec_freshness --strict: 71 pages, 0 stale, 0 uncovered; doc links and citations green; full suite green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwmmhpCK9pfEsWWzUBx3GB --- doc/architecture/SPEC/cli.md | 21 +++++- doc/architecture/SPEC/config.md | 14 ++-- doc/architecture/SPEC/contract.md | 62 ++++++++++++++++++ doc/architecture/SPEC/direct_routing.md | 61 +++++++++++++++++ doc/architecture/SPEC/engine/agent_runtime.md | 15 +++-- doc/architecture/SPEC/engine/lifecycle.md | 20 ++++-- doc/architecture/SPEC/engine/steps/_common.md | 12 +++- doc/architecture/SPEC/engine/steps/pr.md | 37 +++++++---- doc/architecture/SPEC/engine/steps/review.md | 12 +++- doc/architecture/SPEC/engine/worktrees.md | 58 +++++++++++++++++ doc/architecture/SPEC/idempotency.md | 65 +++++++++++++++++++ doc/architecture/SPEC/intent.md | 2 +- doc/architecture/SPEC/mcp_policy.md | 33 ++++++++-- doc/architecture/SPEC/mcp_server.md | 36 ++++++++-- doc/architecture/SPEC/providers/base.md | 2 +- doc/architecture/SPEC/providers/deepseek.md | 2 +- .../SPEC/providers/harness_llm.md | 2 +- doc/architecture/SPEC/providers/registry.md | 2 +- doc/architecture/SPEC/review/planner.md | 12 +++- doc/architecture/SPEC/run_status.md | 38 ++++++++--- doc/architecture/SPEC/task_spec.md | 18 +++-- doc/architecture/SPEC/thin_mcp_server.md | 30 ++++++--- 22 files changed, 484 insertions(+), 70 deletions(-) create mode 100644 doc/architecture/SPEC/contract.md create mode 100644 doc/architecture/SPEC/direct_routing.md create mode 100644 doc/architecture/SPEC/engine/worktrees.md create mode 100644 doc/architecture/SPEC/idempotency.md diff --git a/doc/architecture/SPEC/cli.md b/doc/architecture/SPEC/cli.md index 266a664c..69abb5aa 100644 --- a/doc/architecture/SPEC/cli.md +++ b/doc/architecture/SPEC/cli.md @@ -1,8 +1,8 @@ # cli/ —— 规范 - + -`LOC ~1240(6 个文件) · 接口 + 编排门面 · refactor-status: ok` +`LOC ~1420(6 个文件) · 接口 + 编排门面 · refactor-status: ok` ## 职责 flag CLI 与 `Copilot` 门面:解析 → 过门 → 执行;并持有 run 目录、RunTrace、notifier @@ -54,6 +54,19 @@ flag CLI 与 `Copilot` 门面:解析 → 过门 → 执行;并持有 run 目 **抬高档位永远不会扩大权限**(`tier` 仍然由 `kind` 推导)。 - CLI 主路径在**创建 run 目录之前**过门,所以被放弃的计划不留下任何东西。 MCP 的预约形状(先建、后规划)是**刻意不同**的 —— 见 `mcp_server.md`。 +- **`reserve_run` 返回 `(run_id, created)`,签名变更是契约的一部分**: + 调用方只在 `created` 为真时入队(idempotency:命中既有键返回既有 run)。 + 预约时即经 `mcp_policy.authorize_repo_path` 授权/固化 `spec.repo_path`; + 带键的预约走 `_reserve_keyed`/`_reserve_new`(重拉-冲突-已决三分)。 +- **执行的 at-most-once 是 claim,不是锁**:`_execute_reserved_locked` + 先 `rs.claim_for_execution(run_dir, child_pid)` —— 输掉 claim 的子进程 + 不规划、不执行、不写状态,打印后 0 码退出(关掉"server 死后重试 + + 旧子进程醒来"的双评审竞态)。 +- **冻结的 `spec.repo_path` 压过环境解析**:`_repo_path_for(spec)` 先看 + 冻结绑定、再落回 alias 解析(`_resolve_repo_path` 现在只管 alias); + `resolve()` 与 `_execute` 的 state seed 都走它。 +- 阻塞原因随终局落盘:`last_blocked_reason` 进入 `run_status.json` 的 + note —— 以前只打在子进程 console 上就被丢掉。 ## 边界 —— 不属于这里 不含 step 逻辑、不含仓库知识字面量、不含 LLM prompt。**只做编排接线。** @@ -68,7 +81,9 @@ flag CLI 与 `Copilot` 门面:解析 → 过门 → 执行;并持有 run 目 新的纯格式化器 → utils.py。 ## 测试 -`test_cli.py`、`test_phase_b.py`、`test_chat.py`、`test_ui.py`。 +`test_cli.py`、`test_phase_b.py`、`test_chat.py`、`test_ui.py`; +预约/claim/repo_path 冻结:`test_mcp.py`、`test_idempotency.py`、 +`test_contract.py`。 ## 重构备注 拆分**已完成**(它曾是内聚拆分候选)。`Copilot` 类完整留在 `copilot.py`, diff --git a/doc/architecture/SPEC/config.md b/doc/architecture/SPEC/config.md index c928f9fe..bf5f6bbd 100644 --- a/doc/architecture/SPEC/config.md +++ b/doc/architecture/SPEC/config.md @@ -1,8 +1,8 @@ # config.py —— 规范 - + -`LOC ~630 · 配置 · refactor-status: oversized` +`LOC ~658 · 配置 · refactor-status: oversized` ## 职责 从 env / `.env` 加载的 `Settings`(pydantic-settings),以及把本次 run 的档位与后端 @@ -13,12 +13,18 @@ (reviewer 模型、远端 CI 轮次/预算、`github_token` 推送凭据)、agent 运行时、 ensemble、MoA、评审深度与按 pass 路由、Strict 后端选择、profile、patch 触发器、 metrics 与升级,提供带类型字段和安全默认值;外加 PR4d 知识运行时 cutover -(`imx_knowledge_runtime`)与 manifest 展开回退(`expansion_env()`)。 +(`imx_knowledge_runtime`)、manifest 展开回退(`expansion_env()`),以及 +MCP `repo_path` 授权面与 idempotency 保留期: +`mcp_allowed_repo_roots`(`MCP_ALLOWED_REPO_ROOTS`,空 = 只有已配置的 +checkout —— 最小权限;放宽是**运维**决定,不是调用方的)与 +`idem_retention_days`(默认 30,圈住 `.idem/` 索引)。 ## 公开契约 带全部可调项的 `Settings`;`reviewer` / `intent`(回退到 `agent_model`); `repo_path(name)`;`model_for(mode)`;`tier_target(role)` → `ResolvedTarget`; -`expansion_env()`;`knowledge_runtime_repos`;以及 `strict_backend` 校验器。 +`expansion_env()`;`knowledge_runtime_repos`;`allowed_repo_roots` +(配置列表,未设时 = 已配置 checkout 本身 —— `mcp_policy.authorize_repo_path` +的圈禁判据即此属性);以及 `strict_backend` 校验器。 ## 不变量(**A5**、**C2**、**B1**) - 密钥只经 env / `.env`(被 git 忽略,**绝不提交**)。 diff --git a/doc/architecture/SPEC/contract.md b/doc/architecture/SPEC/contract.md new file mode 100644 index 00000000..11dd1ff4 --- /dev/null +++ b/doc/architecture/SPEC/contract.md @@ -0,0 +1,62 @@ +# contract.py —— 规范 + + + +`LOC ~181 · 跨仓库公开契约面 · refactor-status: ok` + +## 职责 +本 copilot 的**对外消费契约**:一个 review bot(或任何宿主)被允许 import 的 +一切都住在这里,此外的任何 import 都不受支持。它的存在源于一次真实事故: +下游仓库曾经用 `importlib` 伸进 `thin_mcp_server` 拿四个 `_direct_*` 私有名, +一次重命名就让另一个仓库在运行期断裂、且没有任何构建期信号。 + +## 公开契约(`__all__`) +`DIRECT_API_VERSION` / `STRICT_API_VERSION`(`"1.0.0"`,形状变化到消费方 +必须察觉时递增);`capabilities(max_strict_workers=1, +supports_file_locking=True) -> dict`(版本/能力握手:`supports_expected_head`、 +`supports_structured_result`、`supports_post_false`、`supports_file_locking`、 +`max_strict_workers`);`build_review_result(run_dir) -> dict`(结构化评审 +结果:`contract_version`、`run_id`、`state`、`reviewed_head_sha`、`verdict`、 +`summary_markdown`、`comments`、`stale`/`expected_head_sha`/ +`actual_head_sha`、`diagnostics`);`unknown_run_result(run_id)`(显式 +`state: unknown`,绝不抛错 —— 丢响应和还在跑必须可区分); +`sanitize_comments` 与 `COMMENT_FIELDS`;以及从 `direct_routing` 再导出的 +`direct_knowledge_routes` / `direct_execution_budget` / +`direct_completion_result` / `direct_mandatory_review_guides`。 + +## 不变量 +- **依赖方向单向、由测试钉住**:本模块 import 数据层(`run_status`、 + `run_trace`),**绝不** import 任何 MCP server 模块;server 向下 import + 这里。`test_contract.py::test_contract_imports_no_server_module` 钉住。 +- `build_review_result` **只读 run 已持久化的东西**:对已完成、进行中、 + 评审步之前就死掉的 run 都成立;JSON 读取 fail-soft(早死的 run 合法地 + 缺文件)。 +- verdict 是**字段**不是 Markdown 刮取;stale 是**数据** + (`stale`/`expected`/`actual`),不是从散文推断。 +- `sanitize_comments` 按 `COMMENT_FIELDS` 白名单 + (file/line/severity/comment/evidence/suggestion)——内部记账键 + (`_verified`、`corroborated_by` 等)绝不泄进消费方输出。 +- 本模块自身保持仓库中立;仓库专属的 Direct 路由表住在 + `direct_routing.py`(见其页)。 + +## 边界 —— 不属于这里 +不调模型、不执行 run、不做策略强制(`mcp_policy`)、不实现 Direct 路由 +(委托 `direct_routing`)。 + +## 依赖(允许) +`run_status`、`run_trace`、`direct_routing` —— 只向下。消费方: +`mcp_server.py`(capabilities / build_review_result / unknown_run_result) +与仓库外的 reviewbot(`direct_*` 四件套)。 + +## 扩展点 +消费方需要的新字段 → 加进 `build_review_result` 并递增 API 版本; +新的 Direct helper → 在 `direct_routing` 实现、在这里再导出。 + +## 测试 +`test_contract.py`(23 例:verdict 字段化、评论白名单、stale 即终局事实、 +早死 run 的降级结果、unknown-run 显式化、能力上报、import 方向、 +本模块仓库中立)。 + +## 重构备注 +新模块(PR2 mixed-mode contract 拆分)。保持它薄:任何"顺手在这里实现" +的诱惑都在重造 thin_mcp_server 的巨石。 diff --git a/doc/architecture/SPEC/direct_routing.md b/doc/architecture/SPEC/direct_routing.md new file mode 100644 index 00000000..e215bf04 --- /dev/null +++ b/doc/architecture/SPEC/direct_routing.md @@ -0,0 +1,61 @@ +# direct_routing.py —— 规范 + + + +`LOC ~768 · Direct 模式知识路由(表 + 机制) · refactor-status: known-debt` + +## 职责 +Direct 模式的知识路由:owner/model 路由表和选路机制。从 +`thin_mcp_server.py` **逐字迁出**(下游曾经 `importlib` 拿它的私有名), +公开面由 `contract.py` 再导出。它显式携带仓库专属知识 —— 这正是 +`contract.py` 必须保持中立而这里不必的原因;把表外置到 +`adapters//` 是**未被这次搬迁改变的既有欠账**。 + +## 公开契约 +经 `contract.py` 再导出的四个名字:`direct_knowledge_routes`、 +`direct_execution_budget`、`direct_completion_result`、 +`direct_mandatory_review_guides`。其余全部下划线私有 —— 仅供 +`thin_mcp_server` 既有调用点/测试使用(它继续直接 import 下划线名)。 + +## 不变量 +- **repo 守卫最先跑**:不支持的仓库在任何路由计算之前被拒 —— 修的是一个 + 真实历史 bug(守卫曾排在空 intent 提前返回之后,向不支持的仓库泄漏 + owner 知识)。 +- **quick map fail-closed**:`_direct_quick_map` 返回内嵌代码地图与状态 + `{ok, truncated, unavailable}`,`truncated` 不是装饰 —— 把残图当全图 + 与缺图同罪、且更难察觉;`_direct_route` 据此置 + `read_required = status != "ok"`("自己去打开"是真回退,"什么都不给 + 又不许看"不是)。 +- **changed files 校验选择、绝不静默替换选择**:title/body 选 owner, + diff 只报告支持或矛盾;scope-fallback 是最后手段且永远显式 + (`status="scope_fallback"`)。 +- `_direct_execution_budget` 是**硬顶**预算字典(`hard_ceiling=True`、 + 一次有界扩展);docs-only PR 走更便宜的 profile。 +- `_direct_completion_result` 是**机械结构门**,不校验证据真假: + 单条最终评论、`subtraction_signal ∈ {none, triggered}`、 + `evidence_head_sha` 7–40 位十六进制、`existing_feedback_status` 枚举、 + `finding_dispositions` 的 anchor/disposition/existing_thread/ + head_recheck 约束。 +- 叶子模块:**绝不** import 任何 server 模块 + (`test_contract.py::test_direct_routing_does_not_import_a_server_module`)。 + +## 边界 —— 不属于这里 +不执行、不调模型;只有路由表 + 机制。多 adapter 桥接经 +`_normalize_repo`/`_adapter_for_repo` 走 `adapters/`。 + +## 依赖(允许) +stdlib + `.adapters`(AdapterError / AdapterRegistry / RepoAdapter)。 +位于 `contract.py` 和 `thin_mcp_server.py` 之下。 + +## 扩展点 +新 owner 路由/模型规则 → 表数据;跨仓库通用化 → 外置进 +`adapters//routing`(既有欠账的正解,不是在这里再长表)。 + +## 测试 +`test_contract.py`(公开家、import 方向、中立性豁免); +`test_thin_mcp_server.py` / `test_thin_mcp.py`(经新家继续锻炼全部 +下划线函数:路由、预算、完成门)。 + +## 重构备注 +768 行里约 500 行是表数据。`known-debt` 指的就是表:机制是稳定的, +表的归宿在 adapter 数据面(见结构重组计划 Stage 8 的 routing.yaml 方案)。 diff --git a/doc/architecture/SPEC/engine/agent_runtime.md b/doc/architecture/SPEC/engine/agent_runtime.md index f98b964d..acfa22fc 100644 --- a/doc/architecture/SPEC/engine/agent_runtime.md +++ b/doc/architecture/SPEC/engine/agent_runtime.md @@ -1,6 +1,6 @@ # engine/agent_runtime/ —— 规范 - + `LOC ~1690(7 个文件) · 引擎(受治理的 agent 运行时) · refactor-status: ok` @@ -61,10 +61,15 @@ - **压根没跑起来**(proven dead)→ 改跑档位模型,并记 `moa_member_fallback` (带 `phase`/`member`/`effective`/`reason`),绝不再问一次已死的成员。 死亡有**两种形态**,两种都必须接住:裸 API 成员**抛异常**,而 harness transport - 会把一次死掉的会话转成**类型化的非 OK 结果**("a dead harness is an outcome"), - 由 `outcome_blocked` 判定。首轮与**零产出重问**都要各自守住这两种形态;重问那一处 - 曾经完全没有守卫,于是一个 403 的成员直接穿过 `asyncio.gather` 把整个 step 打成 - BLOCKED。回退每处至多一次,档位模型自己再失败就按类型化失败返回,**不递归**。 + 会把一次死掉的会话转成**类型化的非 OK 结果**("a dead harness is an outcome")。 + **路由判据是 `member_unreachable`(seat 压根没跑,传输级死亡),不是 + `outcome_blocked`("有没有产出"—— 对死掉的和被截断但活着的 seat 都答 True)**: + 用后者做路由决定,正是 2026-08-16 把 18/28 个 holdout seat 静默改道的缺陷。 + 首轮与**零产出重问**统一走 `_attempt`/`_member_died` 守卫路径(重问那一处 + 曾经完全没有守卫,于是一个 403 的成员直接穿过 `asyncio.gather` 把整个 step + 打成 BLOCKED);`_member_died` 还比对 `member == tier_model`(trace 的 + `same_model` 字段)—— 解析成同一个模型的回退在 trace 里可见为 no-op。 + 回退每处至多一次,档位模型自己再失败就按类型化失败返回,**不递归**。 - MoA 成员本身也可以骑上 provider 注册表里的某个 harness(`transport_for_id`), 与本次 run 自己的 `STRICT_BACKEND` 无关。 diff --git a/doc/architecture/SPEC/engine/lifecycle.md b/doc/architecture/SPEC/engine/lifecycle.md index 5c525108..795900a5 100644 --- a/doc/architecture/SPEC/engine/lifecycle.md +++ b/doc/architecture/SPEC/engine/lifecycle.md @@ -1,8 +1,8 @@ # engine/lifecycle.py —— 规范 - + -`LOC ~121 · 引擎底座(run 生命周期原语) · refactor-status: ok` +`LOC ~141 · 引擎底座(run 生命周期原语) · refactor-status: ok` ## 职责 executor 自己给不了的两条**进程级**保证:同一 run 的互斥锁,与 run 离开事件 @@ -19,9 +19,13 @@ executor 自己给不了的两条**进程级**保证:同一 run 的互斥锁 `RunLock(run_dir).acquire()/release()`(context manager);`RunLockHeld`; `register_finalizer(run_dir, fn)`(`fn(outcome)`,outcome 是 RunOutcome,或 executor 在产出之前就抛时为 None);`finalize(run_dir, outcome)`; -`run_guarded(run, run_dir) -> outcome`。调用方:`cli/copilot.py` 在 -run/resume 全程持锁并用 `run_guarded` 包 `executor.run`;目前唯一的注册者是 -rebase 流水线(flock 释放、scratch 清理、终局报告、CI abort 清理)。 +`run_guarded(run, run_dir) -> outcome`;`require_file_locking()` 与 +`FileLockingUnavailable`(POSIX advisory 锁不可用时抛出的 fail-closed 门)。 +调用方:`cli/copilot.py` 在 run/resume 全程持锁并用 `run_guarded` 包 +`executor.run`;finalizer 注册者是 rebase 流水线(flock 释放、scratch 清理、 +终局报告、CI abort 清理)与 `engine/worktrees.py` 的共享持有释放; +`require_file_locking` 由 `engine/worktrees.py::materialize` 和 +`idempotency.py`(`key_lock`/`reap_stale`)消费。 ## 不变量 - `flock` 争用按 open file description 计:同进程内对同一路径的第二次 @@ -29,6 +33,12 @@ rebase 流水线(flock 释放、scratch 清理、终局报告、CI abort 清 - 锁文件释放后**留在原地**:它的存在不携带语义,只有 flock 本身算数。 - 非 POSIX(无 fcntl,与 `run_status.py` 同款守卫):无 advisory 锁, `acquire` 直接成功 —— 注释声明的刻意降级(保住 run 可用;无 trace 事件)。 +- **降级与 fail-closed 是两条刻意分开的路径**:`RunLock` 的静默降级只对 + "单进程 CLI run 可用性"成立;对**正确性就是锁本身**的路径(worktree + materialize、idempotency 索引与 reaper),`require_file_locking()` 直接抛 + `FileLockingUnavailable` —— 宁可拒绝,也不在无保护下运行并谎报保证 + (不变量 7)。`contract.capabilities()` 会如实上报 + `supports_file_locking=False`。 - finalizer **恰好一次**:`finalize` 先 pop 再跑,第二次调用是 no-op; 按注册顺序执行。 - 一个抛异常的 finalizer(**含 `CancelledError`** —— BaseException,不点名 diff --git a/doc/architecture/SPEC/engine/steps/_common.md b/doc/architecture/SPEC/engine/steps/_common.md index 5732cac7..046645d4 100644 --- a/doc/architecture/SPEC/engine/steps/_common.md +++ b/doc/architecture/SPEC/engine/steps/_common.md @@ -1,8 +1,8 @@ # engine/steps/_common.py —— 规范 - + -`LOC ~264 · step 库基础设施 · refactor-status: ok` +`LOC ~300 · step 库基础设施 · refactor-status: ok` ## 职责 step 的自注册面,以及各 step 文件共享的跨模块 helper。 @@ -21,6 +21,14 @@ helper:`repo_path`、`require_repo`、`task_spec`、`from_state`、`published` - 这是 step 共享 helper 的**唯一**归处 —— step 模块从这里 import, **绝不互相 import**(**A2**)。 - helper 保持轻薄、对副作用诚实、且仓库中立。 +- **`repo_path(ctx)` 不再是纯访问器** —— 两处刻意的变化都要知道: + (1) 三级优先序 `ctx.params → ctx.state → state["task_spec"]["repo_path"]` + (最后一级是预约时冻结、`authorize_repo_path` 授过权的绑定 —— executor + state seed 之外触达的 step 也与规划看到的 checkout 一致); + (2) 解析出的路径落在 `worktrees.worktree_root()` 之下时经 + `_hold_if_worktree` 取共享 worktree 持有 —— 挂在**使用**而非创建上 + (`--resume` 跳过已完成 step 的函数体,见 `engine/worktrees.md`); + 取不到持有**绝不抛**(那是被回收的风险,不是评审错误)。 ## 边界 —— 不属于这里 不含 step handler;不含领域逻辑。**只有基础设施 + 共享 IO。** diff --git a/doc/architecture/SPEC/engine/steps/pr.md b/doc/architecture/SPEC/engine/steps/pr.md index 8e551e3e..7574245f 100644 --- a/doc/architecture/SPEC/engine/steps/pr.md +++ b/doc/architecture/SPEC/engine/steps/pr.md @@ -1,8 +1,8 @@ # engine/steps/pr/ —— 规范 - + -`LOC ~1100(6 个文件) · step 库(PR) · refactor-status: ok` +`LOC ~1300(6 个文件) · step 库(PR) · refactor-status: ok` ## 职责 受守卫的推送、只读的 PR 抓取/门禁、PR rebase、PR debug、受门禁的评审发布。 @@ -30,12 +30,24 @@ ## 不变量 - `ci.push` 把全部安全判断委托给 `guard_push`(**C4**)。 -- **`pr.fetch_diff` 把评审树 pin 到 PR head**(`_pr_time_checkout`):head sha 取自 - 最后一个 commit 的 oid,`git fetch origin pull/N/head`(对**开放和已合并**的 PR 都 - 有效),detached worktree 复用于 `~/.infermatrix-copilot/worktrees/`。 - 它经 `state_updates` 发布 `repo_path` / `checkout_note`,于是**每个 lens 调查的都是 - PR 那一刻的代码**,而不是本地 checkout 恰好停在哪个分支。失败时降级回 live checkout, - 并带**响亮**的注记加一条 `capability_gap` trace。 +- **`pr.fetch_diff`:一个 head 统治一切**(PR2 重构后)。head 由 + `_resolve_pr_head` **恰好解析一次**,stale 门、fetch、diff、worktree 全部 + 从这一个答案推导(`_fetch_at_one_head`)。fetch 走 run 域强制目的 ref + `refs/imx//{base,head}`(取代旧的机会主义 tracking ref + + `FETCH_HEAD` —— 对着可能陈旧的 `origin/` 取 merge-base 会把无关的 + 上游漂移当成 PR 的工作),且 `_fetch_pinned` 把取到的 head 与 API 报告的 + head 复核、不符即**响亮失败**("head 在 view 与 fetch 之间动了")。 + `_pinned_diff` 是**主**diff 路径;`gh pr diff` 只是回退(已合并 PR / + 未钉请求的钉取失败)。worktree 的身份/验证/存活模型整体委托 + `engine/worktrees.py`(见其页);经 `state_updates` 发布 + `repo_path`/`checkout_note`/`pr_head_sha`,于是**每个 lens 调查的都是 PR + 那一刻的代码**。未钉请求失败时仍降级回 live checkout 并带响亮注记 + + `capability_gap` trace。 +- **`expected_head_sha` 是快照绑定的硬门**:spec 携带它时,解析出的 head + 不符 → 在任何 fetch/物化**之前**就以 BLOCKED 停下(`_stale()`,trace + 事件 `expected_head_mismatch`,`contract.build_review_result` 据此上报 + stale);且每条"降级回 live checkout"的路径都变成硬 BLOCK —— 钉了快照 + 还静默评错树,比停下更糟。 - **`pr.post_review` 只发一条 GitHub review + inline thread**,且先把每条发现的位置 对照已抓取的 diff 校验过 —— **绝不是一串独立评论**。 - `diff_text` 和 `gate_report` 都可以经 state 注入,因此网络之下的每条路径都可离线测试; @@ -56,9 +68,12 @@ `..agent_runtime`。 ## 测试 -`test_pr_steps.py`、`test_push_and_steps.py`、`test_ci_and_repo_map.py` -(注意:`test_ci_and_repo_map` monkeypatch 的是 `pr.debug._gh`, -即 `pr.fetch_ci_failures` 绑定 `gh` 的那个子模块)。 +`test_pr_steps.py`(含钉 ref run 域隔离、head 移动检测、stale expected_head +BLOCK、worktree 分键/拒外来树)、`test_push_and_steps.py`、 +`test_ci_and_repo_map.py`(注意:`test_ci_and_repo_map` monkeypatch 的是 +`pr.debug._gh`,即 `pr.fetch_ci_failures` 绑定 `gh` 的那个子模块); +端到端:`test_thin_mcp_server.py`(评审跑在钉住的 worktree 上、head 移动 +在评审前停下)。 ## 重构备注 拆分**已完成**。各子模块只共享 `._common` 的 helper,所以拆分**没有制造交叉 import**。 diff --git a/doc/architecture/SPEC/engine/steps/review.md b/doc/architecture/SPEC/engine/steps/review.md index 103b1b2e..7b6c864a 100644 --- a/doc/architecture/SPEC/engine/steps/review.md +++ b/doc/architecture/SPEC/engine/steps/review.md @@ -1,6 +1,6 @@ # engine/steps/review/ —— 规范 - + `LOC ~900(6 个文件) · step 库(评审) · refactor-status: ok` @@ -27,6 +27,16 @@ ## 不变量 - patch 门:廉价摘要**常开**,只有触发时才跑 LLM 评审;**fail-closed**(**C6**); 高风险模块来自 adapter,settings 只作兜底(**A5**)。 +- **`review_verdict` 是发布的 state 字段,不是散文**:由 + `_review_verdict(review_comments, pr_state)` 计算(**与渲染器同一个 + helper,绝不第二份校准规则**),随 `review_text`/`review_summary`/ + `review_comments` 一起进 `state_updates`(B2)—— + `contract.build_review_result` 读的 `verdict` 就是它;此前裁决只活在 + Markdown 的 `**Verdict:**` 散文里,机器消费方只能刮取。 +- **planner 失职是显式 `capability_gap`**:`plan.planner_error` 非空且不匹配 + `_EXPECTED_PLANNER_CAUSES`(`unavailable` / `rejected_depth:`)时记 + trace 缺口 —— 配置了的 planner 答非所问(transport / 空回复 / 不可解析) + 与"没配"和"守卫在履职"是三件事(见 `review/planner.md` 的 5 种 cause)。 - 评审:领域 checklist 由 profile 的 `review.md` 扩展;`_sweep_targets` 以 `repo.language` 为键,**诚实降级**;裁决自洽(任何 ≥minor 的评论 ⇒ REQUEST CHANGES); 确定性的按严重度排序的评论上限。 diff --git a/doc/architecture/SPEC/engine/worktrees.md b/doc/architecture/SPEC/engine/worktrees.md new file mode 100644 index 00000000..f279dccf --- /dev/null +++ b/doc/architecture/SPEC/engine/worktrees.md @@ -0,0 +1,58 @@ +# engine/worktrees.py —— 规范 + + + +`LOC ~231 · PR-time worktree:身份、物化、持有 · refactor-status: ok` + +## 职责 +PR 快照 worktree 的三条性质,每条都对应一个修掉的真实事故: +**身份**(旧键 `-pr` 在不同 head 的 run 之间、同名目录的不同 +checkout 之间碰撞,force-remove 曾删掉一个活评审的树)、 +**验证**(复用前确认这棵树属于请求方仓库且 HEAD == sha)、 +**存活**(run 期间的共享 flock 持有,让 reaper 的 `LOCK_EX|LOCK_NB` +扫描跳过它 —— 这是对 MCP 预约 run 和 CLI run 都成立的唯一存活信号, +`run_status.json` 只有前者才有)。 + +## 公开契约 +`worktree_root()`;`canonical(path)`;`owner_tag(repo)`(checkout canonical +路径 sha256 前 8 位);`dest_for(repo, pr, sha)` —— 键形如 +`--pr-`;`is_managed_dest(dest)`;`lock_path(dest)`; +`owned_by(repo, dest, sha, git)`;`materialize(repo, sha, dest, git, +timeout=300)`;`hold(dest, run_dir)`;`held_paths()`;`GitRunner` 类型别名 +(git 以可调用注入 —— 本模块不含 subprocess 策略,可测)。 + +## 不变量 +- **reaper 只删匹配 `_MANAGED_DEST`(`-hex8-prN-hex12` 后缀)的树**: + worktrees 根是共享 scratch,长期存着其他工具的树 —— 一次"见什么删什么" + 的扫描曾经毁掉过别人的工作,这个守卫因此存在。 +- `materialize` 对 git 级失败**绝不抛**(调用方决定 block 还是降级), + 但平台无文件锁**必须抛**(`lifecycle.require_file_locking` —— 序列化 + 就是保证本身)。 +- 物化锁 `LOCK_EX` **阻塞而非失败**:同 sha 两个 run 在 + `git worktree add` 里赛跑是常态,失败路径会静默降级回 live checkout —— + 恰是 head 门要防的"评错树"。 +- 复用验证:`--git-common-dir` 必须解析回请求方仓库自己的 git dir **且** + HEAD == sha;force-remove 只对外来/撕裂的树成立,绝不对同身份的活树。 +- `hold()` 幂等(进程内 memo),经 `lifecycle.register_finalizer` 在每条 + 退出路径释放,进程死亡时内核兜底 —— 崩掉的 run 绝不永久钉住一棵树。 +- **持有挂在"使用"而不是"创建"上**(`engine/steps/_common.py::repo_path` + 里的 `_hold_if_worktree`):`--resume` 重放 `state_updates` 并跳过已完成 + step 的函数体,挂在 `pr.fetch_diff` 体内的持有只在首轮存在。 + +## 边界 —— 不属于这里 +无 subprocess 策略(git runner 注入);无 run 生命周期逻辑(持有原语之外 +—— 那是 `engine/lifecycle.py`);何时物化/何时 block 是 +`engine/steps/pr/fetch.py` 的决定。 + +## 依赖(允许) +stdlib(hashlib/os/re/time/pathlib)+ `.lifecycle` +(fcntl 守卫、register_finalizer、require_file_locking)。 + +## 测试 +`test_pr_steps.py`(materialize 创建与复用、同 basename 仓库分键、 +拒绝外来树、fetch_diff 端到端钉住);`test_idempotency.py` +(reaper 只碰自己键出的树、放过被持有的树)。 + +## 重构备注 +新模块(PR2)。键格式与 `_MANAGED_DEST` 必须同步演进 —— 改其一忘其二, +reaper 就会开始放过(或误删)新格式的树。 diff --git a/doc/architecture/SPEC/idempotency.md b/doc/architecture/SPEC/idempotency.md new file mode 100644 index 00000000..b44df534 --- /dev/null +++ b/doc/architecture/SPEC/idempotency.md @@ -0,0 +1,65 @@ +# idempotency.py —— 规范 + + + +`LOC ~293 · 持久 idempotency 索引:每键一个 run、一次执行 · refactor-status: ok` + +## 职责 +三个分开解决的问题:(1) **每键一个 run** —— 持久的 +`/.idem/.json` 条目 + 阻塞式逐键锁;(2) **每键一次执行** +—— `reserve` 报告自己是否*创建*了 run,只有创建才入队;(3) **崩溃安全** +—— 命中只有在其 run 存活或握有真实结局时才可复用,否则同锁之下重新武装 +并重拉。 + +## 功能与范围 +**刻意只服务 `start_strict_review`**:对通用 `start()` 按 spec 哈希取键 +是主动有害的 —— `issue_answer`/`issue_filter` 携带 `pr=None` 且无 head, +一个仓库的所有 issue 任务会塌缩到同一个键上。 + +## 公开契约 +`IdempotencyError`;`INDEX_DIR=".idem"`;`DEFAULT_RETENTION_DAYS=30`; +`KEY_RE` / `validate_key`;`spec_fingerprint(spec_dump)`(**整字典** +sha256 —— 之后新增的字段自动纳入,绝不把两个不同请求静默塌到一个键); +`index_dir`;`key_lock(run_root, key, timeout=30)`(**阻塞**式上下文管理器 +—— fail-fast 会把一次去重变成一个错误);`read_entry`/`write_entry` +(tmp + `os.replace` 原子写);`relaunchable(run_dir)`; +`resolvable(run_dir)`;`reap_stale(run_root, retention_days=30, ...) -> +{"entries","worktrees","refs"}`。 + +## 不变量 +- `relaunchable`:`init_queued` 写 `child_pid: null`,子进程的 claim 才写 + pid —— `interrupted` 且 pid 为 null 意味着**没有子进程跑过**,可重拉; + 带 pid 的 `interrupted` 做过部分工作,是真实结局。 +- **run 目录刻意不回收**:它们是用户可见的审计线索;删已完成的 run 是 + 另一个、后果大得多的变更。`reap_stale` 只清索引条目、worktree、 + `refs/imx//*`。 +- reaper 绝不触碰活 run 的条目(`state not in TERMINAL` → 跳过)。 +- worktree 回收:存活以树**自己的**共享锁判定(绝不是注册表),移除走 + `git worktree remove`(绝不裸 `rmtree`),逐树限时(一棵慢树不拖垮 + 整个扫描)。 +- ref 回收只限 `refs/imx//*` 前缀 —— 扫描永远不可能解钉一个 + 活 run 的 base/head。 +- 无文件锁的平台 fail-closed(`lifecycle.require_file_locking`)。 + +## 边界 —— 不属于这里 +不决定什么被取键(`mcp_server`/`cli/copilot` 的调用方决定);不执行评审; +CAS 状态迁移住在 `run_status.py`(`claim_for_execution`/`reclaim_queued`)。 + +## 依赖(允许) +`run_status`、`engine.lifecycle`;`_reap_worktrees`/`_reap_refs` 内部 +**惰性** import `engine.worktrees` 与 `engine.steps._common.git` +(保持模块可独立 import,不拖入整个 step 库)。 + +## 扩展点 +新的可取键入口 → 先回答"这个 kind 的 spec 指纹在什么输入下会塌缩", +答案不是"不会"就别接。 + +## 测试 +`test_idempotency.py`(26 例:键字符集、终态前后同键去重、指纹覆盖全字段、 +issue 任务不塌缩、并发预约恰好一次、写索引前崩溃、入队前崩溃后重拉、 +claim 即 CAS、reclaim 守卫、reaper 三类各自的边界); +`test_e2e_strict_mock.py`(idempotency-key 重试语义端到端)。 + +## 重构备注 +新模块(PR2)。窄范围是它的安全性来源 —— 泛化到其他 kind 之前先把 +"指纹塌缩"问题写成测试。 diff --git a/doc/architecture/SPEC/intent.md b/doc/architecture/SPEC/intent.md index fab4448d..676ddb15 100644 --- a/doc/architecture/SPEC/intent.md +++ b/doc/architecture/SPEC/intent.md @@ -1,6 +1,6 @@ # intent.py —— 规范 - + `LOC ~379 · 任务层 · refactor-status: ok` diff --git a/doc/architecture/SPEC/mcp_policy.md b/doc/architecture/SPEC/mcp_policy.md index 4269cdef..128468f7 100644 --- a/doc/architecture/SPEC/mcp_policy.md +++ b/doc/architecture/SPEC/mcp_policy.md @@ -1,15 +1,19 @@ # mcp_policy.py —— 规范 - + -`LOC ~141 · 安全原语(MCP 结构性门) · refactor-status: ok` +`LOC ~254 · 安全原语(MCP 结构性门) · refactor-status: ok` ## 职责 从原始 MCP 输入**重新推导**出一个*安全的* `TaskSpec`,拒绝 MCP 面不被允许做的任何事 —— 这就是"宿主无法扩大服务端权限"的**结构性**保证。 ## 公开契约 -`enforce_mcp_policy(raw) -> TaskSpec`(拒绝时抛错)。 +`enforce_mcp_policy(raw) -> TaskSpec`(拒绝时抛错); +`enforce_strict_review_policy(raw) -> TaskSpec`(Strict 兼容路径:限 +`pr_review`、强制 `mode="eco"`); +`authorize_repo_path(repo, raw_path, settings) -> str`(对调用方提供的 +checkout 路径做**身份 + 圈禁**双重校验,返回 canonical 路径)。 ## 不变量(**C2**、**C3**、**A2**) - **它跑两次,这是设计如此。** 一次在**边界**(server,工具被调用时),一次在 @@ -18,6 +22,20 @@ 与执行之间把它改写。 - `kind` 必须 ∈ `READ_ONLY_KINDS`;`post` 被**硬置为 `False`**;`repo` 必须在 allowlist 内;`pr`/`issue` 必须为正;未知 params 被**剥除**而不是透传。 +- **Strict 路径上 `post` 是被拒绝,不是被恢复。** 这条路径曾经"共享门里 + 强制 `post=False`、事后把调用方原值放回去",使 Strict 成为唯一能发布的 + MCP 面。显式 `post=True` 现在直接抛 `PolicyError` —— 移除这个恢复不是 + 制造异常,而是消灭一个异常(`mcp_server` 侧的 `ALLOW_POST="0"` 环境闸 + 与此呼应,双闸同向)。 +- **`expected_head_sha` 只收全长 SHA**:经 `task_spec.FULL_SHA_RE` + (import,绝不重述正则)校验后随 `TaskSpec` 冻结;短 SHA / 非十六进制 + 被拒绝,空串 = 不钉快照。 +- **`authorize_repo_path` 双重校验、fail-closed**:(1) **身份** —— + checkout 的 `origin` remote 必须解析为该 alias 配置的 GitHub 全名 + (经 `intent` 的 remote/identity helper);(2) **圈禁** —— canonical + 路径必须落在 `settings.allowed_repo_roots` 之下(默认 = 只有已配置的 + checkout 本身,最小权限)。身份无法验证 = 拒绝,绝不假定。它是 + `TaskSpec.repo_path` 的一等入口 —— **不要**把它塞进 `_ALLOWED_PARAMS`。 - **允许集是 import 进来的,绝不重述。** 它直接引用 `task_spec.READ_ONLY_KINDS`, 因此策略永远不会与任务模型漂移 —— 新增一个具备写能力的 kind,**无法**静默地把 MCP 面放宽。 @@ -27,10 +45,15 @@ 不执行 run;不调模型;不访问知识。 ## 依赖(允许) -`..task_spec` + stdlib。一个叶子安全原语。 +`..task_spec` + stdlib;外加 `authorize_repo_path` 内部**函数级**引入的 +`.intent`(remote→GitHub 身份解析)—— 与 `config.md` 记录函数级 import 的 +方式一致的刻意例外:身份判定必须与 intent 的 URL 路由用同一套解析, +否则两处各养一份就会漂移。模块导入期仍然只依赖 task_spec + stdlib。 ## 测试 -`test_mcp.py`(篡改防御、只读工具集)。 +`test_mcp.py`(篡改防御、只读工具集、`expected_head_sha` 贯穿与短 SHA 拒绝、 +Strict 显式 post 被拒);`test_contract.py`(repo_path 身份不符 / 圈外 / +身份不可验证均拒绝;冻结 repo_path 直达解析与执行、压过环境路径)。 ## 重构备注 和 `push.guard_push`、`scopes` 一样,这是一个纯权限原语 —— 保持它无依赖。任何新的 diff --git a/doc/architecture/SPEC/mcp_server.md b/doc/architecture/SPEC/mcp_server.md index e5f9d8c8..b7788181 100644 --- a/doc/architecture/SPEC/mcp_server.md +++ b/doc/architecture/SPEC/mcp_server.md @@ -1,8 +1,8 @@ # mcp_server.py —— 规范 - + -`LOC ~421 · Strict 后台机器(start/poll) · refactor-status: ok` +`LOC ~496 · Strict 后台机器(start/poll) · refactor-status: ok` ## 职责 为 MCP 宿主运行 Strict 工作流:预约、拉起、跟踪、供给结果 —— @@ -15,7 +15,11 @@ 和 `get_status`(`run_status` + `progress`)负责轮询。 ## 公开契约 -`CopilotMCP`、`build_mcp()`,以及那组 start/poll 工具。 +`CopilotMCP`、`build_mcp()`、那组 start/poll 工具,外加 +`get_capabilities()`(包 `contract.capabilities`,含 +`MAX_STRICT_WORKERS=1` 与文件锁能力上报)。`start_review` 接受 +`expected_head_sha`;`start_strict_review` 接受 `idempotency_key` 并把 +`(run_id, created)` 语义(见下)落到入队决定上。 ## 不变量(**C2**、**C3**、**E1**) - **安全是结构性的,不是"信任宿主"。** `enforce_mcp_policy` 在这里跑一次, @@ -34,16 +38,38 @@ - **`mcp` SDK 是可选 import**,藏在 `[mcp]` extra 之后,且**绝不能**被核心包 import —— 纯 CLI 安装保持零依赖。 - `build_mcp()` 暴露 V1 工具面(autonomous 工作流执行器);它**默认不注册**。 +- **子进程环境闸恒关**:`_launch` 对**每个**子进程(含 Strict)无条件写 + `ALLOW_POST="0"` —— 给策略的"拒绝而非恢复"(`mcp_policy.md`)系上第二道 + 背带;同时转发 `MCP_ALLOWED_REPO_ROOTS`,让子进程的 + `authorize_repo_path` 复检对着父进程判过的同一组根。 +- **入队只发生在 `created` 为真时**:`reserve_run` 现在返回 + `(run_id, created)` —— 重试命中既有键只返回既有 run,绝不第二次入队 + (配合 `run_status.claim_for_execution` 的 CAS,双保险关掉重复执行)。 +- **`configure_strict_repo` 已删除**(进程全局 `settings.repo_paths` 突变): + 替代是 `strict_readiness(repo, repo_path="")` 按调用校验 —— 两个并发 + Strict 请求各带各的 checkout,再无共享状态可互踩。 +- **回收是尽力而为、绝不拦路**:启动时与每次 run 终局后跑 + `idempotency.reap_stale`;一次清不完的扫描绝不阻止 server 服务或 run + 完成。 +- **轮询对未知 id 给结构化答案**:`get_result` 对形状合法但不存在的 + run_id 返回 `contract.unknown_run_result`(`state: unknown`)而不是抛错 + —— 丢响应和还在跑可区分;终局响应同时附带 + `contract.build_review_result` 的结构化 `result`(分页 `report` 保留)。 ## 边界 —— 不属于这里 不含 Direct 模式逻辑(`thin_mcp_server.py`);不定义策略(`mcp_policy.py`); 不定义状态文件格式(`run_status.py`)。 ## 依赖(允许) -stdlib + `mcp`(可选 extra)+ `.mcp_policy` + `.run_status` + `.cli`。 +stdlib + `mcp`(可选 extra)+ `.mcp_policy` + `.run_status` + `.cli` ++ `.contract` + `.idempotency`。 ## 测试 -`test_mcp.py`(篡改防御、单写者对账、分页、只读工具集)。 +`test_mcp.py`(篡改防御、单写者对账、分页、只读工具集、快照绑定转发、 +子进程 post 闸恒关、未知 run_id 不抛、结构化 result 附带); +`test_contract.py`(capabilities 上报、configure_strict_repo 已亡); +`test_e2e_strict_mock.py`(离线端到端:钉 head 评审、idempotency 重试、 +第二个子进程 no-op、post 拒绝)。 ## 重构备注 预约形状(先建 run 目录,再规划)是 **MCP 专属**的;CLI 主路径仍然在**建目录之前** diff --git a/doc/architecture/SPEC/providers/base.md b/doc/architecture/SPEC/providers/base.md index 0d842ebb..68e2dc3c 100644 --- a/doc/architecture/SPEC/providers/base.md +++ b/doc/architecture/SPEC/providers/base.md @@ -1,6 +1,6 @@ # providers/base.py —— 规范 - + `LOC ~161 · provider 层契约 + 子进程环境白名单 · refactor-status: ok` diff --git a/doc/architecture/SPEC/providers/deepseek.md b/doc/architecture/SPEC/providers/deepseek.md index 673c57df..e9ffbada 100644 --- a/doc/architecture/SPEC/providers/deepseek.md +++ b/doc/architecture/SPEC/providers/deepseek.md @@ -1,6 +1,6 @@ # providers/deepseek.py —— 规范 - + `LOC ~502 · harness transport(dsh,API-keyed) · refactor-status: oversized` diff --git a/doc/architecture/SPEC/providers/harness_llm.md b/doc/architecture/SPEC/providers/harness_llm.md index 8aba0ae2..fc77e6ee 100644 --- a/doc/architecture/SPEC/providers/harness_llm.md +++ b/doc/architecture/SPEC/providers/harness_llm.md @@ -1,6 +1,6 @@ # providers/harness_llm.py —— 规范 - + `LOC ~66 · 套在 harness 之上的 LLM 形状适配器(仅限无工具) · refactor-status: ok` diff --git a/doc/architecture/SPEC/providers/registry.md b/doc/architecture/SPEC/providers/registry.md index f6395e86..35fa565a 100644 --- a/doc/architecture/SPEC/providers/registry.md +++ b/doc/architecture/SPEC/providers/registry.md @@ -1,6 +1,6 @@ # providers/registry.py —— 规范 - + `LOC ~107 · 后端解析(唯一那张表) · refactor-status: ok` diff --git a/doc/architecture/SPEC/review/planner.md b/doc/architecture/SPEC/review/planner.md index cfbd53a4..da7e2da9 100644 --- a/doc/architecture/SPEC/review/planner.md +++ b/doc/architecture/SPEC/review/planner.md @@ -1,8 +1,8 @@ # review/planner.py —— 规范 - + -`LOC ~307 · 评审深度选择 · refactor-status: ok` +`LOC ~360 · 评审深度选择 · refactor-status: ok` ## 职责 为一个 PR 选定评审深度(`light` / `standard` / `full`)。 @@ -14,6 +14,7 @@ ## 公开契约 `plan_review_depth(...) -> depth`,以及那些确定性分类器。 +`ReviewPlan` 携带 `planner_error: str`(空 = 无故障)。 ## 不变量(**C2**、**B2**) - **`light` 永远不可能来自模型输出。** 灰区那次调用**只能**返回 `standard` 或 `full`。 @@ -25,6 +26,13 @@ - **灰区调用需要推理留白。** 它曾在两个战役里对**每一个**灰区条目静默失败,因为思考 在任何 JSON 出现之前就吃完了 400-token 的上限 —— 这里的 cap 是**正确性设置, 不是成本旋钮**。 +- **planner 故障是分类过的、消毒过的字符串**(`planner_error` 的 5 种 + cause):`unavailable`(没配 client —— 配置事实)、 + `rejected_depth:`(守卫在履职)、`transport:: ` / + `empty_reply (stop_reason=…)` / `unparseable: `(中间三种 = + **配置了的 planner 在失职**)。消费方 `engine/steps/review/steps.py` + 据此把失职(而非前两种)声明为 `capability_gap`(不变量 7:静默降级 → + 显式缺口)。 ## 边界 —— 不属于这里 不执行 lens(`agent_runtime/ensemble.py`);不做渲染;不含仓库专属风险清单 diff --git a/doc/architecture/SPEC/run_status.md b/doc/architecture/SPEC/run_status.md index 03fb444c..26b2cf92 100644 --- a/doc/architecture/SPEC/run_status.md +++ b/doc/architecture/SPEC/run_status.md @@ -1,8 +1,8 @@ # run_status.py —— 规范 - + -`LOC ~247 · 持久化的单写者 run 生命周期记录 · refactor-status: ok` +`LOC ~309 · 持久化的单写者 run 生命周期记录 · refactor-status: ok` ## 职责 `run_status.json` —— 一次 Strict run 的**持久、无歧义**的生命周期记录,可跨进程观测。 @@ -13,14 +13,22 @@ server 重启,并且必须能把**崩掉的 run 和正在跑的 run 区分开* 启发式做不到)。 ## 公开契约 -`reserve_run`、`mark_child_started`、`read_status`,以及状态常量 -(`queued`/`planning`/`running`/终态/`interrupted`/`FAILED`)。 +`init_queued`(server 预约时写 `queued` + 属主字段 + `child_pid: null`)、 +`claim_for_execution(run_dir, child_pid)`(**预约 run 子进程的第一步**: +原子 CAS `queued → planning`,同 pid 幂等,输者不得执行)、 +`mark_child_started`(**非预约** run 记录自身 pid 的路径)、`mark`、 +`read_status`、`reclaim_queued(run_dir, owner_server_id, +owner_server_pid)`(`interrupted → queued` 重新武装)、 +`reconcile_after_wait`/`reconcile_if_dead`/`startup_reconcile`、 +`register_server`/`unregister_server`/`server_alive`,以及状态常量 +(`QUEUED`/`PLANNING`/`RUNNING`/`TERMINAL`/`INTERRUPTED`/`FAILED`)。 ## 不变量(**C3**、**E1**) -- **单写者。** `reserve_run`(server,在子进程存在之前)写下 `queued`;一旦拉起, - **子进程就是运行期唯一的写者**:它的第一件事是通过 `mark_child_started` 写下自己的 - pid,然后 `planning → running → 终态`。父进程**只在 `.wait()` 之后**对账 —— - 也就是在子进程已确认死亡之后。 +- **单写者。** `init_queued`(server,在子进程存在之前)写下 `queued`;一旦拉起, + **子进程就是运行期唯一的写者**:预约 run 的子进程第一件事是 + `claim_for_execution` 赢下 CAS 并写入自己的 pid(非预约 run 走 + `mark_child_started`),然后 `planning → running → 终态`。父进程 + **只在 `.wait()` 之后**对账 —— 也就是在子进程已确认死亡之后。 - **跨进程对账只发生在写者被确认死亡之后**,持 `flock` 进行,并保留 owner 字段。 - **按属主对账**(`owner_server_id` / `owner_server_pid` / `child_pid`):只有**属主** server 被确认死亡,才可以把一个非终态 run 标记为 `interrupted`。在多 server 模型下 @@ -28,6 +36,16 @@ server 重启,并且必须能把**崩掉的 run 和正在跑的 run 区分开* server 仍然活着的 `queued` run。 - **没有 run 会永远停在非终态**:对账发生在每次 `get_*` 时(惰性)、父进程 `wait()` 之后、以及启动扫描时 —— **三处**。 +- **at-most-once 执行是状态 CAS,不是锁**(与"单写者"相关但独立的保证): + server 拉起子进程 A 后死亡、重试合法地重占预约并入队子进程 B 时,谁持锁 + 都拦不住 A 醒来后把 `done` 走回 `planning` 再评审一次 —— 只有"持有者到场 + 时 run 处在什么状态"能拦住。`claim_for_execution` 因此是原子 + compare-and-set:输掉 claim 的子进程不规划、不执行、不写状态,直接退出。 +- **`interrupted → queued` 是对"终态不再迁移"的一个刻意的、狭窄的例外**: + 仅当 `interrupted` 且 `child_pid` 为 null(属主 server 在任何子进程启动前 + 就死了,预约从未执行过 —— 不是真正的终局结果)时,`reclaim_queued` 才 + 允许同一属主身份重新武装;带 pid 的 `interrupted` 做过部分工作,是真结果, + 拒绝重占。 ## 边界 —— 不属于这里 不拉起进程(那是 `mcp_server`);不含策略;不渲染报告。 @@ -36,7 +54,9 @@ server 重启,并且必须能把**崩掉的 run 和正在跑的 run 区分开* 仅 stdlib(`json`、`os`、`fcntl`/`flock`、`pathlib`)。 ## 测试 -`test_mcp.py`(单写者对账、属主判定)。 +`test_mcp.py`(单写者对账、属主判定);`test_idempotency.py` +(claim 是 CAS 而非写入、终态不可再 claim、reclaim 的双重拒绝、 +输掉 claim 的子进程 no-op 退出)。 ## 重构备注 这里的每一处改动,都必须**带着"两个 server + 一个已死子进程"的场景**去推演; diff --git a/doc/architecture/SPEC/task_spec.md b/doc/architecture/SPEC/task_spec.md index fa408e3b..f1f5362d 100644 --- a/doc/architecture/SPEC/task_spec.md +++ b/doc/architecture/SPEC/task_spec.md @@ -1,8 +1,8 @@ # task_spec.py —— 规范 - + -`LOC ~70 · 任务层,纯数据 · refactor-status: ok` +`LOC ~104 · 任务层,纯数据 · refactor-status: ok` ## 职责 定义 `TaskSpec`(意图解析的结构化产物),并从任务 kind **推导**出它的权限 **tier**。 @@ -12,14 +12,22 @@ 以及给人看的 `describe()`。 ## 公开契约 -`TaskSpec(kind, repo, pr?, issue?, report_only, post, params)`;property -`tier`、`read_only`、`confirm_required`;`describe()`。常量:`TaskKind` -(7 种 kind)、`READ_ONLY_KINDS`、`KIND_TIER`。 +`TaskSpec(kind, repo, pr?, issue?, report_only, post, params, +expected_head_sha?, repo_path?)`;property `tier`、`read_only`、 +`confirm_required`;`describe()`。常量:`TaskKind`(7 种 kind)、 +`READ_ONLY_KINDS`、`KIND_TIER`、`FULL_SHA_RE`(40 位十六进制全长 SHA 的 +唯一真相正则,`mcp_policy.py` 复用它校验)。 ## 不变量 - **C1**:**不存在可设置的 tier 字段**;`tier = KIND_TIER[kind]` —— 文本无法把它扩大。 - 只读 kind 的 `read_only` = `not post`,其余为 `report_only`; `confirm_required = not read_only`。 +- **快照绑定字段只收窄,绝不扩权**(C1 完整无损):`expected_head_sha` + (field_validator 强制 `FULL_SHA_RE`;设置后 run 只准评审这个 head, + 否则以 stale 停下)与 `repo_path`(预约时冻结、由 + `mcp_policy.authorize_repo_path` 授权的 canonical checkout;空 = 按环境 + 解析,即所有 CLI run)都是惰性数据 —— 它们缩小 run 接受的输入, + 从不改变 run 被允许做的事。 ## 边界 —— 不属于这里 不解析、不做 I/O、不执行、不含仓库知识。纯数据 + 推导。 diff --git a/doc/architecture/SPEC/thin_mcp_server.md b/doc/architecture/SPEC/thin_mcp_server.md index a5679bf5..2b47eba4 100644 --- a/doc/architecture/SPEC/thin_mcp_server.md +++ b/doc/architecture/SPEC/thin_mcp_server.md @@ -1,8 +1,8 @@ # thin_mcp_server.py —— 规范 - + -`LOC ~1286 · 默认 MCP:Direct 路由 + Strict 入口 · refactor-status: oversized` +`LOC ~627 · 默认 MCP:Direct 门面 + Strict 入口 · refactor-status: ok` ## 职责 安装器**实际注册**的那个 MCP 门面:以**零模型**提供 Direct 模式的知识路由, @@ -19,6 +19,16 @@ ## 不变量(**C1**、**C2**、**D1**) - **Direct 在这个 server 里不跑任何模型。** 它返回知识路由和一份治理契约;阅读由 **宿主自己的模型**完成。执行主脊完全不参与。 +- **路由表与机制已迁出**(上一版预告的拆分点已经发生):`_direct_*` 全家 + 现在**住在 `direct_routing.py`**、经 `contract.py` 作为公开面再导出; + 本模块以下划线别名 import 它们,保持既有调用点/测试不变,**只向下** + 委托 —— 没有任何东西从那两个模块向上 import 回 server。下面关于 + quick-map fail-closed、路由不静默替换、仓库守卫先跑的不变量**仍然为真**, + 但其实现体在 `direct_routing.py`(规范见其页)。 +- **Strict 分支透传快照绑定**:`_strict_review_request` 把 + `expected_head_sha`、`repo_path`、`idempotency_key` 一并送进内部请求; + `review()` 的 Strict 路径按 `strict_readiness(repo, repo_path)`(两参, + 按调用校验 —— `configure_strict_repo` 的进程全局突变已删除)预检。 - **治理靠数据,因为 server 管不住宿主。** "该怎么审"被编码成随返回值一起下发的结构化 字段:≤3 条路由(内嵌 `quick_map`,3.5k 封顶)、一个硬性的 `execution_budget`、 一份 checklist,以及 `mandatory_review_guides` —— 跨 owner 的强制评审程序 @@ -55,14 +65,18 @@ Direct 路径里不调模型;不含 Strict 后台机器(`mcp_server.py`) (`mcp_policy.py`)。 ## 依赖(允许) -stdlib + `mcp` extra + `.adapters` + `.config` + `.intent.resolve_repo_alias` + +stdlib + `mcp` extra + `.direct_routing`(下划线别名 re-import)+ +`.adapters` + `.config` + `.intent.resolve_repo_alias` + `.knowledge_docs` + `.mcp_policy` + `.mcp_server`。 ## 测试 -`test_thin_mcp_server.py`、`test_thin_mcp.py`、`test_imreview_output_contract.py`。 +`test_thin_mcp_server.py`(42 例,别名保持调用点不变)、`test_thin_mcp.py`、 +`test_imreview_output_contract.py`;外加 `test_contract.py` +(`_direct_*` 的公开家与再导出仍然成立)与 `test_e2e_strict_mock.py` +(Strict 快照绑定端到端)。 ## 重构备注 -约 1286 行,是包里**最大**的模块,且自上次核对以来又长了约 480 行;Direct 路由 -helper(`_direct_*`,现有 6 个)是一个内聚单元,如果再次增长,那就是显而易见的 -拆分点。**拆分时务必保住"server 不跑模型"这条 -性质 —— 它就是 Direct 模式的产品承诺。** +拆分**已发生**(→ `contract.py` / `direct_routing.py`,约 1420 → 627 行); +留在这里的是 Strict 桥接、checklist/progress 常量与工具接线本身。 +拆分保住了"server 不跑模型"—— 它仍是 Direct 模式的产品承诺; +后续增长优先落到 `direct_routing`/adapter 数据面,不回到这里。