Files
qinglong/docs/adr/ADR-0168-durable-model-invocation-step-run-fence.md
T

166 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-0168Durable ModelInvocation 与唯一 StepRun Fence
- 状态:Accepted
- 日期:2026-07-26
- 关联:RFC D-12、D-13、D-156、D-157ADR-0156、ADR-0167、ADR-0170
## 背景
Model Gateway 已经能执行受预算约束的远程调用,但进程内 audit 不能回答崩溃、
COMMIT 响应丢失、集群 promotion 或用户取消后的 durable outcome。另建一套
Invocation `pending/running/succeeded` 状态会与既有 Run/StepRun 产生双写;自动重试
结果未知的远程模型调用又可能重复计费,并产生不同输出。
同时,禁用 AI 的路由设备不应因为 workspace 中存在 AI 包就创建 AI 表、加载 adapter
或增加默认 Profile 产物。持久化能力需要复用现有 SQLite/PostgreSQL 部署边界,但
不能再拆一个只有 repository 的 package。
## 决策
### 1. ModelInvocation 是 model StepRun 的 receipt,不是第二状态机
每次 invocation 必须绑定一个 `StepRun.kind=model`
- admission 原子提交 `ready → running`、Run version/event、RunEvent、
StepRunMutation 和 ModelInvocationStart
- completion 原子提交 usage/cost/byte receipt 与
`running → succeeded|failed|timed_out|lost`
- `succeeded` 的 StepRun 只保存 `outputRef=model-invocation:<id>`,正文默认不进入
Start、Completion、RunEvent 或 mutation
- deadline 映射为 `timed_out`
- provider 调用后的 caller abort、consumer stream cancel 或其它结果未知映射为
`lost/outcome_unknown`,只允许人工裁决,不自动 replay。
Start/Completion/Command 使用独立 v1 schema 和 domain-separated SHA-256 digest
单 record/command JSON 最大 24 KiB。完整绑定 Project、Run、StepRun、Trace、provider、
model、policy revision、request digest、deadline、输入/输出字节、token 与可选费用。
### 2. mutation identity 必须由摘要派生
requestId 最长 128 字符,不能用 `requestId + ':started'` 生成事件 ID。
`createModelInvocationMutationIdentity` 以 invocationId、phase 和独立 domain 派生完整
SHA-256,并产生均不超过 128 字符的 mutation/event/dedupe identity。Start 与
Completion normalizer 同时复验 identity、StepRun digest/version、Run event sequence
和 command digest;身份或内容复用漂移一律 conflict。
### 3. durable admission 不得被 Abort race 脱离
Policy 解析和 provider Promise 可以被 deadline 从调用方视角中止;durable admission
不可以。Gateway 必须等待 audit/start sink 自身的受限数据库操作结束:
- admission 未成功时 provider I/O 为零;
- deadline 在 admission 中到达且 start 最终提交时,随后写入 `timed_out` completion
- admission sink 失败时返回 `MODEL_AUDIT_UNAVAILABLE`
- terminal sink 失败时不把 provider 结果交给上层。
这会让一个超时的 admission 在 repository timeout 内继续占用一个有界并发槽,但避免
后台出现无法解释的 running StepRun。repository 必须有 statement/busy timeout,不能
用 Gateway Promise race 替代数据库取消和事务收敛。
### 4. migration、repository 与 coordinator 留在可选 AI 能力族
SQLite 和 PostgreSQL feature migration、原子 repository、durable coordinator 与
recovery 都进入既有 `@qinglong/ai` 的显式 subpath,不新增 workspace package。
双方言实现只依赖 `node:sqlite` 或 runtime-core 的 structural PostgreSQL
Pool/Client port,不导入 Drizzle、`pg`、local-sqlite 或 cluster-postgres,也不增加
optional peer。具体 Profile 只在启用 AI 时注入已有数据库 authority。
AI schema 使用独立 feature migration stream
- AI 未启用时不运行 feature migration、不创建表;
- feature history 使用独立表、stream identity 和 checksum,绝不向双方言 main
migration history 插入 feature row
- PostgreSQL feature tables/history 位于独立 `ql3_ai` schema,避免被核心 `ql3`
exact readiness 误判为未知表;SQLite readiness 保留精确 owned-table 校验并允许
同库显式 feature table
- 启用 composition 必须先显式 migrate/readiness,再构造 repository
- PostgreSQL runtime role 对不可变 invocation 表只取得 `SELECT/INSERT`,不得为了
`FOR UPDATE` 扩大 `UPDATE/DELETE`
- repository subpath 只取得数据库 authority,不取得 provider credential 或外部网络。
Gateway admission sink 返回 `created|existing`。只有 `created` 可开始 provider I/O
`existing` 包括正常重放和 COMMIT 响应丢失后的耐久回查,必须返回
`MODEL_INVOCATION_REPLAY_BLOCKED`,禁止再次调用 provider。bounded recovery 只扫描
已过数据库 deadline 且没有 completion 的 start,并收敛为
`lost/outcome_unknown`,不自动重放模型。
### 5. unknown outcome 只能以不可变人工 Resolution 推进
`lost/outcome_unknown` 不再是永久死点,也不能被自动 retry。唯一出口是受信 User
提交一个 `qinglong/model-invocation-resolution@v1` receipt,决策严格限定为:
- `retry`:原子执行 `lost → ready`,清除旧结果字段;后续 `ready → running` 必须
使用新的 invocationIdStepRun attemptCount 在新 start 时增加;
- `fail`:原子执行 `lost → failed`,固定
`resultCode=model_outcome_rejected`
- `cancel`:原子执行 `lost → cancelled`,固定
`resultCode=model_outcome_cancelled`
Resolution/Command 绑定原 Completion digest、Project/Run/StepRun/Trace、User ID、
StepRun version/digest、mutation/event/dedupe identity 与时间。repository 必须在同一
SQLite/PostgreSQL 事务内复验原 completion 仍是 `outcome_unknown`、当前 StepRun 仍
精确等于该 completion 的 `lost` winner,然后提交 StepRun/Run/RunEvent/
StepRunMutation/Resolution。每个 invocation 只能有一个 Resolution;相同命令 exact
replay,不同 User/decision conflict。
一个 StepRun 可以有多个 attempt,因此 Start/Completion 表不能再对
`(run_id, step_run_id)` 建唯一索引。它们改用包含时间和 invocationId 的普通历史索引,
invocation、mutation、RunEvent 和 digest 继续保持唯一。旧 Start/Completion/
Resolution 永不覆盖,新 attempt 形成新的 invocation 链。
## 被否决方案
1. 把 adapter 放入双方言 storage package 并反向 optional peer AI:会污染默认 packlist
且真实 Edge artifact 已证明即使 AI 不安装也会增加文件。
2. 在 AI 包引入 `pg`/Drizzle:会扩大可选 Edge AI 的依赖和供应链闭包。
3. 把 durable contract 放入 runtime-core root:禁用 AI 的基础 artifact 也会增长。
4. 把 AI 表加入双方言 main migration:禁用功能仍产生 schema、备份和写放大。
5. start audit 使用 `Promise.race`:调用返回后事务仍可能提交 running 状态。
6. 对 unknown outcome 自动 retry:可能重复计费且输出非确定。
7. 保存 Prompt/输出便于调试:默认扩大 Secret、个人数据和备份泄露面。
## 当前验证
- `@qinglong/ai` 默认门:50 pass、1 条 PostgreSQL 条件 skip;真库门另 1 pass
- 最大长度 requestId 的 mutation/event/dedupe identity 均不超过 128 字符;
- admission deadline 期间 provider 调用为零,start sink 不脱离,随后写入明确的
deadline failure
- success、deadline 与 caller-abort 分别收敛为
`succeeded``timed_out``lost/outcome_unknown`
- SQLite 与 PostgreSQL feature history 使用独立表;主迁移与 AI 迁移交错重启通过;
- SQLite 原子 admission/completion、provider replay block 和 expired recovery 通过;
- SQLite Edge `DELETE/FULL` 与 Standalone `WAL/FULL` 共 14 个真实文件
`SIGKILL` 窗口通过:10 个 COMMIT 前窗口零部分事实并重放 `created`4 个 COMMIT
后窗口重启为 durable winner 并重放 `existing`,全部 `integrity_check=ok`
- PostgreSQL 18 真库以 migration/runtime 分角色验证原子事务、exact replay、rollback、
bounded recovery source,以及 invocation 表仅 `SELECT/INSERT`
- PostgreSQL ModelInvocation `COMMIT` 已提交但响应丢失时,provider 调用为 0,
coordinator durable recheck 返回 existingrecovery 收敛为
`lost/outcome_unknown`
- SQLite 与 PostgreSQL 都覆盖 User `retry/fail/cancel` 原子 Resolution`retry`
后同一 StepRun 可用新 invocationId 进入第二次 runningattemptCount 从 1 增至 2
原 unknown completion 保持不变;不同 decision replay 被拒绝;
- dependency audit `findings=[]`AI 只有 runtime-core workspace dependency
- Edge 基线仍为 3,902,728 bytes/478 files/40 modulesedge-ai 为
4,212,508 bytes/508 files/41 modulesstandalone-ai 为
4,212,580 bytes/508 files/41 modules
- ADR-0170 已把非空 usage 从 Completion 原子投影为独立不可变 ledger;失败 outcome
也保留已知 usage,无 usage 不伪造零成本,exact replay 同时复验 Completion 与
ledger
- PostgreSQL 18.4 arm64 完整 HA 门通过 physical streaming、timeline 1→2、fencing、
双 control replica、`pg_rewind` 与总 `passed=true``ql3_ai` 独立 history 和
Start/Completion/Resolution/UsageLedger 四表在 promotion 前后 9001/9002
checksum、表集与 runtime append-only ACL 完全一致,
`optionalAiFeatureSchemaSurvivesPromotion=true`
## 后续门禁
1. credential binding audit 的双方言持久化,以及 binding digest 与 invocation
查询的关联;Project-bound SecretRef 和可清零 material lease 已由 ADR-0169
完成;
2. ADR-0170 ledger 之上的原子 Project quota、计价 catalog/revision、不可变 rollup
与 retention coverage receipt
3. AI invocation 数据行级 promotion/partition fault,而不只 schema promotion
4. 正式产品 importer、配置 ceremony、read-only usage/Copilot/MCP authority
5. 在这些门禁完成前保持 HTTP/MCP/UI 产品 route 和费用配额关闭。