c8c46eab16
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.
11 KiB
11 KiB
Task 4 报告 — ModelRegistryResolver + 运行快照服务
- 状态:DONE
- 分支:
feature/unified-model-config - Commit:
b1233d4feat(model-registry): add ModelRegistryResolver and run snapshot service - 规格来源:
docs/unified-model-configuration-architecture.mdv1.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,校验顺序:- Provider 存在(否则 404
MODEL_NOT_FOUND)且 enabled(否则 422MODEL_NOT_AVAILABLE); - 模型存在(
MODEL_NOT_FOUND)且 enabled(否则 422MODEL_DISABLED); find_adapter_spec匹配契约:未开放 Adapter 抛ADAPTER_NOT_SUPPORTED,无匹配契约抛MODEL_NOT_AVAILABLE(只可保持 configured);resolve_parameters重放保存期契约校验(AuthSpec、能力、参数范围/互斥);- 凭据:
credential_required时读credential_pointers.current_revision写入auth_ref(指针缺失 →CREDENTIAL_NOT_CONFIGURED),从不读secret_value;mode=none冻结{mode: none}; - 当前验证五元组(
configuration_hash+ 当前凭据 revision + 当前adapter_spec_revision)必须存在且passed,否则MODEL_NOT_AVAILABLE; limits_status=confirmed,否则MODEL_LIMITS_UNCONFIRMED;- 预算(6.5):
combined→context_window - max_output;input_only→max_input;四种工具/附件组合扣除固定预留后均须 ≥min_effective_input_tokens,否则CONTEXT_BUDGET_UNSATISFIABLE。冻结resolved_input_limit、三个固定预留与 basemessage_budget(仅扣系统预留;逐次调用的预算由 MessageBudgetMiddleware 按 6.5 公式重算,Task 6 接线); - 能力按唯一规则
protocol AND declared AND verified计算并冻结。
- Provider 存在(否则 404
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 → 422MODEL_REGISTRY_NOT_READY;primary=null(inherit)解析 Registrydefaults.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 语义),不同抛 409RUN_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 全包 + 涉及测试)→ 全净
疑虑 / 后续注意
SNAPSHOT_NOT_FOUND是对设计文档 9.5 错误码总表的增补(404 类别文档已承认,但总表未列快照缺失码);Task 5 HTTP 层应直接复用。- 终态(expired/aborted)快照的 bind/get 统一抛
SNAPSHOT_EXPIRED;文档只明文规定 expired 的情形,aborted 按同一终态语义处理。 - 冻结的
budget.message_budget取 base(仅扣系统预留);逐次调用的 has_tools/has_attachments 重算属 Task 6 的 MessageBudgetMiddleware。 resolve支持registry=参数供create传入同一份 Registry,保证快照registry_revision与解析所用文档一致。
评审修复(2026-07-21,commit 见下)
- Important:
abortread-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 重复调用保持幂等成功。 - 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:monkeypatchget_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(涉及文件)→ 全净