文档/ (2026-09-16; 老板定调: 这是 agent 底座, 所以必须扎实, 现在越扎实以后开发越简单)
00-索引 文档地图 + 30 秒概念速查 + 三条命令跑起来 + 事实源优先级
01-快速上手 体检 -> 建库 -> 起内核 -> 起驱动 -> 收工, 全带实测输出; 第一次最易踩的四个坑
02-写一个驱动 五分钟最小驱动 / 形态选择 / 能碰哪些表 / 汇报与调用两份模板 / 交付检查表
03-命令手册 两层每条命令 + 日志选项 + 退出码约定 + --json 样例 + 日常十条
04-契约与调用 一次调用的完整生命周期 / 六条仲裁 / 锁与按需拉起 / 排障表
05-日志与排障 三条道怎么读 + "症状->判据->处置"总表 + 断电收尸语义
06-架构与不变量 分层 / 14 条硬不变量 / 主流程表 / 双真相 / 为什么故意不做 / 已知薄弱点
07-模块与接口 逐模块职责与公开接口 + "想改 X -> 动哪几处"连带清单
08-数据模型 8 张表逐字段 (谁写谁读) + events.kind 字典 + 状态机 + 快照 + 排查 SQL
09-扩展指南 六个配方 (加子命令/加字段/加表/加日志来源/加自测/改判定) + 同步清单
10-验收与质量门 四道门 + 五份自测明细 + pyright 严格档 + 26 条已知坑总表 + 发布 checklist
规矩: 不重复设计文档 / 每条命令实测过再写 (含 jq 表达式) / 代码>设计>文档 的事实源优先级 /
改代码必须同步文档 (清单在 09 末尾) / 暂时没做到的事写成"已知边界"不含糊过去
修复: 同锁串行化原来是死的 (实测抓到的真缺陷)
旧行为: db.领调用 只领 pending (waiting 没人再碰) + db.同锁在跑 把 waiting 也算"占着锁"
-> 同一把锁上两条请求互相排队, 双双停在 waiting 谁也不跑 (实测 id 16/17);
而 收权超时 只收 running -> 排队连超时都没有 = 死锁
修法: ① db.领调用 的 SQL 改 state IN ('pending','waiting') -- 每轮把排队的领回来重判, 锁一空就推进
② db.同锁在跑 只认 state='running' (排队的还没拿到锁, 不挡人)
③ 内核.转发调用 waiting 分支补 deadline (排队也立期限); 内核.收权超时 遍历 running + waiting
④ 抽出 内核.期限文本() 统一算 deadline
实测: 两条同锁调用串行跑完 (19.started_at == 18.finished_at); 排队者超时被收权 (events 有记录)
回归: 自测db.py 调用组 +4 条断言 (waiting 不算占着锁 / waiting 会被重新领 / ...);
去掉一条依赖生产库全局计数的脆弱断言
其它: 内核 与 引导器 的 用法() 末尾加文档指引
验收: uvx pyright 0 errors / 0 warnings; 五份自测全过 (进程/内核 58/配置/db/日志 86);
试跑引导器.py PASS 11 / FAIL 0 / 残留无; 残留进程 0
9.0 KiB
04 · 契约与调用(驱动之间怎么"牵线")
核心口径:驱动之间零耦合 —— 不 import 对方、不互相调用、配置里也不写对方的名字。 只声明"我要什么 / 我产出什么"(契约名),谁给、什么顺序、怎么送达全归内核。 这里讲的是用它;判定表原文在
设计/02-内核设计.md第 2 节末与设计/01-驱动规范.md第 3 节。
1. 契约 = 中立的能力名
样板:心跳 ← 好: 说的是"一种能力", 换谁提供都不影响消费方
样板常驻 ← 坏: 这是驱动名, 消费方就"认识"对方了 (一旦换名字就全断)
命名建议 域:能力(冒号当分隔,纯文本,大小写敏感)。契约名不是驱动名,也不是文件路径 ——
它只是内核手里那张"谁提供什么"的对照表里的一个键。
| 字段 | 谁声明 | 含义 |
|---|---|---|
provides |
提供方 | 我产出什么能力(可以多个) |
needs |
消费方 | 我需要什么能力(可以多个) |
同一个契约被两个驱动声明 provides → 按驱动名排序取先的,并记一条扫描警告(确定性,不靠运气)。
2. 一次调用的完整生命周期
消费方 calls 表 内核(常驻) 提供方
│ │ │ │
├─ INSERT (want=契约名) ────►│ pending │ │
│ │ ◄── 领调用 (pending/waiting→waiting) │
│ │ ◄── 六条仲裁 │
│ │ ├─ 没在跑? 按需拉起 ───►│
│ │ running (provider/deadline) │ │
│ │ ◄────────── pg_notify driver_<提供方> ─────────────────┤
│ │ │ 干活 │
│ ◄── 轮询 id 直到有结论 ────│ done / failed │ ◄── UPDATE result ─────┤
│ │ │ │
│ 另外三种结论: denied (被拒) / timeout (内核收权) / waiting (排队中) │
calls.state 取值与含义:
| state | 谁写的 | 含义 |
|---|---|---|
pending |
消费方 INSERT | 刚发出来,等内核看见 |
waiting |
内核 | 正在仲裁中,或在锁上排队(每轮会被重新领回来重判,锁空即推进) |
running |
内核 | 已转发给提供方(provider 有值,deadline 已立) |
done / failed |
提供方回填 | 干完了(结果在 result)/ 干不了(原因在 error) |
denied |
内核 | 被仲裁拒(越权 / 成环 / 没人提供 / 提供方起不来) |
timeout |
内核 | 过了 deadline,内核收权(running 和 waiting 都会超时) |
3. 仲裁六条(内核拿到请求时依次判什么)
| # | 规则 | 不过时 | 怎么修 |
|---|---|---|---|
| 1 | 越权:只能要自己 needs 里声明过的契约 |
denied:越权: <驱动> 的 needs 里没有 <契约> |
在 配置.efi.json 的 needs 里补上 |
| 2 | 成环:要的契约或其提供方已在这次调用的链上 | denied:调用链成环: A -> B -> A |
检查依赖方向(扫描期就能挡掉静态环) |
| 3 | 无人提供 | denied:契约没人提供: <契约> |
要么写提供方,要么去掉这个 needs |
| 4 | 提供方没在跑 → 按需拉起 | 拉不起来才 denied:提供方 X 起不来: … |
看提供方的日志(它自己起不来) |
| 5 | 同一 lock_key 串行化(先到先执行) |
排队:锁 X 上已有调用在跑, 排队 → waiting |
正常现象;等前一条干完会自动推进 |
| 6 | 超时收权(deadline 到期) |
内核标 timeout 并释放锁 |
看提供方为什么慢;调用默认时限秒 = 60 |
链(chain)是消费方自己带的路书:args = {"chain": ["驱动A"]},转发时内核把它当"这次请求已经走过的路"。
判环只看两件事:要的契约或匹配出来的提供方是否已在链上 —— 不能拿发起方自己判(它就是链尾,
那样第一次调用也会被拒;这个坑 2026-09-16 踩过)。
4. 锁(lock_key):同一份数据不许两个人同时写
lock_key由消费方给(建议直接用契约名)。为空 = 不参与串行化。- 判定只认
state='running':拿到锁在干活的那条才挡人;排队中的(waiting)不挡人。 - 排队者每轮被重新领回来重判 → 前一条
done后自动推进(实测:两条同锁请求1819串行跑完,19.started_at=18.finished_at)。 - 排队也有期限:第一次排队时立
deadline,排到超时会被收权(timeout),不会永远等。
2026-09-16 修掉的真缺陷:以前"只领
pending" + "waiting也算占锁",导致两条同锁请求 互相排队、双双卡在waiting谁也不跑。改了两处 SQL(db.领调用/db.同锁在跑)+ 排队补deadline,回归测试在内核/自测db.py的调用组("waiting 不算占着锁"/"waiting 会被重新领")。
5. 按需拉起:消费方不用管提供方在不在跑
内核在仲裁第 4 步发现提供方没在跑,会当场把它拉起来(记一条 被调用 N 按需拉起 的事件):
提供方 mode |
内核怎么处理 |
|---|---|
resident |
拉起(它自己 LISTEN driver_<名>,等内核 pg_notify 转发) |
oneshot |
拉起 → 它跑一遍 → 自己退(不用 LISTEN) |
实测(设计/02 里的活体验收):只起消费器,内核自动把提供方拉起来,calls 回填 done:
调用 5: 样例消费器 要 样板:心跳 -> 转发给 样板常驻
样板常驻 起来了 pid=… (被调用 5 按需拉起)
6. 驱动侧两份模板
发请求(消费方) —— 完整可跑版 驱动/样例消费器/请求.py:
# ① 插一行 (want 写契约名; chain 带上自己, 让内核能挡环)
游标.execute(
"INSERT INTO calls (caller, want, args, lock_key) VALUES (%s, %s, %s::jsonb, %s) RETURNING id",
("我的驱动名", "样板:心跳", json.dumps({"chain": ["我的驱动名"]}, ensure_ascii=False), "样板:心跳"),
)
调用id = int(游标.fetchone()[0])
# ② 轮询直到有结论 (等不到要如实说"等不到", 别无限等)
# SELECT state, provider, result, error FROM calls WHERE id = %s
# state ∈ {done, failed, denied, timeout} 就是有结论了
# ③ result 里取数据
接活(提供方,resident) —— 完整可跑版 驱动/样板常驻/心跳.py:
连接.execute("LISTEN driver_我的驱动名") # 通道名跟"我是谁"有关, 跟谁在调我无关
# 主循环里: while 连接.notifies: 通知 = 连接.notifies.pop(0)
# 调用id = int(通知.payload) # 内核 pg_notify 的载荷就是 calls.id
# 干活 → UPDATE calls SET state='done', result=%s::jsonb, finished_at=now() WHERE id=%s
# **只许改自己这条**: 不改别人的行, 也不去查"谁在调我"
驱动不许直接调用另一个驱动,也不许知道对方是谁 —— 这是"打不起来架"的全部秘密。
7. 排障表(调用相关)
| 症状 | 判据 | 处置 |
|---|---|---|
请求一直 pending |
常驻内核没在跑 | python3 UEFI.boot.py 内核 状态;没跑就 内核 启动 --守护 |
denied: 越权 |
needs 没声明 |
配置里补 needs 并 重启 <驱动> |
denied: 契约没人提供 |
没有驱动的 provides 对上 |
列表/状态 看那个驱动是不是 无效(原因在它 error 里) |
denied: 提供方 X 起不来 |
提供方自己坏 | 内核/内核.py 日志 X -n 50,看它临死说了什么 |
卡在 waiting |
同一 lock_key 上还有 running 的 |
正常排队;若对方真卡死,等 deadline 到点内核收权(默认 60s) |
timeout |
提供方干太久 | 查提供方;需要更长的时限就调 内核/内核.py 里的 调用默认时限秒 |
| 回填了结果但消费方看不到 | 消费方在轮询别的 id,或状态不是终态 | psql … SELECT * FROM calls WHERE id=… 看真实状态 |
8. 本版不做(边界,别以为是 bug)
- 没有优先级 / 公平性:锁上按
id先到先得,没有权重、没有抢占。 - 没有推送回调:消费方轮询
calls行(pg_notify只用于内核 → 提供方那一段)。 - 没有结果大小限制:
result是 jsonb,大对象该走"提供方写自己的表 + result 只放引用"。 - 没有跨库调用:一张
calls表、一个 PG 库。跨库需求 = 换架构,不在本版。