Files
EvoScientist/.superpowers/sdd/briefs/task-4-report.md
T
m4 c8c46eab16 fix(model-registry): make snapshot abort atomic against concurrent bind
The abort path was read-then-write with an unconditional UPDATE, so a bind
committing between the two calls was clobbered back to aborted, losing its
langgraph_run_id. Add a conditional store-level abort_run_snapshot
(prepared-only UPDATE, rowcount-checked) and re-read on a lost race, matching
the bind loop. Also pin the inherit selection_hash test to a hardcoded
SHA-256 literal instead of reimplementing the serialization in the test.
2026-07-21 08:45:33 +08:00

11 KiB
Raw Blame History

Task 4 报告 — ModelRegistryResolver + 运行快照服务

  • 状态:DONE
  • 分支:feature/unified-model-config
  • Commit:b1233d4 feat(model-registry): add ModelRegistryResolver and run snapshot service
  • 规格来源:docs/unified-model-configuration-architecture.md v1.1.1,章节 4.3 / 5.2 / 6.1 / 6.4 / 6.5 / 8.1 / 8.2

交付物

新建 EvoScientist/model_registry/resolver.py

ModelRegistryResolver(store, *, specs=None):

  • resolve(model_ref, role="primary", *, registry=None) -> ResolvedModelConfig,校验顺序:
    1. Provider 存在(否则 404 MODEL_NOT_FOUND)且 enabled(否则 422 MODEL_NOT_AVAILABLE);
    2. 模型存在(MODEL_NOT_FOUND)且 enabled(否则 422 MODEL_DISABLED);
    3. find_adapter_spec 匹配契约:未开放 Adapter 抛 ADAPTER_NOT_SUPPORTED,无匹配契约抛 MODEL_NOT_AVAILABLE(只可保持 configured);
    4. resolve_parameters 重放保存期契约校验(AuthSpec、能力、参数范围/互斥);
    5. 凭据:credential_required 时读 credential_pointers.current_revision 写入 auth_ref(指针缺失 → CREDENTIAL_NOT_CONFIGURED),从不读 secret_value;mode=none 冻结 {mode: none};
    6. 当前验证五元组(configuration_hash + 当前凭据 revision + 当前 adapter_spec_revision)必须存在且 passed,否则 MODEL_NOT_AVAILABLE;
    7. limits_status=confirmed,否则 MODEL_LIMITS_UNCONFIRMED;
    8. 预算(6.5):combined → context_window - max_output;input_only → max_input;四种工具/附件组合扣除固定预留后均须 ≥ min_effective_input_tokens,否则 CONTEXT_BUDGET_UNSATISFIABLE。冻结 resolved_input_limit、三个固定预留与 base message_budget(仅扣系统预留;逐次调用的预算由 MessageBudgetMiddleware 按 6.5 公式重算,Task 6 接线);
    9. 能力按唯一规则 protocol AND declared AND verified 计算并冻结。
  • resolve_for_test(model_ref):仅放宽"模型必须 enabled",其余校验(含 Provider enabled、凭据、验证记录、限制、预算)全部执行(9.4)。
  • compute_availability(registry, verifications, *, credential_revisions=None) -> list[ModelAvailability]:4.3 判定顺序 unavailable → enabled → verified → verification_failed → verification_stale → configured,首个命中生效;verification_stale 优先于 configured;selectable 仅 enabled 为 true。verification 报告当前记录的 passed/failed 或最新旧记录的 stale;effective_capabilities 仅在当前记录 passed 时非全 false。reason_code 取值:PROVIDER_DISABLED / NO_ADAPTER_CONTRACT / MODEL_DISABLED / 记录的错误码 / VERIFICATION_STALE。
  • 6.1 角色映射实现为 snapshots.config_for_role(映射对象是快照而非 Registry,故放在快照模块):primary → snapshot.primary;auxiliary/summary/tool_selector → snapshot.auxiliary ?? snapshot.primary;未知角色 ValueError。Resolver 不猜测其他映射。

新建 EvoScientist/model_registry/snapshots.py

SnapshotService(store, resolver)(Task 5 HTTP API 与 Task 7 本地入口共用):

  • create(SnapshotCreateRequest):bootstrap → 422 MODEL_REGISTRY_NOT_READY;primary=null(inherit)解析 Registry defaults.primary,auxiliary=null 解析 defaults.auxiliary;冻结两个完整 ResolvedModelConfig(含 adapter_spec_revision、固定预留、能力、auth_ref 凭据版本)与 registry_revision、model_selection_revision 写入 payload_json,绝无 secret。selection_hash = 解析前 {primary, auxiliary}(inherit 以 null 参与)固定字段序 JSON 的 SHA-256;model_selection_revision 仅审计、不参与哈希。
  • 幂等:同一三元组哈希相同返回原快照(created=False,200 语义),不同抛 409 RUN_REQUEST_CONFLICT;并发创建撞部分唯一索引时回退到同一幂等比较。expired/aborted 不占三元组,同三元组可重建(created=True,201)。
  • bind(snapshot_id, langgraph_run_id):prepared→bound 一次(条件 UPDATE 保证原子),并把 expires_at 延长到 +24h;相同 run id 重复 bind 幂等成功,不同值抛 SNAPSHOT_ALREADY_BOUND;终态抛 SNAPSHOT_EXPIRED;不存在抛 SNAPSHOT_NOT_FOUND。
  • abort(snapshot_id):仅 prepared→aborted;已 bound 抛 SNAPSHOT_ALREADY_BOUND;expired 抛 SNAPSHOT_EXPIRED;重复 abort 幂等成功。
  • get(snapshot_id, *, deployment_id, thread_id):绑定关系不匹配按 SNAPSHOT_NOT_FOUND 失败(不跨线程/部署泄露存在性);终态抛 SNAPSHOT_EXPIRED;读取时对两个冻结配置逐一重校验 adapter_spec_revision 仍存在(经 Resolver 的 spec 列表),已移除抛 ADAPTER_NOT_SUPPORTED,绝不静默替换。
  • cleanup_expired(now):到期 prepared(创建时 TTL 15 分钟)与 bound(bind 时 +24 小时)置为终态 expired,返回迁移的 ID。
  • resolve_snapshot_credential(snapshot, role) -> str:经 6.1 角色映射取冻结 auth_ref,按冻结 credential_revision 每次从凭据存储解析(无进程内密钥缓存);版本销毁抛 RUN_CREDENTIAL_REVISION_UNAVAILABLE;mode=none 返回 ""。
  • public_snapshot_view(snapshot):仅 8.2 示例字段(snapshot_id、registry_revision、primary/auxiliary 的 provider_id/model_key/adapter_spec_revision/runtime 五项),无 base_url、无 secret。

Task 1-3 文件的增补(均为纯新增,未改动任何既有对外行为)

  • errors.py:新增 SNAPSHOT_NOT_FOUND(404)。9.5 承认"404 资源不存在"类别但总表只有 MODEL_NOT_FOUND(语义为 ModelRef);快照缺失需要独立稳定码,属对总表的增补,已同步更新 Task 1 的 ALL_ERROR_CODES 测试(仅加一行)。
  • store.py:新增 current_credential_revision、list_model_verifications(可用性判定的输入)、find_active_run_snapshot(三元组幂等查询)、bind_run_snapshot(WHERE status='prepared' 条件绑定 + 延长 expires_at)、expire_due_run_snapshots;行→dict 映射提取为共享私有helper,既有方法签名不变。
  • __init__.py:导出新符号。
  • 未删除 EvoScientist/llm/runtime_snapshots.py(Task 6 切换)。

Task 3 评审接线要求的遵守

本任务不构造任何 HTTP client / ChatModel:resolve/resolve_for_test 只产出 ResolvedModelConfig,SnapshotService 只做冻结与读取。因此 build_chat_model 双传 sync+async client、ollama max_retries 经 client builder retries= 执行这两条接线要求在本任务无适用点,也未被绕过;Task 5/7 构造 client 时仍须遵守(build_safe_http_client(policy, retries=resolved.client_options.max_retries) 双传)。

测试(TDD)

先写 tests/test_resolver.py(37 例)与 tests/test_snapshots.py(34 例)并确认红灯(模块不存在),再实现至全绿。覆盖简报全部要求:

  • resolve 正/反例:完整冻结断言(含预算数值 1048576-32768-4096)、Provider disabled→MODEL_NOT_AVAILABLE、模型 disabled→MODEL_DISABLED、无匹配契约→不可用、验证缺失/失败/五元组不一致(配置哈希、凭据轮换)→不可用、MODEL_LIMITS_UNCONFIRMED、CONTEXT_BUDGET_UNSATISFIABLE、四种角色戳记、未开放 Adapter、AUTH_MODE_UNSUPPORTED、mode=none 无凭据解析、resolved config 不含 secret;
  • resolve_for_test:放宽模型 enabled、其余校验不放宽;
  • ModelAvailability 六态判定顺序、stale 优先 configured、凭据轮换致 stale、selectable 仅 enabled、全 Provider 全模型覆盖;
  • 快照:inherit 解析 defaults(含 auxiliary default null 两条路径)、显式选择冻结双角色、bootstrap 422、selection_hash 解析前语义(显式等于默认仍不同哈希)、revision 不参与哈希、幂等 200/冲突 409、expired/aborted 后重建 201、payload 冻结凭据版本且无 secret、prepared TTL 15min 断言;
  • bind 一次/同 id 幂等/异 id 409/expired/aborted/未知 404;abort 规则全集;get 绑定校验(跨线程/跨部署拒绝)、expired 拒绝、spec_revision 移除后读取 ADAPTER_NOT_SUPPORTED;cleanup 到期迁移;bind 后 +24h 断言;
  • 凭据:冻结 revision 解析、轮换后旧版本仍可用、销毁后 RUN_CREDENTIAL_REVISION_UNAVAILABLE、新 store 实例(无进程缓存)仍可解析、辅助角色解析到 auxiliary 凭据、mode=none 返回空串;
  • 6.1 角色映射(auxiliary 冻结/缺省两路径、未知角色)与 public_snapshot_view 形状(无 secret、无 base_url)。

验证结果

  • .venv/bin/python -m pytest tests/test_resolver.py tests/test_snapshots.py -x -q → 71 passed
  • .venv/bin/python -m pytest tests/ -x -q → 3131 passed, 10 skipped(无回归;期间修复一处:Task 1 错误码表测试因新增 SNAPSHOT_NOT_FOUND 需增补一行期望值)
  • ruff check 与 ruff format --check(model_registry 全包 + 涉及测试)→ 全净

疑虑 / 后续注意

  1. SNAPSHOT_NOT_FOUND 是对设计文档 9.5 错误码总表的增补(404 类别文档已承认,但总表未列快照缺失码);Task 5 HTTP 层应直接复用。
  2. 终态(expired/aborted)快照的 bind/get 统一抛 SNAPSHOT_EXPIRED;文档只明文规定 expired 的情形,aborted 按同一终态语义处理。
  3. 冻结的 budget.message_budget 取 base(仅扣系统预留);逐次调用的 has_tools/has_attachments 重算属 Task 6 的 MessageBudgetMiddleware。
  4. resolve 支持 registry= 参数供 create 传入同一份 Registry,保证快照 registry_revision 与解析所用文档一致。

评审修复(2026-07-21,commit 见下)

  1. Important:abort read-then-write 竞态。原实现先读后写且 set_run_snapshot_status 为无条件 UPDATE,并发 bind 在两次调用间提交时会把 bound 改写为 aborted 并丢失 langgraph_run_id。修复:store 层新增 abort_run_snapshot(UPDATE ... SET status='aborted' WHERE snapshot_id=? AND status='prepared',按 rowcount 判定),SnapshotService.abort 改为与 bind 相同的读-条件写-失败重读循环;已 aborted 重复调用保持幂等成功。
  2. Minor:selection_hash 期望值自证。test_selection_hash_uses_pre_resolution_semantics 原先用测试内重复实现的同一序列化逻辑计算期望值(两侧同变不红)。改为钉死离线算出的 SHA-256 字面值({"auxiliary":null,"primary":null} → 697c0462...55abc,常量 INHERIT_SELECTION_HASH),锁住对外契约;删除测试内的重复实现。

新增测试(先红后绿):

  • test_abort_run_snapshot_store_update_is_conditional:store 层条件 UPDATE 的 rowcount 语义(prepared→True,重复/bound→False 且行不被改写)。
  • test_abort_losing_bind_race_keeps_bound_state:monkeypatch get_run_snapshot 在 abort 读与写之间插入并发 bind,断言 abort 抛 SNAPSHOT_ALREADY_BOUND 且行保持 bound、langgraph_run_id 完好(旧实现此测试必红)。

验证:

  • .venv/bin/python -m pytest tests/test_snapshots.py -x -q → 42 passed
  • .venv/bin/python -m pytest tests/ -x -q → 3133 passed, 10 skipped(无回归)
  • ruff check / ruff format --check(涉及文件)→ 全净