底座文档 11 份 + 修掉"同锁调用互相排队、双双卡死"

文档/ (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
This commit is contained in:
lou
2026-09-16 21:13:31 +08:00
parent 0827b2399c
commit 53e7d1e5f2
15 changed files with 1655 additions and 35 deletions
+2
View File
@@ -1529,6 +1529,8 @@ def 用法() -> str:
" 列表 | 扫描 | 启动 <驱动> | 停止 <驱动> | 重启 <驱动> | 状态 | 日志 <驱动> | 事件 | 清单",
"",
" 分界: 引导器自己的命令 PG 不通只 WARN (降级到 json 快照); 透传内核的命令不降级.",
"",
" 文档: 文档/00-索引.md (用起来 01-05 / 改底座 06-10; 设计档案 设计/, 日志系统 设计/04)",
]
)
+17 -4
View File
@@ -594,16 +594,24 @@ def 读命令(连接: Any, 命令id: int) -> dict[str, Any] | None:
def 领调用(连接: Any) -> list[dict[str, Any]]:
"""常驻内核领走所有待处理的驱动调用请求 (pending -> waiting), 返回这些行.
"""常驻内核领走所有待处理的驱动调用请求 (pending / waiting -> waiting), 返回这些行.
驱动只写 calls 表, 不跟内核握手 -- 内核轮询这一张表就等于收请求 (没有协议).
为什么 pending 和 waiting 都要领 (2026-09-16 实测踩到):
只领 pending 时, "因为锁被别人占着"而排队的行会**永远卡在 waiting** -- 没人再碰它,
而 收权超时 又只收 running, 于是排队 = 死。更糟的是两条同锁请求同批进来会**互相排队**:
甲看乙在 waiting、乙看甲在 waiting, 双双不动 (实测: 两条都停在 waiting, 谁也不跑)。
所以每轮把 waiting 一起领回来重判: 锁一空就自动推进成 running。
判重的开销可以忽略 (表小, 一轮 0.5s), 换来的是"排队真能排到头".
注意 SQL 写法: UPDATE 不能直接带 ORDER BY (PG 会报 syntax error at or near "ORDER");
要排序 + 跳锁就得用子查询 (跟 领命令 同一个套路, 这里踩过一次).
"""
with 连接.cursor(cursor_factory=_游标工厂()) as 游标:
游标.execute(
"UPDATE calls SET state = 'waiting'"
" WHERE id IN (SELECT id FROM calls WHERE state = 'pending'"
" WHERE id IN (SELECT id FROM calls WHERE state IN ('pending', 'waiting')"
" ORDER BY id FOR UPDATE SKIP LOCKED)"
" RETURNING *"
)
@@ -649,10 +657,15 @@ def 取调用(连接: Any, state: str, 条数: int = 200) -> list[dict[str, Any]
def 同锁在跑(连接: Any, lock_key: str, 排除id: int) -> bool:
"""同一个 lock_key 上是不是已经有别的调用在跑 / 排队 (防打架第 1 条: 同一份数据串行化)."""
"""同一个 lock_key 上是不是**已经有调用真正在跑** (防打架第 1 条: 同一份数据串行化).
只认 state='running' (2026-09-16 修的): 把 'waiting' 也算进"占着锁"会让两条同锁请求
**互相排队** -- 甲看乙 waiting 就排队、乙看甲 waiting 也排队, 两条一起卡死 (实测过).
排队的行应该看成"还没拿到锁", 所以它不挡别人; 真正挡人的是拿到锁在干活那条.
"""
= _查(
连接,
"SELECT id FROM calls WHERE lock_key = %s AND state IN ('waiting', 'running') AND id <> %s LIMIT 1",
"SELECT id FROM calls WHERE lock_key = %s AND state = 'running' AND id <> %s LIMIT 1",
(lock_key, 排除id),
)
return bool()
+49 -29
View File
@@ -588,6 +588,16 @@ def 校验调用(连接: Any, 环境: dict[str, Any], 契约: dict[str, str],
return "running", f"转发给 {提供方}"
def 期限文本(时限秒: float | None = None) -> str:
"""给一条调用算 deadline (ISO8601 带时区) -- 内核按它收权, 不让人无限占着.
参数:
时限秒: 多少秒之后算超时; None = 用 调用默认时限秒.
"""
秒数 = 调用默认时限秒 if 时限秒 is None else 时限秒
return (datetime.now().astimezone() + timedelta(seconds=秒数)).isoformat(timespec="seconds")
def 转发调用(连接: Any, 环境: dict[str, Any], 契约: dict[str, str], : dict[str, Any]) -> None:
"""仲裁 + 转发一条调用请求 (结果字段留给驱动回填: 内核只做仲裁/唤醒/收权).
@@ -605,11 +615,15 @@ def 转发调用(连接: Any, 环境: dict[str, Any], 契约: dict[str, str],
return
if 判定 == "waiting":
db.写调用(连接, 调用id, {"state": "waiting", "error": 说明})
等待改动: dict[str, Any] = {"state": "waiting", "error": 说明}
# 排队也要有期限: 第一次排队时立 deadline. 否则提供方一直占着锁, 排队者能等到天荒地老
# (2026-09-16 修: 以前 waiting 行既没 deadline 又不会被重新领 -> 排队 = 永久卡住).
if .get("deadline") is None:
等待改动["deadline"] = 期限文本()
db.写调用(连接, 调用id, 等待改动)
return
提供方 = 契约.get(要的, "")
时限 = datetime.now().astimezone() + timedelta(seconds=调用默认时限秒)
db.写调用(
连接,
调用id,
@@ -617,7 +631,7 @@ def 转发调用(连接: Any, 环境: dict[str, Any], 契约: dict[str, str],
"state": "running",
"provider": 提供方,
"started_at": 状态.现在文本(),
"deadline": 时限.isoformat(timespec="seconds"),
"deadline": 期限文本(),
"error": None,
},
)
@@ -634,37 +648,42 @@ def 转发调用(连接: Any, 环境: dict[str, Any], 契约: dict[str, str],
def 收权超时(连接: Any) -> int:
"""给超时的调用收权 (防打架第 4 条): 标 timeout + 释放锁, 免得一个卡死的驱动拖垮全局.
两种都要收 (2026-09-16 修的):
running -- 提供方拿着锁干超时了;
waiting -- 排队的也没等到头 (提供方占着锁不松). 以前只收 running, 排队的行会永远等着.
返回:
收了几条.
"""
现在 = datetime.now().astimezone()
= 0
for in db.取调用(连接, "running"):
时限值 = .get("deadline")
if 时限值 is None:
continue
时刻 = 时限值 if isinstance(时限值, datetime) else None
if 时刻 is None:
continue
if 时刻.astimezone() < 现在:
db.写调用(
连接,
int(.get("id") or 0),
{
"state": "timeout",
"error": f"超过期限 {时刻.isoformat(timespec='seconds')}, 内核收权",
"finished_at": 状态.现在文本(),
},
)
db.写事件(
连接,
"内核",
"error",
f"调用 {.get('id')} 超时收权 (提供方 {.get('provider')})",
driver=str(.get("provider") or ""),
level="warn",
)
+= 1
for 状态值 in ("running", "waiting"):
for in db.取调用(连接, 状态值):
时限值 = .get("deadline")
if 时限值 is None:
continue
时刻 = 时限值 if isinstance(时限值, datetime) else None
if 时刻 is None:
continue
if 时刻.astimezone() < 现在:
db.写调用(
连接,
int(.get("id") or 0),
{
"state": "timeout",
"error": f"超过期限 {时刻.isoformat(timespec='seconds')}, 内核收权",
"finished_at": 状态.现在文本(),
},
)
db.写事件(
连接,
"内核",
"error",
f"调用 {.get('id')} 超时收权 (原来是 {状态值}, 提供方 {.get('provider') or '未定'})",
driver=str(.get("provider") or ""),
level="warn",
)
+= 1
return
@@ -1238,6 +1257,7 @@ def 用法() -> str:
"",
" 判活一律回 /proc 复核; 停之前先校验 cmdline (pid 会被系统复用, 宁可不杀不可误杀)",
" 日志分三条道: 内核.log(结构化) / 内核.out.log(命令输出+崩溃原文) / 驱动/<名>/logs(驱动原始输出)",
" 文档: 文档/00-索引.md (用起来 01-05 / 改底座 06-10; 设计档案在 设计/)",
])
+25 -2
View File
@@ -459,10 +459,10 @@ def 测调用(事务连接: Any) -> None:
我的id = int([0]) if else 0
核对真("驱动那条 INSERT 写进去了 (真集成)", 我的id > 0, f"calls id={我的id}")
待领 = int(db.(事务连接, "SELECT count(*) AS 条数 FROM calls WHERE state = 'pending'", ())[0]["条数"])
领到的 = db.领调用(事务连接)
核对真("领调用 能领到驱动写的行", 我的id in [int(["id"]) for in 领到的], f"领到 {len(领到的)}")
核对真("领调用 备注是 pending 数 + 1", len(领到的) <= 待领, f"待领 {待领} 领到 {len(领到的)}")
# 这里不核"条数": 领调用 现在把 pending 与 waiting 一起领回来 (见下面回归组), 生产库里若正好有
# 待处理的行, 领到几条不由我们说了算 -- 只核对"我们那条一定在里面" (上面那条足够).
= db.读调用(事务连接, 我的id)
核对("领走后 state=waiting", ["state"] if else None, "waiting")
@@ -487,6 +487,29 @@ def 测调用(事务连接: Any) -> None:
db.写调用(事务连接, 我的id, {})
核对("写调用({}) 不报错", db.读调用(事务连接, 我的id) is not None, True)
# ── 回归 (2026-09-16 实测踩到的真缺陷, 修完必须有这条守着) ──
# 旧行为: waiting 的行既不会被重新领、又在 同锁在跑 里被当成"占着锁" ->
# 同一把锁上两条排队请求**互相排队**, 双双卡在 waiting, 谁也不跑 (实测: 两条都停在 waiting).
# 新行为: waiting 每轮被领回来重判; 只认 state='running' 算占锁 -> 锁一空就推进.
锁2 = f"{前缀}同锁"
两个id: list[int] = []
for _序 in range(2):
游标2 = 事务连接.cursor()
游标2.execute(
"INSERT INTO calls (caller, want, args, lock_key) VALUES (%s, %s, '{}'::jsonb, %s) RETURNING id",
(我的, 契约, 锁2),
)
行2 = 游标2.fetchone()
游标2.close()
两个id.append(int(行2[0]) if 行2 else 0)
领到的2 = [int(行2["id"]) for 行2 in db.领调用(事务连接)]
核对真("同锁两条都被领走 (变成 waiting)", all(i in 领到的2 for i in 两个id), f"ids={两个id} 领到={领到的2}")
核对真("waiting 不算'占着锁' (否则两条互相排队)", not db.同锁在跑(事务连接, 锁2, 两个id[0]), "排除自己后应为 False")
db.写调用(事务连接, 两个id[0], {"state": "running"})
核对真("第一条拿到锁后, 第二条才被挡住", db.同锁在跑(事务连接, 锁2, 两个id[1]), f"lock_key={锁2}")
领到的3 = [int(行2["id"]) for 行2 in db.领调用(事务连接)]
核对真("waiting 会被重新领 (锁一空就能推进)", 两个id[1] in 领到的3, f"领到={领到的3}")
db.写调用(事务连接, 我的id, {"state": "done", "result": {"产出": "自测的产出"}, "finished_at": 现在()})
= db.读调用(事务连接, 我的id)
核对("回填结果 state", ["state"] if else None, "done")
+56
View File
@@ -0,0 +1,56 @@
# 00 · 文档索引(先看这份)
> 这套东西是**底座**:引导器 → 内核 → 驱动 → 程序(Skill,v0.2 占位)。
> 后面所有开发都长在它上面,所以文档按"用的人"分两组:**使用**(把驱动写出来跑起来)和
> **开发**(改底座本身)。每份文档只解决一类问题,写错的地方以**代码**为准。
## 30 秒概念速查(先记住这 8 条)
| 概念 | 一句话 |
|---|---|
| 引导器 `UEFI.boot.py` | 内核的管家:体检环境 / 管内核进程的启停与记账;纯 stdlib,坏了也能报出为什么 |
| 内核 `内核/内核.py` | 常驻**总调度**:谁给谁、什么顺序、谁先谁后,全归它;纯 CLI + 日志,没有 TUI |
| 驱动 | **一个文件夹**:根目录有 `配置.efi.json` 才算;里面是源码 + venv 或可执行文件 |
| 契约 | 中立的能力名(如 `样板:心跳`)。驱动只声明"我产出什么 / 我要什么",**不写对方名字** |
| 内存 | PostgreSQL 库 `efi_kernel`8 张表)。`events` 表就是总线,**没有自造协议、没有 socket** |
| 两个真相 | PG 是活真相;驱动文件夹里的 `运行.efi.json` 是落盘快照(离线可读,不当状态源) |
| 三条日志道 | `内核.log`(结构化)/ `内核.out.log`(命令输出+崩溃原文)/ `引导器.log`;驱动日志各自一份 |
| 内核可以随时死 | 崩了不连累驱动(它们是独立会话);下次启动先**收尸**认领回来 |
## 文档地图
| # | 文件 | 解决什么问题 | 什么时候看 |
|---|---|---|---|
| 01 | `01-快速上手.md` | 从零把整套跑起来(体检 → 建库 → 起内核 → 起驱动 → 收工) | 第一次接触 / 换机器 / 重装 |
| 02 | `02-写一个驱动.md` | 亲手写一个驱动:文件夹怎么摆、配置怎么写、怎么要别人的东西、怎么汇报 | 要加能力的时候 |
| 03 | `03-命令手册.md` | 每条命令、每个选项、退出码、`--json` 输出长什么样 | 天天用(贴在旁边) |
| 04 | `04-契约与调用.md` | 驱动之间的"牵线"机制:一次调用的完整生命周期 + 六条仲裁 | 驱动要调别人、调用被拒 |
| 05 | `05-日志与排障.md` | 三条日志道怎么读;"出事了先查哪"的症状表 | 出事的时候 |
| 06 | `06-架构与不变量.md` | 分层、必须守住的不变量、数据流、为什么没有协议/TUI | 动手改底座前**必读** |
| 07 | `07-模块与接口.md` | 每个文件管什么、公开函数表、"想改 X 要动哪几处" | 改底座的时候 |
| 08 | `08-数据模型.md` | 8 张表逐字段(谁写/谁读/生命周期)+ 事件字典 + 状态机 + 快照字段 | 写 SQL / 加字段 / 排查数据 |
| 09 | `09-扩展指南.md` | 六个 recipe:加子命令 / 加配置字段 / 加表 / 加日志来源 / 加自测 / 改判定 | 扩展底座的时候 |
| 10 | `10-验收与质量门.md` | 五份自测 + pyright 严格档 + AST 等价 + 端到端;发布前 checklist + 坑总表 | 每次改完代码 |
**设计档案**(为什么这么设计、字段表原文)在 `设计/``01-驱动规范.md`(配置字段表 / 校验 9 条 / 状态机)、
`02-内核设计.md`DDL / 判定表 / CLI)、`03-引导器.md`(体检 6 项 / 台账)、`04-日志系统.md`(三条道 / 轮转 / 选项)。
老板自己写的原始需求在 `内核/内核设计.md`**只读,别改**)。
## 三条命令跑起来
```bash
cd ~/桌面/工作区/内核
python3 UEFI.boot.py --check # 1 体检 6 项(PG 不通只 WARN,不算失败)
python3 UEFI.boot.py 内核 启动 --守护 # 2 后台常驻总调度
./.venv/bin/python 内核/内核.py 启动 样板常驻 # 3 起一个驱动
./.venv/bin/python 内核/内核.py 日志 样板常驻 -n 20 # 看它说话
python3 UEFI.boot.py 内核 停止 ; ./.venv/bin/python 内核/内核.py 停止 样板常驻 # 收工
```
## 事实源优先级
```
代码 > 设计/*.md > 文档/*.md > 技能 / 记忆
```
文档跟代码打架 → 以代码为准,并**当场把文档改掉**(底座文档一旦漂移,后面每个开发都踩一遍)。
改完代码的同步清单见 `09-扩展指南.md` 末尾。
+198
View File
@@ -0,0 +1,198 @@
# 01 · 快速上手(从零到跑起来)
> 目标:一台装好 Python 3.11+ 和 PostgreSQL 的 Linux 上,10 分钟内让**引导器 + 内核 + 一个驱动**
> 真跑起来,并且每一步都能看见证据。所有输出都是 2026-09-16 在本机实测的原样。
## 0. 前置条件(本机已经满足的)
| 项 | 本机现状 | 怎么自查 |
|---|---|---|
| 项目根 | `~/桌面/工作区/内核/` | `ls` 应看到 `UEFI.boot.py` / `内核/` / `驱动/` / `环境.efi.json` |
| Python | 3.11.15`.venv` 里跑;3.14 那个只是系统备着) | `./.venv/bin/python -V` |
| uv | 有(建 venv / 装包用) | `uv --version` |
| PostgreSQL | 18,数据目录 `/home/lou/pgdata`socket `/home/lou/pgdata/socket`,用户 `lou` | `psql -h /home/lou/pgdata/socket -l` |
| 库 `efi_kernel` | 已建(8 张表由内核 `CREATE TABLE IF NOT EXISTS` 自动建) | `psql -h /home/lou/pgdata/socket -d efi_kernel -c '\dt'` |
> PG 连接信息**只写在** `环境.efi.json``db` 段(`name/host/port/user`)。`host` 填 **unix socket 目录**
> 不是主机名 —— 本机 PG 走 peer 认证,写 `localhost:5432` 反而要密码。
## 1. 体检(永远先跑这条)
```bash
cd ~/桌面/工作区/内核
python3 UEFI.boot.py --check
```
输出(全绿时):
```
体检通过: 解释器 3.11.15 | venv 健康 | 包 2/2 | 驱动目录 可写 | PG 通
```
六项的阻塞级别记牢,这决定了"出问题还能不能干活":
| # | 检查 | 过不了会怎样 |
|---|---|---|
| 1 | 解释器版本满足 `环境.efi.json``python` | **阻塞**(内核跑不起来) |
| 2 | `.venv` 存在 | **阻塞** |
| 3 | `.venv` 健康(`pyvenv.cfg` 与实际解释器一致、能 import) | **阻塞** |
| 4 | 包状态(只有**必需**包缺失才阻塞,可选包缺失写进详情) | **阻塞** |
| 5 | 驱动目录可读写 | **阻塞** |
| 6 | PG 能连 | **只 WARN** —— 引导器自己的命令(体检/环境/包)要能在 PG 挂了时照样报出原因 |
有阻塞项时它会把**整张表**打出来,每行带一句修复命令:
```
$ python3 UEFI.boot.py --check
序号 检查 结果 必需 详情 修复
──── ──────────── ──── ──── ──────────────────────────────────────────────── ──────────
1 解释器版本 过 是 用 /…/.venv/bin/python = 3.11.15 (要求 >=3.11)
```
**不自动修、不自动装、不自动升级** —— 引导器只报,动手要你显式敲(下面两条)。
## 2. 修环境(只在需要时)
```bash
python3 UEFI.boot.py 环境 重建 # 按 环境.efi.json 的 python 重建 .venv(旧 venv 改名 .venv.bak-<时间戳> 留退路)
python3 UEFI.boot.py 包 安装 # 装 环境.efi.json 里列的包(走国内源 pypi.tuna)
```
实测一次真实修复(2026-09-16 首次跑时踩到的三连):
```
体检当场抓出: venv 被搬过 (pyvenv.cfg 记 3.14.4 vs 实际 3.11.15) / psycopg2-binary 缺失 / 库 efi_kernel 不存在
→ 环境 重建 (uv venv --python 3.11) → 包 安装 (清华源 6.6s) → createdb efi_kernel → 6 项全绿
```
库不存在就一条命令建(引导器不替你建库,那是 DBA 的事):
```bash
createdb -h /home/lou/pgdata/socket efi_kernel
```
## 3. 起内核(总调度)
```bash
python3 UEFI.boot.py 内核 启动 --守护
```
输出:
```
[INFO] 内核 启动 --守护 pid=20406 命令=/home/lou/桌面/工作区/内核/.venv/bin/python /home/lou/桌面/工作区/内核/内核/内核.py
体检通过: 解释器 3.11.15 | venv 健康 | 包 2/2 | 驱动目录 可写 | PG 通
[守护] /home/lou/桌面/工作区/内核/.venv/bin/python /home/lou/桌面/工作区/内核/内核/内核.py
[守护] 日志: /home/lou/桌面/工作区/内核/内核/logs/内核.log
[守护] 已拉起 pid=20406 pgid=20406
```
看它活着没:
```bash
python3 UEFI.boot.py 内核 状态
```
```
内核: 运行中 (pid 20406) 入口: …/内核/内核.py
启动时刻: 2026-09-16T20:55:37+08:00 已跑: 00:03:12
今日运行: 3 次 台账: kernel_runs(最近一条 ok=true, mode=daemon
```
**同一时间只允许一个内核**`pg_try_advisory_lock` 会话级锁)。起第二个被秒拒:
```
2026-09-16T12:57:29+08:00 ERROR [内核] 已经有一个内核在跑了 (抢不到调度锁) -- 同一份 PG 内存只能有一个调度器, 别起第二个
```
## 4. 起一个驱动
```bash
./.venv/bin/python 内核/内核.py 扫描 # 先扫一遍:认文件夹 + 校验 + 写注册表
./.venv/bin/python 内核/内核.py 启动 样板常驻
```
```
样板常驻: 已拉起 pid=20444
```
看状态(直读 `/proc`,不信库里的旧 pid):
```bash
./.venv/bin/python 内核/内核.py 状态
```
```
状态 驱动名 形态 PID 运行时长 配置 说明
────── ────────── ────── ───── ──────── ──── ──────────────────────────────
已退出 Json解码 python — — 一致 把 web.json 解出来打印 -- 程序/Skill 层样本
停止 样例消费器 python — — 一致 要别人的东西时不认识对方, 只往 calls 表发契约请求
运行 样板常驻 python 20444 00:00:12 一致 常驻 + 打印心跳 + 往 events 表汇报
驱动 3 / 有效 3 / 运行 1 / 无效 0 扫描 #43 20:55:37
```
看它说话(驱动日志 = 内核重定向的 stdout/stderr,每轮启动前有一条分隔头):
```bash
./.venv/bin/python 内核/内核.py 日志 样板常驻 -n 6
```
```
样板常驻: 心跳
==== 启动 样板常驻 python3 /…/驱动/样板常驻/心跳.py 2026-09-16T20:55:47+08:00 ====
样板常驻: 起来了 pid= 20444 工作目录= /…/驱动/样板常驻
样板常驻: EFI_DB = 有
样板常驻: 已经在 LISTEN driver_样板常驻
```
## 5. 看总线(events 表)
驱动之间、驱动与内核之间没有协议,全靠这张表:
```bash
./.venv/bin/python 内核/内核.py 事件 -n 8
```
```
时间 级别 类型 来源 驱动 内容
─────────── ──── ───────── ──── ───────── ─────────────────────────────────────────
09-16 20:55 info scan 内核 — 扫描完成 drivers=3 valid=3 invalid=0 version=43
09-16 20:55 info start 内核 样板常驻 样板常驻 起来了 pid=20444 pgid=20444
09-16 20:55 warn exit 内核 样板常驻 样板常驻: running -> crashed (断电 / 被杀)
```
## 6. 收工(停干净,别留孤儿)
```bash
./.venv/bin/python 内核/内核.py 停止 样板常驻 # 先校验 cmdline 再 SIGTERM 进程组(连子树一起收)
python3 UEFI.boot.py 内核 停止 # 停内核(内核死了驱动照跑,所以顺序是"先驱动后内核")
ps -eo pid,stat,cmd | grep -E "内核/内核.py|心跳.py" | grep -v grep # 复查:应为空
```
> `内核 停止` 是**幂等**的(没在跑也回 0),并且会顺手给 `kernel_runs` 里没写 `finished_at`
> 的行收尾(断电留下的半截记录)。
## 7. 一键验收(改过代码后跑这个)
```bash
python3 试跑引导器.py # 11 项端到端,全跑真命令,退出码 0 = 没 FAIL
```
```
合计: PASS 11 | 预期 0 | FAIL 0
残留: 无
结论: 全过
```
## 第一次跑最可能踩的四个坑
| 症状 | 原因 | 处置 |
|---|---|---|
| `体检不过: venv 不健康` | `.venv` 被搬过目录(`pyvenv.cfg` 里的版本/路径对不上) | `python3 UEFI.boot.py 环境 重建` |
| `连不上 PG (efi_kernel@/home/lou/pgdata/socket)` | 库没建 / PG 没起 / socket 路径不对 | `createdb …`;查 `pg_isready -h /home/lou/pgdata/socket` |
| 内核起来又秒退,日志里写 `抢不到调度锁` | 已经有一个内核在跑 | `python3 UEFI.boot.py 内核 状态`,要重启用 `内核 重启` |
| 驱动 `无效``error``契约无人提供` | 它 `needs` 的契约没有任何驱动的 `provides` 对上 | 看 `状态` 表的说明,或 `设计/01` 第 5 节第 8 条 |
下一步:想加自己的驱动 → `02-写一个驱动.md`;日常命令 → `03-命令手册.md`;出事 → `05-日志与排障.md`
+185
View File
@@ -0,0 +1,185 @@
# 02 · 写一个驱动
> 驱动 = **一个文件夹**。内核认不认它只看一件事:根目录有没有 `配置.efi.json`
> 字段表原文在 `设计/01-驱动规范.md` 第 3 节(那份是规范,这份是**动手做**)。本机已有三个真样板:
> `驱动/Json解码`oneshot)、`驱动/样板常驻`resident + 提供服务)、`驱动/样例消费器`resident + 消费服务)。
## 1. 五分钟:一个最小驱动(oneshot)
```bash
cd ~/桌面/工作区/内核
mkdir -p 驱动/问好
```
`驱动/问好/配置.efi.json`
```json
{
"efi": 1,
"name": "问好",
"runtime": "python",
"mode": "oneshot",
"entry": "问好.py",
"interpreter": "system",
"note": "最小驱动: 打印一句话就退"
}
```
`驱动/问好/问好.py`
```python
#!/usr/bin/env python3
"""最小驱动: 打印一句就退 (oneshot)."""
import os
print("问好: 你好, 我是驱动", os.getpid(), flush=True)
print("问好: 内核给我的库连接串 EFI_DB =", "有" if os.environ.get("EFI_DB") else "没有", flush=True)
```
跑起来:
```bash
./.venv/bin/python 内核/内核.py 扫描 # 认文件夹 + 9 条校验 + 写注册表
./.venv/bin/python 内核/内核.py 启动 问好 # oneshot 跑完就退 = 正常
./.venv/bin/python 内核/内核.py 状态 问好
./.venv/bin/python 内核/内核.py 日志 问好 -n 20
```
`状态` 里会显示 **已退出**(oneshot 跑完就退是正常态,不是失败),`日志` 里是它那两行。
## 2. 先选形态(选错会白写)
| `mode` | 什么时候用 | 内核怎么对待它 |
|---|---|---|
| `oneshot` | 一次性任务:解码一个文件、跑一趟抓取、生成一份报告 | 起 → 等它自己退;退出码 0 = 正常;不会被"重拉" |
| `resident` | 长跑能力:提供契约、守着一个端口、定时干活 | 起 → 判定为常驻;崩了按 `restart` 策略决定要不要重拉 |
| `runtime` | 文件夹里放什么 | 内核怎么起 |
|---|---|---|
| `python` | `.py` +(可选)`.venv/` | `<解释器> <entry>``cwd=` 驱动根目录 |
| `exec` | 可执行文件(ELF / 脚本,看 **x 位** 不看后缀) | 直接 exec,内核不找解释器 |
`python` 的解释器解析顺序(`interpreter` 字段):显式路径 → `venv``.venv/bin/python`,不存在回落
`system` 并记 WARN)→ `system``python3`)→ 默认 `venv`。**本机样板都用 `system`**,因为样板不需要第三方包。
## 3. 驱动能碰什么、不能碰什么(硬规矩)
| 表 | 驱动能不能写 | 说明 |
|---|---|---|
| `events` | ✔ 随便写 | 汇报唯一通道(`source`/`driver` 写自己名字) |
| `calls` | ✔ 只能插自己的请求 + 回填**自己那条**的结果 | 要别人的东西走这里 |
| `drivers` / `driver_state` | ✘ | 内核的地盘(否则驱动能去停别人的进程) |
| `commands` | ✘ | 引导器的地盘 |
环境变量:内核对每个驱动注入 **`EFI_DB`**(libpq 关键字式连接串)。密钥类信息走 `配置.efi.json`
`env` 段或环境变量,**不要落盘**
```python
import os, psycopg2
连接 = psycopg2.connect(os.environ["EFI_DB"]) # 连接串由内核给, 驱动不用知道库在哪
连接.autocommit = True
```
> 驱动自己 venv 里没装 `psycopg2` 也能跑(只是不能汇报/发请求)—— 内核**不会**因此判它失败。
> 样板就是这么写的:拿不到 `EFI_DB` 就老老实实跑,不装成功。
## 4. 汇报:往 `events` 表插一行
没有协议、没有 socket、没有消息格式 —— 想说什么就插一行(内核只读表,**不解析你的 stdout**):
```python
def 汇报(连接, message: str, kind: str = "heartbeat") -> None:
"""kind 约定: start|stop|exit|log|produce|heartbeat|error; level: info|warn|error."""
try:
with 连接.cursor() as 游标:
游标.execute(
"INSERT INTO events (source, driver, level, kind, message, data)"
" VALUES (%s, %s, 'info', %s, %s, %s::jsonb)",
("我的驱动名", "我的驱动名", kind, message, json.dumps({"pid": os.getpid()}, ensure_ascii=False)),
)
except Exception as 错: # 汇报失败不该把驱动搞死
print("汇报失败:", 错, flush=True)
```
看别人汇报:`内核/内核.py 事件 -n 50`,或按驱动过滤(`db.读事件(连接, 条数, driver=名)`)。
**别刷太密** —— `events` 是总线,刷满了有用的事就看不见了(样板 30s 一条心跳)。
## 5. 要别人的东西:声明 `needs` + 往 `calls` 发请求
**你不需要、也不许知道谁提供它。** 两步:
**① 配置里声明契约名**(中立名,不是驱动名):
```json
{
"efi": 1, "name": "样例消费器", "runtime": "python", "mode": "resident",
"entry": "请求.py", "interpreter": "system",
"needs": ["样板:心跳"]
}
```
**② 代码里发请求 → 等结论 → 取结果**(完整可跑版见 `驱动/样例消费器/请求.py`):
```python
# 发起方
游标.execute(
"INSERT INTO calls (caller, want, args, lock_key)"
" VALUES (%s, %s, %s::jsonb, %s) RETURNING id",
("样例消费器", "样板:心跳", json.dumps({"chain": ["样例消费器"]}, ensure_ascii=False), "样板:心跳"),
)
调用id = 游标.fetchone()[0]
# 之后轮询: SELECT state, provider, result, error FROM calls WHERE id = %s
# state 走到 done / failed / denied / timeout 就是有结论了 (等不到要如实说等不到, 别无限等)
```
**被调的一方**`resident`)自己 `LISTEN driver_<我的名字>`,内核用 `pg_notify` 叫醒它:
```python
连接.execute("LISTEN driver_我的驱动名") # 通道名只跟"我是谁"有关, 跟谁在调我无关
# 收到通知 -> payload 是 calls.id -> 干活 -> UPDATE calls SET state='done', result=… WHERE id=…
```
`oneshot` 的提供方不用自己 LISTEN:内核仲裁时发现它没在跑,会**按需把它拉起来**跑一遍。
排障、六条仲裁(越权/成环/无人提供/按需拉起/同锁排队/超时收权)、锁与超时的细节 → `04-契约与调用.md`
## 6. 常驻驱动的三条纪律
1. **收到 `SIGTERM` 要干净退出**。装死不退,内核只能 `SIGKILL`(日志/状态都不好看):
```python
def 收工(号: int, 帧: object) -> None:
print("我的驱动: 收到信号", 号, "收工", flush=True)
sys.exit(0)
signal.signal(signal.SIGTERM, 收工)
signal.signal(signal.SIGINT, 收工)
```
2. **stdout 随便打,但要知道它去哪**:你的 stdout/stderr 被内核重定向进 `驱动/<名>/logs/<名>.log`
(每次启动前插一条 `==== 启动 … ====` 分隔头)。用 `内核/内核.py 日志 <名> -f` 实时跟。
3. **打点节流**:每 5s 打印一行是给人看的(老板 tail 得见它在活着),`events` 30s 一条就够。
## 7. 调试手法(按这个顺序查最快)
| 想确认 | 怎么做 |
|---|---|
| 文件夹被认了没 | `内核/内核.py 列表``无效` 的话看 `说明` 列 / `状态 <名>` 的"上次错误" |
| 内核怎么判它的 | `内核/内核.py 状态 问好 --json`(含 PID / 命令行 / 配置是否待重启 / 重启次数 / 契约) |
| 它说了什么 | `内核/内核.py 日志 <名> -n 50``-f` 实时跟) |
| 内核怎么记账的 | `内核/内核.py 事件 -n 50``内核/内核.py 日志 --内核` |
| 不起眼但最有用的一招 | 直接**手动跑一遍**:`python3 驱动/问好/问好.py`(驱动是普通程序,不需要内核就能跑) |
| 配置改完不生效 | 配置有 `config_hash` 指纹,改完要**重启**`内核/内核.py 重启 <名>``列表` 会提示"待重启" |
## 8. 交付一个驱动前的检查表
- [ ] 文件夹根目录有 `配置.efi.json`,且 `efi=1` / `name` 唯一 / `runtime` / `entry` 齐全
- [ ] `entry` 是**相对路径**,不出驱动根(不许 `..`
- [ ] `mode` 选对(跑完就退的用 `oneshot`,长跑用 `resident`
- [ ] `autostart` / `restart` 都是想要的(默认 `false` / `no` —— 手动控制口径)
- [ ] 要别人的东西写的是**契约名**(`needs`),代码里没出现别的驱动名
- [ ] 只写 `events` / `calls`;碰不到 `drivers` / `driver_state` / `commands`
- [ ] `SIGTERM` 干净退出(`resident` 必须)
- [ ] 汇报失败、连不上 PG 都有兜底(别把驱动搞死、别装成功)
- [ ] `内核/内核.py 扫描` → 启动 → 状态 → 日志 四步都看过一眼
- [ ] 敏感信息走 `env` / 环境变量,没写进 `配置.efi.json`
+129
View File
@@ -0,0 +1,129 @@
# 03 · 命令手册
> 两个入口:**引导器**`python3 UEFI.boot.py …`,管环境和内核进程)和 **内核**`./.venv/bin/python 内核/内核.py …`,管驱动)。
> 键盘上只需要记两条:`UEFI.boot.py <命令>``内核/内核.py <命令>` —— 引导器除了自己那 5 个词,其余**原样透传**内核。
## 0. 归属一眼看清
```
python3 UEFI.boot.py ← 引导器: 自检 | 环境 | 包 | 内核 | 日志
└─ 其余一律透传给内核 ─────► ./.venv/bin/python 内核/内核.py 列表 | 扫描 | 启动 | 停止 | 重启 | 状态 | 日志 | 事件 | 清单 | 调度
```
两条铁律:**内核对驱动的命令**(启动/停止/重启/状态/日志/事件/清单)在引导器那侧**不做体检门槛**
"看/收尾"必须能在环境坏掉时用);**引导器起停内核**(`内核 启动/重启`)会先例行体检,有阻塞项就拦住不让动。
## 1. 引导器自己的命令(5 个词)
| 命令 | 作用 | 退出码 |
|---|---|---|
| `UEFI.boot.py`(无参数) | 体检 → 移交内核的默认动作(`列表` | 内核的退出码 |
| `UEFI.boot.py -h` / `--help` / `帮助` | 打印用法 | 0 |
| `UEFI.boot.py 版本` | 打引导器 + 内核版本 | 0 |
| `UEFI.boot.py --check` / `自检` / `检查` | **只体检**,不启内核 | 0 全过 / 1 有阻塞项 |
| `UEFI.boot.py 环境 [--json]` | 体检 6 项,逐项给结论 + 修复命令 | 同上 |
| `UEFI.boot.py 环境 重建` | 按 `python` 要求重建 venv(旧 venv 改名 `.venv.bak-<时间戳>` 留退路) | 0 / 1 |
| `UEFI.boot.py 包 [--json]` | 包状态台账(读 `uv pip list`,失败回落 `importlib.metadata` | 0 / 1 |
| `UEFI.boot.py 包 安装 [名...]` | 装包(显式;走 `环境.efi.json` 的国内源) | 0 / 1 |
| `UEFI.boot.py 日志 [选项]` | **引导器自己的动作日志**`--内核` / `--输出` 转看内核那两份 | 0 / 1 参数错 |
```bash
python3 UEFI.boot.py --check
python3 UEFI.boot.py 环境 --json | jq .checks # 机器读: 6 项结论 + blocking_ok
python3 UEFI.boot.py 日志 -n 30 # 引导器动作 (体检/包/移交/内核启停)
python3 UEFI.boot.py 日志 --级别 WARN -g 内核 # 只看警告里跟内核有关的
```
**`环境 --json` 的输出**(机器读,别跟人读的表格混):
```json
{ "efi": 1, "ts": "2026-09-16T21:03:40+08:00", "引导器": "引导器 v0.1",
"blocking_ok": true, "ok": true,
"checks": [ { "序号": 1, "名称": "解释器版本", "结果": true, "必需": true, "详情": "…", "修复": "" }, … ] }
```
## 2. 内核进程管理(归引导器)
| 命令 | 作用 | 退出码 |
|---|---|---|
| `内核 状态` | 有没有在跑(pid + `/proc/<pid>/cmdline` 交叉校验)/ 上次运行 / 今日次数 / 未收尾台账 | 0 / 1 |
| `内核 启动 [参数...]` | **前台**跑一次(输出实时可见;默认动作 = 常驻调度,会一直挂着) | 内核的码 |
| `内核 启动 --守护 [参数...]` | **后台常驻**`--守护` 是引导器的开关,不转给内核) | 0 起得来 / 1 起不来 |
| `内核 停止` | 先校验 cmdline 再 `SIGTERM` 进程组,超时升 `SIGKILL`;顺手收尾 `kernel_runs` 半截记录 | 0(幂等)/ 1 有残留 |
| `内核 重启` | 停干净了才启(没停干净**不启**,一次只动一件事) | 0 / 停的码 |
| `内核 日志 [选项]` | 内核日志;默认看结构化 `内核.log``--输出` 看命令输出/崩溃原文 | 0 / 1 参数错 |
```bash
python3 UEFI.boot.py 内核 启动 --守护
python3 UEFI.boot.py 内核 状态
python3 UEFI.boot.py 内核 日志 --级别 WARN -n 50
python3 UEFI.boot.py 内核 日志 --输出 -n 100 # 命令输出 + 未捕获的崩溃原文
python3 UEFI.boot.py 内核 停止
```
## 3. 内核自己的命令(管驱动)
| 命令 | 作用 | 退出码 |
|---|---|---|
| `列表` | 驱动清单表(默认动作) | 0 |
| `扫描` | 只扫不启:认文件夹 → 9 条校验 → 写注册表 → 收尸 → 落快照 | 0 / 1 驱动根不存在 |
| `启动 <驱动名>` | 拉起一个驱动(幂等:在跑就直说);拉起前轮转它的日志 | 0 / 1 起不来 |
| `停止 <驱动名>` | 校验 cmdline → `SIGTERM` 进程组;**级联停下游**;超时升 `SIGKILL` | 0 / 1 |
| `重启 <驱动名>` | 停干净了才启 | 0 / 1 |
| `状态 [驱动名] [--json]` | 直读 `/proc` 的现场判定(**不信**库里的旧 pid);不带名字 = 全部 | 0 / 1 没这个驱动 |
| `日志 [驱动名] [选项]` | 不带名字 = **驱动日志台账**;带名字 = 那个驱动的日志;`--全部` = 所有驱动各一段 | 0 / 1 没这个驱动 / 1 参数错 |
| `事件 [-n 50]` | 全局事件流(`events` 表 = 总线,最近的在上面) | 0 |
| `清单` | 输出清单 JSON(注册表 + 状态 + 契约,重定向就是文件) | 0 |
| `调度`(= 无参数) | 常驻调度内核(前台阻塞;`--守护` 由引导器负责) | 0 |
```bash
./.venv/bin/python 内核/内核.py 列表
./.venv/bin/python 内核/内核.py 启动 样板常驻
./.venv/bin/python 内核/内核.py 状态 样板常驻 --json
./.venv/bin/python 内核/内核.py 日志 # 台账: 谁有日志 / 占用 / 行数 / 最后改动
./.venv/bin/python 内核/内核.py 日志 --全部 -n 20 # 所有驱动各一段
./.venv/bin/python 内核/内核.py 事件 -n 30
./.venv/bin/python 内核/内核.py 清单 > 清单.json
```
## 4. 日志选项(三处认同一套,解析只有一份)
`UEFI.boot.py 日志 …` / `UEFI.boot.py 内核 日志 …` / `内核/内核.py 日志 …` 都认这些开关:
| 开关 | 含义 |
|---|---|
| `-n 200` | 尾部行数(**含轮转的历史份**一起数) |
| `-f` | 实时跟(先吐 10 行,Ctrl+C 停;**一次只能跟一个来源**) |
| `--级别 DEBUG\|INFO\|WARN\|ERROR` | 只要这个级别及以上;驱动原始日志按字样**猜**级别(不是规范) |
| `-g 关键词` | 只要含它的行(不区分大小写) |
| `--json` | JSON Lines:结构化日志拆成 `{时刻,级别,来源,消息}`;裸行给 `来源:""` + 猜的级别 |
| `--全部` | 所有驱动各一段(`== 名字 ==` 分隔) |
| `--内核` / `--引导器` / `--输出` | 换来源:内核结构化 / 引导器动作 / 内核 stdio |
参数写错 → 打用法 + **退出码 1**(不猜、不静默用默认值)。
三条道分别是什么 → `05-日志与排障.md`
## 5. 退出码约定(脚本里能直接判)
| 码 | 含义 |
|---|---|
| 0 | 成了(`内核 停止` 在"本来就没跑"时也回 0,幂等) |
| 1 | 有事:体检有阻塞项 / 没这个驱动 / 起不来 / 参数写错 / 有残留进程 |
| 2 | **命令不认识**(内核会顺手打一遍 `用法` |
| 其它 | 透传命令一律**原样返回内核的退出码**(引导器不解释、不包一层) |
| -1 | 用在 `试跑引导器.py` 里表示"超时"(前台常驻内核挂住不返回 = 预期,不算错) |
## 6. 日常十条(贴在手边)
```bash
python3 UEFI.boot.py --check # 1 环境有事没事
python3 UEFI.boot.py 内核 启动 --守护 # 2 起总调度
./.venv/bin/python 内核/内核.py 列表 # 3 看驱动清单
./.venv/bin/python 内核/内核.py 状态 # 4 看现场状态(直读 /proc)
./.venv/bin/python 内核/内核.py 启动 <名> # 5 起一个
./.venv/bin/python 内核/内核.py 日志 <名> -f # 6 实时跟它的输出
./.venv/bin/python 内核/内核.py 事件 -n 30 # 7 看总线最近发生了什么
./.venv/bin/python 内核/内核.py 重启 <名> # 8 改完配置/代码后
./.venv/bin/python 内核/内核.py 停止 <名> # 9 停一个(级联停下游)
python3 UEFI.boot.py 内核 停止 # 10 停内核(先驱动后内核)
```
+143
View File
@@ -0,0 +1,143 @@
# 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` 后自动推进(**实测**:两条同锁请求 `18` `19`
串行跑完,`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`
```python
# ① 插一行 (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`
```python
连接.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 库。跨库需求 = 换架构,不在本版。
+137
View File
@@ -0,0 +1,137 @@
# 05 · 日志与排障
> 出事先看**四条**`日志 --内核`(内核怎么想的)、`内核.out.log`(命令输出与崩溃原文)、
> `日志 <驱动>`(驱动说了什么)、`事件 -n 50`(谁在什么时候干了什么)。这份文档先讲三条道怎么读,
> 再给一张"症状 → 判据 → 处置"的总表。
## 1. 三条道(一个文件只装一种内容)
| 文件 | 里面是什么 | 从哪看 |
|---|---|---|
| `内核/logs/内核.log` | **只有**结构化日志行(内核自己写) | `内核/内核.py 日志 --内核` / `UEFI.boot.py 内核 日志` |
| `内核/logs/内核.out.log` | 内核进程的 stdout/stderr 原始流:**命令输出**(表格/JSON)+ 未捕获的**崩溃原文** | `内核 日志 --输出` |
| `内核/logs/引导器.log` | 引导器的动作:开始 / 收工 / 移交 / 内核启停 / 所有 WARN | `UEFI.boot.py 日志` |
| `驱动/<名>/logs/<名>.log` | 驱动的 stdout/stderr 原样(内核**不解析**,每轮启动前插一条分隔头) | `内核/内核.py 日志 <名>` |
行格式(内核/引导器那份):
```
2026-09-16T21:08:35+08:00 INFO [内核] 常驻调度启动 pid=26456
└── 时刻(带时区) └级别 └来源 └内容(不截断)
```
**为什么分三条**:以前命令输出跟日志行挤在一个文件里(引导器把内核 stdout 一起重定向进去了),
想按级别筛一条都做不到。现在日志行归内核自己写、stdio 归 `.out.log`,两边都干净。
档案(修前的脏文件)在 `归档/20260916-日志系统重做前/`
## 2. 级别、门槛、轮转
| 项 | 配置键(`环境.efi.json` | 默认 | 行为 |
|---|---|---|---|
| 门槛 | `log_level` | `INFO` | 低于它的**不写**`DEBUG` 不落盘);空串 = 全写 |
| 单份上限 | `log_max_mb` | `5` | 超过就轮转成 `.1``0` = 不轮转 |
| 保留份数 | `log_keep` | `3` | 留几份历史(`.1`/`.2`/`.3`);`0` = 不轮转 |
| 默认行数 | `log_lines` | `200` | `日志` 命令不写 `-n` 时给几行 |
- **轮转只在拉起进程之前做**(运行中改名会让进程继续写老 inode = 日志丢)。内核拉驱动前、
引导器拉内核前各轮转一次;引导器每次跑也轮转自己的。
- 读的时候**把历史份一起算**`-n 200` 拿到的是"跨轮转的最近 200 行"。
- 驱动日志是**别人的原始输出**`--级别` 只能靠字样**猜**(含 `ERROR/Traceback/失败` → ERROR
`WARN/警告/超时/retry` → WARN,其余 INFO)。这一点必须记住:**猜的,不是规范**。
```bash
./.venv/bin/python 内核/内核.py 日志 --内核 --级别 WARN -n 50 # 内核只说警告以上
./.venv/bin/python 内核/内核.py 日志 样板常驻 -g 心跳 -n 20 # 只看含"心跳"的行
./.venv/bin/python 内核/内核.py 日志 --全部 -n 20 # 所有驱动各一段
./.venv/bin/python 内核/内核.py 日志 --json | jq -r '.["消息"]' # 机器读(JSON Lines)
python3 UEFI.boot.py 日志 --级别 WARN -n 30 # 引导器自己的动作
```
## 3. 现场状态从哪看(判活一律回 `/proc`
```bash
./.venv/bin/python 内核/内核.py 状态 # 全部驱动: 状态/PID/运行时长/配置一致性
./.venv/bin/python 内核/内核.py 状态 样板常驻 --json # 单驱动详情: 命令行/内存/CPU/重启次数/契约
python3 UEFI.boot.py 内核 状态 # 内核自己: pid/启动时刻/今日次数/未收尾台账
```
**库里记的 pid 只是账**:真正判活用 `/proc/<pid>/cmdline` 校验(pid 会被系统复用 —— 宁可不杀,
不可误杀)。`状态` 里若写"现判 X 而库里记的是 Y",**以现场为准**。
## 4. 症状 → 判据 → 处置(总表)
### 环境 / 引导器
| 症状 | 判据 | 处置 |
|---|---|---|
| `体检不过: venv 不健康` | `环境 --json` 的 3 项详情(`pyvenv.cfg` 里的版本/路径与实际不符) | `python3 UEFI.boot.py 环境 重建` |
| `包缺失` | 第 4 项列出缺哪个(必需项缺才阻塞) | `python3 UEFI.boot.py 包 安装` |
| PG 那项 WARN | 第 6 项 `pg_ok=false`(引导器自己的命令**不因此失败**) | 查 PG 起没起、socket 路径对不对(`环境.efi.json``db.host` |
| 内核命令报"连不上 PG" | 内核**不降级**(它的内存就是 PG) | 同上;PG 恢复后 `内核 重启` |
### 内核进程
| 症状 | 判据 | 处置 |
|---|---|---|
| `启动` 后 0.6s 就退出 | `[守护] 启动后 0.6s 就退出` —— 内核入口是空文件 / 缺包 | `内核 日志 --级别 ERROR`;跑 `环境 重建` + `包 安装` |
| ERROR `已经有一个内核在跑了 (抢不到调度锁)` | PG 会话级咨询锁(`0x65666901`)被占 | `内核 状态` 看是谁;要重来用 `内核 重启` |
| 内核反复重连 PG | 日志里 `重连 PG 失败 (第 N/10 次)` | 查 PG;连续 10 次它会退出,让引导器看门狗发现 |
| 停止时报"还有 N 个没停掉" | `/proc` 复查不为空 | 逐个看 `状态``试跑引导器.py` 的残留检查也能抓 |
### 驱动
| 症状 | 判据 | 处置 |
|---|---|---|
| `无效 (invalid)`,说明写 `协议版本不支持` | `配置.efi.json``efi` 不是内核支持的版本 | 改成 `1` |
| `无效``runtime 只能是 python 或 exec` | 字段拼错或值不对 | 改配置 |
| `无效``入口路径越界` / `入口文件不存在` | `entry` 用了 `..`、绝对路径、或文件真不在 | 改成驱动根内的相对路径 |
| `无效``驱动名重复` | 两个文件夹声明了同一个 `name` | 改 `name`(先到的先生效) |
| `无效``契约无人提供` | 它的 `needs` 没有任何驱动的 `provides` 对上 | 补提供方,或去掉这个 `needs` |
| `无效``依赖成环 (契约链: A -> B -> A)` | 契约依赖成环 | 拆环(扫描期就能看到) |
| `失败 (failed)``last_error` 里有"日志末尾" | 拉起后秒退;**尾部只算本次启动的输出** | `日志 <名> -n 50` 看它临死说了什么;常驻驱动建议手动 `python3 <入口>` 跑一遍 |
| `崩了 (crashed)` | 旧状态说在跑但 `/proc/<pid>` 没了(断电/被杀) | 看 `restart` / `autostart`;想让它自己回来就配 `restart=on-failure` |
| `挂起 (zombie)` | 进程已死、父进程没回收 | 内核会自己回收;`状态` 如实报 `崩了` |
| 列表里显示 `待重启` | `boot_hash != config_hash`(配置改过,进程还用旧配置跑) | `内核/内核.py 重启 <名>` |
| 改了配置/代码没生效 | 同上(配置有指纹) | 重启;改代码同理 |
### 调用 / 锁
| 症状 | 判据 | 处置 |
|---|---|---|
| 请求一直 `pending` | 常驻内核不在跑 | `python3 UEFI.boot.py 内核 启动 --守护` |
| `denied` | `calls.error` 写明哪一条(越权/成环/没人提供/起不来) | 见 `04-契约与调用.md` 第 7 节 |
| 卡 `waiting` | 同一 `lock_key``running` | 正常排队;等对方 `done` 会自动推进;超时由内核收权(默认 60s) |
| `timeout` | 过了 `deadline` | 查提供方为什么慢;`waiting` 也可能超时(排队没等到头) |
| 事件里 `依赖失效: 上游不在跑 …` | 上游状态是 `崩了`/`失败`(**只是没启动不算**) | 起上游,或查上游为什么崩 |
### 日志本身
| 症状 | 判据 | 处置 |
|---|---|---|
| `内核 日志` 是空的 | 你看的是结构化日志,内核刚起还没说什么 | 试 `内核 日志 --输出`(命令输出/崩溃原文在那份里) |
| 日志文件几十 MB | 轮转没生效(`log_max_mb` 被设成 0?) | 查 `环境.efi.json`;下一轮启动会自动轮转 |
| 驱动日志里几轮输出连成一片 | 那是**上次启动**的输出(分隔头之前) | 用 `==== 启动 … ====` 分隔头定位这一轮 |
| 事件表被刷满 | 某驱动汇报太密 | 让它的上报间隔变大(样板是 30s 一条) |
## 5. 断电之后(本机 22:30 断电,这是必做项)
来电后按顺序:
```bash
python3 UEFI.boot.py --check # 1 环境 (venv/包/PG 都可能被动过)
python3 UEFI.boot.py 内核 启动 --守护 # 2 内核一启动就先"收尸"
./.venv/bin/python 内核/内核.py 状态 # 3 看谁被认领 / 谁判"崩了"
./.venv/bin/python 内核/内核.py 事件 -n 20 # 4 时间线: 最后一条 heartbeat 就是死的那一刻
```
收尸语义(设计 02 第 5 节):
| 库里的状态 | `/proc` 实际 | 内核判成 | 动作 |
|---|---|---|---|
| `running` 有 pid | 在且 cmdline 匹配 | `running` | **认领**(不重起),驱动不受内核生死影响 |
| `running` 有 pid | 没了 | `crashed` | 记 `exit` 事件;`autostart=true` 才拉起 |
| `running` 有 pid | 在但 cmdline 不匹配 | `exited` | 清 pid**不杀**pid 被复用) |
| `stopped` / 无记录 | — | `stopped` | 无 |
| 其它状态 | 在且匹配 | `running` | "认领回来"(有人绕过内核对它做了什么) |
内核自己死了**不连累驱动**(驱动是独立会话,`start_new_session`)—— "内核可以随时死"是设计而不是缺陷。
+128
View File
@@ -0,0 +1,128 @@
# 06 · 架构与不变量(动手改底座前先读)
> 这份文档是给"要改这套底座的人(含以后的我)"看的:**分层**、**必须守住的不变量**、**数据流**、
> 以及几个"为什么故意不做"。改动如果违反下面任何一条不变量,就不是重构,是换架构 —— 要先跟老板对齐。
## 1. 分层(一层管一层,谁都不越级)
```
┌──────────────────────────────────────────┐
人 / 脚本 ───► │ 引导器 UEFI.boot.py (内核的管家) │
│ 体检 6 项 / venv 重建 / 包台账 / 记账 │
│ 管内核进程的: 启动 停止 重启 状态 日志 │
└───────────────────┬──────────────────────┘
│ 起/停/判活(走 进程.py)
┌───────────────────▼──────────────────────┐
│ 内核 内核/内核.py (常驻总调度, 甲) │
│ 命令消费 / 调用仲裁 / 依赖巡检 / 心跳 │
│ 扫描 + 校验 + 契约匹配 + 拓扑排序 │
└───────────────────┬──────────────────────┘
│ 启停/判活(同一个 进程.py)
┌───────────────────▼──────────────────────┐
│ 驱动 驱动/<名>/ (确定性代码能力接入层) │
│ 有 配置.efi.json 才算; 源码+venv 或 exec │
└───────────────────┬──────────────────────┘
│ 只经 PG 表(events 总线 / calls 调用)
┌───────────────────▼──────────────────────┐
│ 程序 / Skill (声明式, 模型驱动, v0.2) │ ← 本版不实现
└──────────────────────────────────────────┘
共享内存(唯一的"内核内存"):PostgreSQL 库 efi_kernel8 张表,见 08
```
职责边界(越界就是架构腐化):
| 层 | 只做 | 绝不做 |
|---|---|---|
| 引导器 | 环境体检 / 内核进程的生命周期 / 记账 | 不实现内核的子命令(只透传)、不改驱动配置、不自动修环境 |
| 内核 | 驱动注册与校验、契约匹配、启停与收尸、调用仲裁、巡检 | 不碰驱动内部(不解析 stdout)、不替引导器体检环境 |
| 驱动 | 声明契约、干活、往 PG 汇报 | 不 import 别的驱动、不写 `drivers`/`driver_state`/`commands`、不互相调用 |
## 2. 硬不变量(破了架构就塌)
| # | 不变量 | 为什么 | 在哪守 |
|---|---|---|---|
| 1 | **内存只有 PG 一处**:不引入文件状态源、socket、消息队列、共享内存 | 两处真相 = 迟早不一致;"不用通信协议,直接用 pgsql 当内存"是老板定调 | 全项目(`db.py` 是唯一碰 SQL 的文件) |
| 2 | **判活一律回 `/proc` 复核**,库里 pid 只当账 | pid 会被系统复用,拿旧 pid 发信号会杀到别人 | `进程.判活` + `状态.复核`(每次动作/渲染前都调) |
| 3 | **发信号前必须校验 cmdline**;宁可不杀,不可误杀 | 老板的 GUI 程序被误杀过一次(点名批评) | `进程.停止(入口=…)` 一律传入口 |
| 4 | **一个文件只装一种内容** | 混装过:命令输出混进日志文件,按级别筛不了 | 三条日志道 / 快照 / 配置 分家 |
| 5 | **一份实现不复制** | 两套实现 = 各有一套坑,还各自以为对方对 | `进程.py``日志.py``文本.py``状态.py` 一份 |
| 6 | **驱动之间零耦合** | 耦合了就打架,而且"认识对方"会让替换/复用全断 | 只有 `provides`/`needs`;寻址在 `calls` 表里由内核做 |
| 7 | **驱动只许写 `events` / `calls`** | 否则驱动能去停别人的进程,照样打架 | 约定 + 内核校验(角色级硬隔离留 v0.2) |
| 8 | **隔离失败**:一个驱动坏只标它自己 | 一个坏配置不能让整台机器停摆 | `校验单个` 就地失败、`invalid` + 原因 |
| 9 | **不吞错、不静默降级** | 出错必须留痕(`last_error` / `events` / 日志三处) | 内核对 PG 连不上**直接退出**(不降级到文件);引导器自己的命令才允许 WARN + 快照 |
| 10 | **状态取值固定 7 个**`stopped/starting/running/exited/crashed/failed/invalid`) | 状态是各方共用的词汇表,加一个要想清楚全链路 | `状态.py` 常量 + 中文映射 |
| 11 | **一次只动一件事**:启动与停止分两条命令,没停干净不启 | 反复起/杀会攒孤儿互抢(踩过 13 份隧道孤儿) | `内核 重启` 先停干净、`试跑引导器.py` 每步复查 |
| 12 | **日志轮转只在拉起进程之前做** | 运行中改名 → 进程继续写老 inode = 日志丢 | `内核.拉起一个` / 引导器 `内核启动` |
| 13 | **常驻内核独一份**PG 咨询锁 `0x65666901`,会话级) | 两个调度器 = 打架;会话级锁进程一死自动放,不留死锁 | `db.试锁` |
| 14 | **契约匹配的确定性**:同契约多提供方按名字取先、启动顺序按名字排 | 结果要可复现,不能靠文件系统顺序碰运气 | `扫描.取契约` / `扫描.排顺序` |
## 3. 操作纪律(破了不塌,但会难查)
| 纪律 | 说明 |
|---|---|
| 配置外置一份 | `环境.efi.json` 是项目级唯一配置(引导器写、内核只读);内核**不再自持**配置文件 |
| 配置外置:驱动字段 | 驱动自己的配置只有 `配置.efi.json` 一份,内核只读 |
| 界面纯中文 | 库里存英文(值稳定),给人看的一律过 `显示状态()` 转中文 |
| CLI 可重定向 | 输出要能 `> 文件``--json` 只给机器读(人读的表格不混进去) |
| 改完代码必跑 | `uvx pyright` + 五份自测 + `试跑引导器.py`(细节见 `10-验收与质量门.md` |
| 注释写"为什么" | 代码里的 `【…】` 段落是决策记录;改逻辑时把理由一起改,别只改代码 |
## 4. 主流程(谁在什么时候碰哪张表)
| 流程 | 步骤 | 涉及 |
|---|---|---|
| **体检移交** | `UEFI.boot.py <命令>` → 体检 6 项 → 写 `环境状态.efi.json` + `kernel_env` → 透传内核(前台 `跑并转发` / 后台 `进程.启动` 记账 `kernel_runs` | `kernel_env``kernel_runs` |
| **扫描** | 扫目录 → 9 条校验 → 收 `provides` → 查 `needs` → 查环 → 拓扑排序 → upsert `drivers` → 清幽灵 → 收尸(`状态.复核`)→ 写 `driver_state` → 落快照 → 记 `scans` + `events(scan)` | `drivers``driver_state``scans``events` |
| **启动** | 填 `commands`(CLI) 或直接调 → 拼命令(解释器/args/env/`EFI_DB`)→ 轮转日志 → `进程.启动`(独立会话、分隔头、探活)→ 写 `driver_state(running)` + `events(start)` → 失败则 `failed` + 日志尾巴 | `driver_state``events` |
| **停止** | 校验 cmdline → `SIGTERM` 进程组 → 等 `stop_timeout` → 升 `SIGKILL` → 收子树 → 写 `stopped` + `events(stop)`**级联停下游** | 同上 |
| **调用** | 驱动 INSERT `calls(pending)` → 内核领 → 六条仲裁 → 转发(`running` + `pg_notify driver_<提供方>`) → 提供方干活回填 `done/failed`;超时内核收权 `timeout` | `calls``events` |
| **巡检**(每 10s) | 收尸(复核 + 记 `exit`)→ 按 `autostart`/`restart` 重拉 → 级联标"依赖失效" → 收权超时 | `driver_state``calls``events` |
| **心跳**(每 300s | 写一条 `heartbeat`(断电时间线的最后一格) | `events` |
## 5. 双真相(最容易搞混的地方)
| | 活真相 | 落盘快照 |
|---|---|---|
| 位置 | PG `driver_state` / `drivers` | `驱动/<名>/运行.efi.json` |
| 谁写 | 内核 | 内核(每次扫描/启停/收尸整份重写,原子替换) |
| 用途 | **一切判定与决策** | 文件夹自包含:拷走驱动目录、PG 不在时也能看上次状态 |
| 不要做 | — | **不要**把快照当状态源去判活;驱动作者手改它没意义(下次刷新覆盖) |
同理配置:`环境.efi.json`(项目级,引导器写/内核读)与 `驱动/<名>/配置.efi.json`(驱动作者写/内核读)
是**两份不同层级的配置**,不要合并 —— 合并后"谁的配置"就说不清了。
## 6. 常驻调度(甲)在干什么,为什么必须常驻
四件事,都是"只有在运行时看着才知道"的:
1. **命令消费**CLI 客户端把命令写进 `commands``state=pending`),常驻内核 `LISTEN/NOTIFY` 领走执行回填;
CLI 自己执行过的命令落 `state=running`,所以常驻内核**不会重复领**。
2. **调用仲裁**`calls` 表的六条(越权/成环/无人提供/按需拉起/同锁排队/超时收权)。
3. **依赖巡检**(每 10s):崩了按策略重拉、上游崩了给下游标"依赖失效"(**上游只是没启动不算**)。
4. **心跳**(每 300s):断电时间线。
退出时**不顺便停驱动**("内核可以随时死"),下次启动先收尸认领。
## 7. 为什么故意不做
| 没做 | 理由(老板定调 / 工程判断) |
|---|---|
| TUI / 图形界面 | "内核要什么界面,肯定是纯 cli 啊和日志" —— 给脚本/自动化用的东西,输出要能 `>` 重定向 |
| 自造通信协议(socket / 消息格式) | "不用什么通信协议,直接用 pgsql 当内存"`events` 表就是总线,驱动直插 |
| supervisor / systemd / RPC 框架 | 内存层就是 PG;多一层框架 = 多一处真相与依赖(引导器本身是纯 stdlib,坏了还能报错) |
| 自动修环境 / 自动装包 / 自动重建 venv | "手动控制"口径:只有 `环境 重建``包 安装` 两个显式命令才动手 |
| 开机自启 | 网络/系统服务手动控制是老板偏好;爬虫管线那种长跑任务是例外 |
| 驱动自动重试 `failed` | 配置错了反复撞墙只会把日志刷满;`restart=on-failure` 只针对**崩了**的 |
| 日志落库 | 结构化汇报走 `events`;日志文件管"翻旧账 + tail";两份真相没意义(细节 `设计/04` |
## 8. 已知薄弱点(诚实清单,不是 bug 但要知道)
| 项 | 现状 | 什么时候要处理 |
|---|---|---|
| 驱动权限隔离 | **约定 + 内核校验**,不是 PG 角色级硬隔离:驱动拿到的连接串理论上能改 `drivers` | v0.2(多用户/不可信驱动时) |
| 调用没有优先级/公平性 | 锁上按 `id` 先到先得,无权重、无抢占 | 出现"重要任务被长任务堵住"时 |
| 调用结果没有大小限制 | `result` 是 jsonb;大对象该走"提供方写自己的表 + result 只放引用" | 出现大结果时 |
| 依赖巡检 10s 粒度 | 崩了最多 10s 才被发现/重拉 | 需要更快时(但会更吵) |
| 单 PG 单库 | 没有分片/跨库 | 不在本版范围 |
| 程序/Skill 层未加载 | `web.json` 是雏形样本,由 `Json解码` 这类驱动解释执行 | v0.2 |
+160
View File
@@ -0,0 +1,160 @@
# 07 · 模块与接口(改哪一层、动哪几处)
> 一套职责一个文件,不合并;**一份实现不复制**(`进程/日志/文本/状态` 都是唯一实现,内核与引导器共用)。
> 这份文档给"要改代码的人":每个文件管什么、对外接口是什么、改一件事要连带改哪些地方。
> 函数级细节看源码里的 docstring(每个函数都写了"为什么")。
## 1. 依赖方向(只能自上往下,不许反向)
```
UEFI.boot.py ──┬─► 进程.py 通用进程库: 启停 / 判活 / 收子树 / 日志读取
├─► 日志.py 日志系统: 写 / 解析 / 过滤 / 轮转 / 实时跟
├─► 文本.py CJK 宽度感知的对齐 / 表格 / 横线
└─► db.py 唯一碰 SQL 的文件 (只建引导器那两张表, 写 kernel_env/kernel_runs)
内核/内核.py ──┬─► 扫描.py ──► 状态.py ──► 进程.py
├─► 状态.py
├─► db.py 唯一碰 SQL 的文件 (建全部 8 张表)
├─► 日志.py / 文本.py / 进程.py
└─► 汇总: 命令分发 / 单驱动动作 / 常驻调度
驱动/<名>/*.py ──► psycopg2 直连 PG (只写 events / calls; 不 import 项目里任何模块)
```
规矩:`db.py` 是**唯一**写 SQL 的地方(别处一律不出现 SQL 字符串);`进程.py` / `日志.py` / `文本.py`
是**唯一**实现(哪一层要用都 import 它,不许抄一遍)。
## 2. 各模块职责与接口
### `UEFI.boot.py` — 引导器(纯 stdlib
| 组 | 关键接口 | 说明 |
|---|---|---|
| 配置 | `读环境() -> (配置, 警告)` | 缺文件按默认模板生成;解析失败**不覆盖**用户文件,用默认值继续 + WARN |
| 体检 | `体检(配置) -> 体检报告` / `打印体检` / `报告快照` / `例行体检(配置, 详细)` | 6 项(1-5 阻塞、6 PG 只 WARN);`例行体检` = 体检 + 写快照 + 记台账 + 按需打印 |
| 包 | `读包要求` / `读实装包` / `核包` / `解析版本` / `满足要求` | 自己撸的版本比较(不引 packaging);`uv pip list` 失败回落 `importlib.metadata` |
| venv | `venv路径` / `检查venv健康` / `环境重建` | 重建 = 旧 venv 改名留退路(`.venv.bak-<时间戳>`)→ `uv venv` / `python -m venv` → 复检 |
| 内核进程 | `内核状态` / `内核启动` / `内核停止` / `内核重启` / `内核日志` | 全部走 `进程.py``--守护``进程.启动`stdout/stderr → `内核.out.log``EFI_LOG_CONSOLE=0` |
| 记账 | `PG连接` / `PG记体检` / `写快照` | 引导器自己的命令 PG 不通**只 WARN**(降级写 `环境状态.efi.json` |
| 日志 | `记日志(级别, 消息, 安静=False)` / `起日志(配置)` / `打日志(路径, 选, 称呼)` | 引导器动作落 `引导器.log`;每次跑先轮转一次自己的 |
| 分发 | `main(argv)``分发(argv, 配置)` | 顶层 5 词:`自检/环境/包/内核/日志`;其余**原样透传**内核 |
**不做**:不实现内核的子命令、不改驱动配置、不自动修环境/装包/重建。
### `内核/内核.py` — 常驻总调度 + 单驱动动作
| 组 | 关键接口 | 说明 |
|---|---|---|
| 日志/配置 | `说(级别, 消息)` / `用环境(环境)` / `读环境` / `驱动根` / `日志行数·日志上限·日志保留` / `轮转日志` | `说()` 是薄包装(落 `内核.log`;是否打 stderr 看 `EFI_LOG_CONSOLE` |
| 连库 | `连库` / `重连` | 连不上**直接退出**(不降级);`重连` 失败 10 次退出,让看门狗发现 |
| 拼命令 | `拼命令(驱动, 环境) -> (argv, env, 警告表)` | 解释器解析 + `args`/`env`/`EFI_DB` 注入;`cwd` = 驱动根 |
| 单驱动动作 | `拉起一个` / `停一个` / `收僵尸` / `下游名` / `停序` | `拉起一个` 是启动/重启/按需/自动重拉**唯一实现**;`停一个` 级联停下游 |
| 调用仲裁 | `取链` / `校验调用` / `期限文本` / `转发调用` / `收权超时` | 六条仲裁;转发 = 写 `running` + `pg_notify driver_<提供方>` |
| 巡检 | `巡检(连接, 环境)` | 收尸 → 重拉 → 级联标"依赖失效" → 收权超时 |
| CLI | `执行命令(连接, 环境, 命令, 参数)` + `命令列表/扫描/启动/停止/重启/状态/日志/事件/清单` | CLI 与常驻内核**共用这一份**实现(不复制) |
| 日志命令 | `命令日志` / `打印某个日志` / `打印全部驱动日志` / `打印日志台账` / `用法日志` | 四形态:台账 / 单驱动 / `--全部` / `--内核·--引导器·--输出` |
| 常驻 | `命令调度(连接, 环境)` | 独一份锁 → 收尸 → 扫描 → autostart → LISTEN → 主循环(命令/调用/巡检/心跳/定期重扫) |
**不做**:不解析驱动 stdout(只重定向)、不替引导器体检环境。
### `内核/扫描.py` — 认文件夹 + 校验 + 契约
| 接口 | 说明 |
|---|---|
| `扫目录(驱动根)` | 只认根目录有 `配置.efi.json` 的文件夹(按名字排序,结果稳定) |
| `读配置(目录)` | json 读不了给 `(空, 原因)` |
| `校验单个(目录) -> 注册表行` | 第 2-6 条校验(JSON/版本/runtime/entry 路径安全/entry 存在+x 位);**不过也返回**(带 `valid=False` + `error` |
| `查重名(记录表)` | 第 7 条(后到的 invalid |
| `取契约` / `定契约` | 第 8 条(收 provides / 查 needs / DFS 找环),最多 3 轮 |
| `排顺序(记录表, 契约)` | 拓扑排序(提供方在消费者前,无依赖按名字) |
| `扫描(连接, 驱动根, 内核版本) -> 扫描结果` | 全流程 + 收尸 + 落快照 + 记 `scans`/`events(scan)` |
| `扫描结果`dataclass | `驱动/状态表/顺序/契约/问题/该拉起/清单版本/总数/有效/无效/在跑` |
**不做**:不碰 SQL(走 `db.py`)、不起进程(那是内核的单驱动动作)。
### `内核/状态.py` — 状态机 + 快照 + 收尸判定
| 接口 | 说明 |
|---|---|
| 常量 | `配置名/快照名/日志目录名/快照版本`7 个状态常量 + `中文表` + `显示状态()` |
| 路径 | `驱动根/入口路径/日志目录/日志路径`(从注册表行算,**唯一**路径来源) |
| 快照 | `读快照` / `写快照`(原子:`.tmp` + `os.replace`/ `组装快照` / `待重启` |
| 判定 | `复核(驱动, 状态行) -> (状态, 原因)`(判活回 `/proc`/ `该拉起(驱动, 状态行, 判定) -> 理由` |
**不做**:不碰 SQL(快照是文件、判定是纯逻辑)。
### `内核/db.py` — 唯一碰 SQL 的文件(43 个函数 / 8 张表)
| 组 | 关键接口 |
|---|---|
| 连接 | `数据库`(dataclass) / `从配置` / `连`(autocommit) / `建表` / `建引导器表` / `库存在` / `查`(公开转手,给自测/诊断) |
| 注册表 | `记驱动`(upsert) / `清不在` / `取驱动` / `取全部驱动` |
| 状态 | `确保状态行` / `写状态`(**白名单动态 UPDATE**,只改传进来的列) / `取状态` / `取全部状态` |
| 事件 | `写事件(连接, source, kind, message, driver=None, level="info", data=None)` / `读事件(条数, driver=None)` |
| 扫描 | `记扫描批次` / `取最近扫描` |
| 命令 | `记命令` / `领命令`(pending→running`FOR UPDATE SKIP LOCKED`) / `记命令结果` / `读命令` / `收尸命令` |
| 调用 | `领调用`(pending/waiting→waiting) / `写调用` / `读调用` / `取调用(state)` / `同锁在跑`(只认 running) |
| 通知/锁 | `监听` / `收通知` / `通知` / `试锁`(会话级咨询锁) |
| 台账 | `记体检` / `记运行开始` / `记运行结束` / `记内核pid` / `最近运行` / `未结束运行` / `收尾未结束` / `今日运行次数` |
**规矩**`text[]` 列传 list`jsonb` 列传 "json.dumps 出来的串 + `::jsonb` 强转"`写调用``result`
给 dict/list 时也会自动 dumps);`写状态`/`写调用` 都有**列白名单**,拼错的列名会被挡(自测里有)。
### `内核/进程.py` — 通用进程库(内核管驱动、引导器管内核,都用这一份)
| 接口 | 说明 |
|---|---|
| `读cmdline` / `读stat` / `读进程信息` / `读开机秒` | 直读 `/proc`(不用 `ps`busybox 截断、`ps|grep` 会把自己算进去) |
| `判活(pid, 入口) -> 状态字符串` | `running/stopped/crashed/exited/zombie`**入口决定是否认领** |
| `匹配入口` / `按入口找进程` / `进程组成员` | cmdline 精确点名;组员跳僵尸(Z 杀不动) |
| `启动(argv, cwd, 日志, env, 入口, 探活秒, 分隔="")` | 独立会话 + stdio 重定向 + 分隔头 + 探活;**秒退不抛**,返回 `ok=False` + 本次日志尾巴 |
| `停止(pid, 入口, 超时)` | 校验 cmdline → `SIGTERM` 进程组 → 超时升 `SIGKILL` → 复查 |
| `从位置读日志` / `尾日志` / `跟日志` | 进程层通用读取(**带过滤/轮转感知的看 `日志.py`** |
| `跑命令` / `跑并转发` | 前台跑并回收输出(引导器透传内核用) |
### `内核/日志.py` — 日志系统(见 `设计/04`
| 接口 | 说明 |
|---|---|
| 路径 | `内核日志路径/内核输出路径/引导器日志路径/驱动日志路径`(集中定义,别再各处拼) |
| 级别 | `级别表` / `规范化级别` / `级别序号` / `达标` |
| 写 | `记(路径, 级别, 来源, 消息, 门槛, 控制台)` / `追加` |
| 解析 | `解析行`(拆字段)/ `猜级别`(驱动裸行)/ `命中`(过滤判据) |
| 读 | `尾(路径, 行数, 级别, 关键词, 含轮转)` / `跟(路径, 级别, 关键词)`(按行缓冲 + 轮转感知) |
| 轮转 | `轮转(路径, 上限字节, 保留份数)` / `轮转名单` / `带序路径` |
| CLI | `选项`(dataclass) / `解析选项(参数, 默认行数)` / `打印(行表, json输出)` / `转json行` |
| 杂 | `大小文本` / `现在文本` / `控制台开()` |
### `内核/文本.py` — CJK 宽度感知的输出
`左/右`(对齐字面量)、`字符宽``显示宽度``截断``填充``横线``表格(表头, 行表, 对齐=None)``打印`
中文是双宽 —— 直接用 `len()` 排表格是歪的,所有表格都过它。
### 验收与自测文件
| 文件 | 覆盖 |
|---|---|
| `内核/自测进程.py` | 真起进程、真收子树(11 项) |
| `内核/自测内核.py` | 58 项纯逻辑:脏配置 / 契约 / 环 / 状态机 / 拼命令 |
| `内核/自测配置.py` | 配置解析 / 版本比较 |
| `内核/自测db.py` | 真库真 SQL 182+ 项(11 组,三层隔离:前缀 / 事务回滚 / 两道终检) |
| `内核/自测日志.py` | 86 项:真写文件 / 真轮转 / 真起子进程跟日志 / 真起进程验分隔头 |
| `内核/自测AST等价.py` | 工具:改注释后证明"逻辑一行没动" |
| `试跑引导器.py` | 端到端 11 项(老板亲自跑的验收) |
## 3. 想改 X → 动哪几处(连带清单,漏一处就是坑)
| 想改 | 动 | 必须同步 |
|---|---|---|
| 加/改一个内核子命令 | `内核/内核.py``执行命令` + `命令Xxx` + `用法()` | `UEFI.boot.py 用法()`(透传清单)、`文档/03``自测内核.py`(纯逻辑那部分) |
| 加/改驱动配置字段 | `扫描.校验单个` + `db.drivers` DDL | `设计/01` 字段表、`状态.组装快照`(要进快照的话)、`自测db.py`(注册表组)、`文档/02` |
| 加状态取值 | `状态.py` 常量 + `中文表` | `db.driver_state`text,不用改)、`状态.复核`/`该拉起``自测内核.py``设计/01` 状态机、`设计/02` 判定表、`文档/05` |
| 加一张表 | `db.内核表` 里的 DDL 字符串 + 读写函数 | `自测db.py` 的两道终检(8 张表 → 9 张)、`文档/08``设计/02` |
| 改调用仲裁某条 | `内核/内核.py``校验调用`+ `db.同锁在跑` 之类) | `自测db.py` 调用组、`文档/04` 六条表、`设计/02` |
| 改日志格式/选项 | `内核/日志.py`(解析/格式化/选项) | `内核/内核.py 命令日志` + 引导器 `内核日志`/`命令日志`(共用解析)、`设计/04``文档/03`/`05``自测日志.py` |
| 改体检项 | `UEFI.boot.py``体检` + `检查项` | `设计/03``试跑引导器.py`(第 2 项查 6 项齐全)、`文档/01` |
| 改轮转策略 | `内核/日志.py 轮转` + 调用点(内核 `轮转日志` / 引导器启动段) | `设计/04``自测日志.py` 第 5 组、`.gitignore`(如果加了新文件后缀) |
| 改"什么时候自动拉起" | `状态.该拉起` | `自测内核.py``设计/02` 第 5 节、`文档/05` 断电表 |
| 加一个样板驱动 | `驱动/<新名>/`(配置 + 入口) | `设计/01` 若有新字段、`文档/02` 的样板清单 |
**改完一律跑**`uvx pyright` + 五份自测 + `python3 试跑引导器.py`(明细见 `10-验收与质量门.md`)。
+186
View File
@@ -0,0 +1,186 @@
# 08 · 数据模型(PG 就是内核内存)
> 库 `efi_kernel`**8 张表**。DDL 全在 `内核/db.py``内核表` / `引导器表` 两个列表里(`CREATE ... IF NOT EXISTS`
> 内核每次启动都调 `建表()`,幂等)。这份文档是**字段级**说明 + 排查常用 SQL。
> DDL 原文与判定表另见 `设计/02-内核设计.md` 第 4-5 节。
## 1. 表总览
| # | 表 | 谁写 | 谁读 | 生命周期 |
|---|---|---|---|---|
| ① | `drivers` | 内核(扫描 upsert) | 内核 / 引导器 / 自测 | 以磁盘为准:文件夹没了 → `清不在` 删行(并记事件) |
| ② | `driver_state` | 内核(每次动作/收尸) | 内核 / 看状态 | 一行一驱动(外键挂 `drivers``ON DELETE CASCADE` |
| ③ | `events` | 内核 **和驱动** | 人 / 内核 | 只增(总线;刷满会让有用的事看不见) |
| ④ | `scans` | 内核(每次扫描一行) | 人 / `内核 状态` | 只增(`list_version` 自增) |
| ⑤ | `commands` | CLI / 引导器(`pending`) | 常驻内核(领走执行) | 只增;`pending → running → done/failed` |
| ⑥ | `calls` | 驱动(INSERT)/ 内核(仲裁、收权)/ 提供方(回填) | 内核 / 消费方轮询 | 只增;终止态 `done/failed/denied/timeout` |
| ⑦ | `kernel_env` | 引导器(每次体检一行) | 人 / 引导器 | 只增("环境什么时候变坏的") |
| ⑧ | `kernel_runs` | 引导器(每次跑内核一行) | `内核 状态` | 只增;`finished_at IS NULL` = 没收尾(断电/被杀留下) |
## 2. 逐表字段
### ① `drivers`(驱动注册表,内核扫描后 upsert)
| 列 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `name` | text PK | — | 驱动名(配置的 `name`,缺省 = 文件夹名);全局唯一 |
| `dir` | text | — | 驱动文件夹绝对路径 |
| `runtime` | text | — | `python` / `exec` |
| `entry` | text | — | 入口(相对驱动根) |
| `interpreter` | text | — | `venv` / `system` / 绝对路径 |
| `args` / `env` | jsonb | `'[]'` / `'{}'` | 启动参数 / 追加环境变量 |
| `deps` | text[] | `'{}'` | **废弃**(早期字段,改用 `provides`/`needs`;留列不动) |
| `provides` / `needs` | text[] | `'{}'` | 契约名(**不是驱动名** |
| `autostart` / `restart` | boolean / text | `false` / `'no'` | 开机自启 / 崩了重拉(`no`\|`on-failure` |
| `mode` | text | `'resident'` | `resident` / `oneshot` |
| `config_hash` | text | — | `sha256(配置.efi.json)`(跟 `driver_state.boot_hash` 比 → "待重启" |
| `entry_hash` | text | — | `sha256(entry 文件)`"代码改过"提示用) |
| `valid` / `error` | boolean / text | `true` / — | 校验结果 + 原因(**坏的也入库**,列表要看得见) |
| `note` | text | — | 人看的一句话 |
| `scanned_at` | timestamptz | `now()` | 最近一次扫描时间 |
### ② `driver_state`(运行时状态,内核每次动作刷新)
| 列 | 类型 | 说明 |
|---|---|---|
| `name` | text PK | 外键 → `drivers(name)` CASCADE |
| `state` | text | 7 个取值之一(见 §4) |
| `pid` / `pgid` | integer | **只是账**:判活一律回 `/proc` 复核;`pgid` 收子树用 |
| `started_at` / `stopped_at` | timestamptz | ISO 显示由 `状态.现在文本()` 统一 |
| `exit_code` | integer | 最后一次退出码(`crash` 有无退出码决定"断电"还是"自己崩" |
| `restarts` | integer | 累计拉起次数(内核记账,不是配置) |
| `boot_hash` | text | **起进程那一刻**的配置指纹 |
| `list_version` | bigint | 本次清单版本(对应 `scans.list_version` |
| `last_error` | text | 最近一次错误原文(不吞错) |
| `updated_at` | timestamptz | 行更新时间 |
> 写入走 `db.写状态(连接, 名, 改动)` —— **白名单动态 UPDATE**:只改传进来的列,别的列一个都不动
> (自测里专门有一条守着)。想加可写列要同时改 `写状态` 的白名单。
### ③ `events`(总线:没有协议,就是往表里插一行)
| 列 | 类型 | 说明 |
|---|---|---|
| `id` | bigserial PK | — |
| `ts` | timestamptz | `now()` |
| `source` | text | 谁写的:`内核` / `引导器` / `<驱动名>` |
| `driver` | text | 关联驱动(可空) |
| `level` | text | `info` / `warn` / `error` |
| `kind` | text | 见 §3 字典 |
| `message` | text | 人读的一句话 |
| `data` | jsonb | 结构化载荷(不确定的放这里 —— **表结构就是消息格式** |
索引:`events_ts_idx(ts DESC)``events_driver_idx(driver, ts DESC)`
### ④ `scans`
`list_version` bigserial PK / `started_at` / `kernel`(内核版本)/ `total` / `valid` / `invalid` / `running`
### ⑤ `commands`
`id` bigserial PK / `ts` / `source``cli`\|`引导器`/ `cmd` / `args` jsonb / `state``pending`\|`running`\|`done`\|`failed`/
`result` jsonb / `started_at` / `finished_at`
内核领命令用 `FOR UPDATE SKIP LOCKED`(手滑起两个内核时,一条命令也只会被执行一次)。
CLI 自己执行过的命令落 `state='running'` → 常驻内核**不会重复领**。
### ⑥ `calls`
`id` bigserial PK / `ts` / `caller` / `want`**契约名**/ `args` jsonb(可带 `{"chain": [...]}`/ `state` /
`provider`(内核记账,消费方看不到)/ `lock_key` / `result` jsonb / `error` / `deadline` / `started_at` / `finished_at`
### ⑦ `kernel_env`
`id` / `ts` / `python_version` / `venv_path` / `venv_healthy` / `packages` jsonb
`[{name, want, got, ok, required}]`/ `pg_ok` / `driver_root_ok` / `ok` / `detail`
### ⑧ `kernel_runs`
`id` / `started_at` / `finished_at` / `argv` / `mode``oneshot`\|`daemon`/ `pid` / `exit_code` / `seconds` / `ok` / `detail`
(失败时存日志尾巴 —— 台账里也能看到死因)。
## 3. `events.kind` 字典(代码里实际在用的)
| kind | 谁写 | 什么时候 | 典型 message |
|---|---|---|---|
| `scan` | 内核 | 每次扫描末尾 | `扫描完成 drivers=3 valid=3 invalid=0 version=43` |
| `start` | 内核 | 驱动起来了 | `样板常驻 起来了 pid=20444 pgid=20444` |
| `stop` | 内核 | 停成功 | `样板常驻 已停 (协议收工)` |
| `exit` | 内核 | 收尸发现它没了 | `样板常驻: running -> crashed (断电 / 被杀)` |
| `error` | 内核 | 仲裁被拒 / 起不来 / 收权超时 / 依赖失效 | `调用 21 超时收权 (原来是 waiting, 提供方 未定)` |
| `produce` | 内核 | 调用转发出去 | `调用 5: 样例消费器 要 样板:心跳 -> 转发给 样板常驻` |
| `log` | 内核 | 配置类警告(如拼命令时的 WARN) | `样板常驻: 解释器回落 system` |
| `heartbeat` | 内核(每 300s)/ 驱动 | 活着的证据(**最后一条 heartbeat 就是死的那一刻** | `样板常驻 常驻中 (pid …)` |
`level``info`(默认)/ `warn` / `error`。驱动自己汇报用 `db.写事件` 同样的三个值。
## 4. 状态机(`driver_state.state`
```
┌──────────► stopped ◄──────────┐
│ │ │
断电/被杀 ───► crashed start│ │stop 成功
pid 被复用 ─► exited ▼ │
starting ──► running ─┤
│ │ │
拉起失败 崩了 │ │
▼ ▼ │
failed ◄────────┘────┘
invalid: 配置不合法, 内核拒载 (原因在 drivers.error / last_error)
```
| state | 中文 | 含义 |
|---|---|---|
| `stopped` | 停止 | 没在跑(正常态) |
| `starting` | 启动中 | 已 spawn,未确认存活 |
| `running` | 运行 | `/proc/<pid>` 在且 cmdline 校验通过 |
| `exited` | 已退出 | 进程没了(正常/异常退出),退出码在 `exit_code``oneshot` 跑完就退属于这个) |
| `crashed` | 崩了 | 断电 / 内核重启后判定:旧状态说在跑,`/proc` 里没有 |
| `failed` | 失败 | 拉起就失败(入口不存在 / 解释器缺 / 立刻非零退出) |
| `invalid` | 无效 | 配置不合法,内核拒载(**不影响别的驱动**) |
## 5. 落盘快照 `驱动/<名>/运行.efi.json`(内核写,整份重写)
`efi` / `name` / `state` / `pid` / `pgid` / `started_at` / `stopped_at` / `exit_code` / `restarts` /
`config_hash`(当前配置指纹)/ `boot_hash`(起进程那一刻的指纹)/ `entry_hash` / `list_version` /
`kernel` / `last_error` / `updated_at`
- 作用:**文件夹自包含**(拷走驱动目录、PG 不在时也知道它上次什么状态)。原子写(`.tmp` + `os.replace`)。
- **不是状态源**:一切判定都以 PG 为准;驱动作者手改它没意义(下次刷新覆盖)。
- `config_hash != boot_hash` ⇒ 列表显示"待重启"。
## 6. 配置速查
| 文件 | 谁写 | 字段表在哪 |
|---|---|---|
| `环境.efi.json`(项目根) | 引导器(内核只读) | `设计/03-引导器.md` 第 4 节:`efi/python/venv/index/packages/db/driver_root/stop_timeout/log_lines/log_level/log_max_mb/log_keep` |
| `驱动/<名>/配置.efi.json` | 驱动作者 | `设计/01-驱动规范.md` 第 3 节:`efi/name/runtime/mode/entry/interpreter/args/env/provides/needs/autostart/restart/logs/note` |
**已知边界(别踩)**`drivers` 表**没有 `logs` 列** —— 配置里的 `logs` 字段只影响"扫描那一刻的内存行",
从库里取行(真正拉起进程时)拿不到它,日志目录**按默认 `logs/`**。要真生效得给 `drivers` 加列
(配方见 `09-扩展指南.md` recipe 2)。目前样板驱动都用默认值,所以没暴露问题。
## 7. 排查常用 SQL
```sql
-- 谁在跑 / 谁坏了(一眼全览)
SELECT name, state, pid, restarts, last_error FROM driver_state ORDER BY name;
-- 最近到底发生了什么(总线)
SELECT ts, level, kind, source, driver, message FROM events ORDER BY id DESC LIMIT 30;
-- 某驱动的完整史
SELECT ts, kind, message FROM events WHERE driver = '样板常驻' ORDER BY id DESC LIMIT 50;
-- 调用有没有卡住(pending/waiting/running 都是"没结论"
SELECT id, caller, want, state, provider, lock_key, deadline, error FROM calls
WHERE state NOT IN ('done','failed','denied','timeout') ORDER BY id;
-- 内核是不是还活着(最后一条 heartbeat 就是它咽气的时刻)
SELECT ts, message FROM events WHERE kind = 'heartbeat' ORDER BY id DESC LIMIT 5;
-- 断电留下的半截运行记录
SELECT id, argv, mode, pid, started_at FROM kernel_runs WHERE finished_at IS NULL;
-- 环境什么时候变坏的
SELECT ts, ok, python_version, venv_healthy, pg_ok, detail FROM kernel_env ORDER BY id DESC LIMIT 10;
```
+122
View File
@@ -0,0 +1,122 @@
# 09 · 扩展指南(改底座的六个配方)
> 每个配方都是"照着做就不会漏"的步骤清单:**改哪 → 同步哪 → 怎么验收**。
> 漏掉"同步哪"是这套底座最常见的坑(改了代码不改文档/自测,下次自己都信不过它)。
## 配方 1 · 加一个内核子命令
目标例子:加一条 `重扫 [驱动名]`(只重扫一个驱动的配置)。
1. **实现**`内核/内核.py` 写一个 `命令重扫(连接, 环境, 参数) -> int`,里面只干这件事:调 `扫描`/`db`/`状态` 的现成接口。
—— 别在命令函数里写 SQL`db.py` 是唯一碰 SQL 的),别自己拼命令行(用 `拼命令`)。
2. **挂上分发**`内核/内核.py``执行命令(连接, 环境, 命令, 参数)` 里加一个分支(CLI 与常驻内核共用这一份实现,加一处两边都有)。
3. **写进用法**:同文件 `用法()` 加一行(`命令不认识` 时会打它)。
4. **透传清单**`UEFI.boot.py``用法()` 里那句"其余命令原样透传内核"的清单加上它。
5. **同步文档**`文档/03-命令手册.md` 的内核命令表加一行(作用/退出码)+ 若有选项写进选项表。
6. **同步自测**:纯逻辑部分(参数解析、命令拼装)加进 `内核/自测内核.py`;真跑一遍的命令进 `试跑引导器.py`(可选)。
7. **验收**
```bash
./.venv/bin/python 内核/内核.py 重扫 样板常驻 # 看输出
./.venv/bin/python 内核/内核.py 不认识的东西 # 应打用法 + 退出码 2
uvx pyright && ./.venv/bin/python 内核/自测内核.py
```
## 配方 2 · 加/改一个驱动配置字段
目标例子:让配置里的 `logs`(日志目录)**真正生效**(现在它只活在扫描那一瞬间的内存行里,见 `文档/08` §6 的"已知边界")。
1. **DDL**`内核/db.py``内核表` 里给 `drivers` 加一列。表已存在,所以要幂等补列:
```python
# 放在 drivers 的 CREATE TABLE 后面 (同一条列表里)
"ALTER TABLE drivers ADD COLUMN IF NOT EXISTS logs text",
```
—— `ADD COLUMN IF NOT EXISTS` 是幂等的,老库自动补列,不用手工迁移。
2. **写入**`db.记驱动` 的 INSERT 列表 + `ON CONFLICT DO UPDATE SET` + 参数元组**三处都要加**(漏一处就静默不写)。
3. **读取方**`状态.日志目录(驱动)` 已经读 `驱动.get("logs")`(不用改);确认其他路径构造也没绕开它。
4. **同步自测**`内核/自测db.py` 的注册表组加"写进去能读回来";`建表幂等` 那组的对象数会 +1`ALTER` 也是一条语句),
核对数量要跟着改;两道终检(8 张表残留/行数)不需要动。
5. **同步文档**`设计/01-驱动规范.md` 字段表(若字段语义变了)、`文档/02-写一个驱动.md` 的检查表、`文档/08-数据模型.md` 的列清单
**删掉"已知边界"那段**)。
6. **验收**
```bash
./.venv/bin/python 内核/内核.py 扫描
psql -h /home/lou/pgdata/socket -d efi_kernel -c "SELECT name, logs FROM drivers;"
./.venv/bin/python 内核/自测db.py # 0 退出 = 全过
uvx pyright
```
最后真起一个用自定义日志目录的驱动,确认日志落到那个目录。
## 配方 3 · 加一张表
1. **DDL**`内核/db.py``内核表` 列表末尾加 `"CREATE TABLE IF NOT EXISTS ..."`(要索引就再加 `CREATE INDEX IF NOT EXISTS`)。
2. **读写函数**:同文件加函数(**列名写全**`text[]` 传 listjsonb 传 `json.dumps(...) + ::jsonb`)。要"部分更新"就照
`写状态` / `写调用` 的白名单动态 UPDATE 写(别裸拼列名)。
3. **建表调用点**:内核 `连库()` 已经会 `db.建表`(全部),引导器只建自己两张 —— 新表归谁就放哪个列表。
4. **同步自测**`内核/自测db.py`
- 加一组测试(用 `_自测db_` 前缀造行 + 跑完删)
- **两道终检要改**:8 张表 → 9 张(残留逐表核对 + 行数快照逐表一致)
5. **同步文档**`文档/08-数据模型.md`(表总览 + 逐表字段 + 若有必要加排查 SQL)、`设计/02-内核设计.md` 第 4 节(DDL 区)。
6. **验收**`./.venv/bin/python 内核/自测db.py`(0 退出,且新表出现在残留/行数核对里)+ `uvx pyright`
## 配方 4 · 加一个日志来源 / 新开关
1. **实现**`内核/日志.py`
- 新来源:加一个 `XX日志路径(根)` 路径函数(**路径集中定义**,别在调用点拼字符串)。
- 新开关:`选项` dataclass 加字段 + `解析选项()` 里加分支(记得给"缺值"和"非法值"两种报错路径)。
2. **接进 CLI**`内核/内核.py 命令日志``UEFI.boot.py 内核日志/命令日志` 两处都要认(它们共用 `日志.解析选项`
但分支要各自加上)。
3. **写的人**:谁写这个来源就用 `日志.记(路径, 级别, 来源名, 消息, 门槛, 控制台)`;若是引导器,走 `记日志()`(它附带 stderr)。
4. **轮转**:在新来源被"拉起进程之前"的位置调 `日志.轮转(...)`(**运行中不许轮转**:改名 = 日志丢)。
5. **同步文档**`设计/04-日志系统.md`(三条道表 / 选项表)、`文档/03-命令手册.md`(选项表)、`文档/05-日志与排障.md`
6. **同步自测**`内核/自测日志.py` 加一组(真写文件、真过滤、真轮转;选项解析的报错分支也要加)。
7. **验收**`./.venv/bin/python 内核/自测日志.py` + 手工 `日志 --json` 看字段齐不齐 + `uvx pyright`
## 配方 5 · 加一项自测(三条隔离原则)
自测铁律:**真机、不 mock**(老板口径:"单元测试不算测试")。所以测试会真起进程、真写库 —— 必须隔离:
1. **前缀命名**:凡是往生产库造的行,一律用 `_自测db_` 前缀(驱动名 / source / cmd / caller / kernel …),
跑完按前缀 DELETE;开跑前先清一次上次崩的残渣(有就 WARN 出来)。
2. **破坏性函数走事务后回滚**`收尸命令` / `收尾未结束` / `清不在([])` 这种会动**别人行**的,
单独开 `autocommit = False` 的连接 → 造自己的行 → 调 → 同一事务里核对 → `rollback()`
3. **两道终检**:① 每张表按前缀核对残留必须**全 0**;② 每张表**行数与开跑前一致**(快照对比,比"残留 0"更硬)。
文件类自测(日志)则用 `tempfile.mkdtemp()` + 跑完删 + 开头结尾拍生产文件快照核对。
加一项的步骤:找到对应自测文件 → 在合适的分组里 `核对("名称", 实际, 期望)` / `核对真("名称", 条件, 说明)`
→ 一组抛异常不要让它中断全局(`跑组` 已带 try/except)→ 跑一遍看是否 0 退出。
**新自测文件要放进复验清单**(见 `10-验收与质量门.md`)。真实用例:2026-09-16 修"同锁互相排队"时,
`自测db.py` 调用组末尾加的 4 条断言就是照这个套路写的。
## 配方 6 · 改一个判定(最需要小心的一类)
判定 = "内核怎么看待一件事":`状态.复核`(现场状态)、`状态.该拉起`(要不要自动拉)、
`校验调用`(六条仲裁)、`扫描.校验单个`9 条校验)、`排顺序`(拓扑)。
1. **先写清"为什么改"**:改判定的注释必须写理由 + 日期(`【…】` 段落里已有这批习惯),别只改代码 —— 半年后没人知道原来的判定为什么那么写。
2. **改完立刻对照三处**
- `设计/01`(状态机 / 校验 9 条)、`设计/02`(判定表 / 仲裁六条)
- `文档/05-日志与排障.md``文档/04-契约与调用.md` 的表格
- 技能里的坑表(`kernel-driver-framework`
3. **补/改自测**:判定类改动**必须有回归**。纯逻辑进 `自测内核.py`(喂脏配置/假状态行),
涉及库与进程的进 `自测db.py` / `自测进程.py`
4. **端到端再走一遍真实故障**:改判定的价值就在于"真出事时行为对不对",所以至少手工演一遍:
```bash
python3 UEFI.boot.py 内核 启动 --守护
./.venv/bin/python 内核/内核.py 启动 样例消费器 # 触发按需拉起
kill -9 <上游 pid> # 级联演练
./.venv/bin/python 内核/内核.py 事件 -n 10 # 看判定结果
```
5. **验收**`uvx pyright` + 五份自测 + `python3 试跑引导器.py`(残留必须为空)。
## 附 · 改完代码的同步清单(打印出来贴墙上)
| 类型 | 同步项 |
|---|---|
| 任何代码改动 | `uvx pyright`0 errors / 0 warnings+ 五份自测 + `python3 试跑引导器.py` |
| 改了 CLI / 选项 | `用法()`(内核 + 引导器)、`文档/03` |
| 改了配置字段 | `db` DDL + `记驱动`(三处)+ `设计/01` + `文档/02`/`08` + `自测db.py` |
| 改了表 | `自测db.py` 两道终检(张数)+ `文档/08` + `设计/02` DDL 区 |
| 改了判定 | 自测回归 + `设计/01`/`02` + `文档/04`/`05` + 技能坑表 |
| 改了日志 | `设计/04` + `文档/03`/`05` + `自测日志.py` |
| 改了注释/文档串(批量) | 先备份 → 改 → `python3 内核/自测AST等价.py <备份> <现在>` 证明逻辑零动 |
| 一律 | `git status` 看有没有漏进去的运行时产物(`logs/``.venv/``运行.efi.json` |
+118
View File
@@ -0,0 +1,118 @@
# 10 · 验收与质量门
> 底座的可信度靠**四道门**:类型检查、五份真机自测、AST 等价(改注释时)、端到端 11 项。
> 任何一道不过就是没过 —— 老板的口径:**"单元测试不算测试,要真实集成测试"**,所以自测都真起进程、真写库。
> 这份文档是改完代码后**照着勾**的清单,末尾是全部已知坑的总表。
## 1. 四道门(一条命令一跑)
```bash
cd ~/桌面/工作区/内核
uvx --from pyright pyright # 门 1: 微软 pyright 严格档 -> 0 errors 0 warnings
# (项目根 pyrightconfig.json: strict + extraPaths)
for t in 自测进程 自测内核 自测配置 自测db 自测日志; do
./.venv/bin/python 内核/$t.py || echo "❌ $t"; # 门 2: 五份真机自测
done
python3 内核/自测AST等价.py <改前备份> <现在的文件> # 门 3: 只改注释/文档串时证明逻辑零动
python3 试跑引导器.py # 门 4: 端到端 11 项, PASS 11 / FAIL 0 / 残留无
```
## 2. 五份自测(真机、不 mock)
| 自测 | 项数 | 覆盖什么 | 会不会碰生产 |
|---|---|---|---|
| `内核/自测进程.py` | 11 | 真起进程、独立进程组、`/proc` 判活、pid 复用不认领、SIGTERM 收子树、秒退只读本次日志 | 只写 `内核/logs/自测进程/`gitignore 里) |
| `内核/自测内核.py` | 58 | 纯逻辑:脏配置 / 契约匹配 / 成环 / 状态机 / 拼命令 / 该拉起 | 不碰 PG、不写注册表 |
| `内核/自测配置.py` | ~10 | `环境.efi.json` 解析、版本比较、包要求解析 | 只读 |
| `内核/自测db.py` | 182+ | **真库真 SQL**:8 张表 / 43 函数(11 组:建表 / 注册表 / 状态白名单 / 事件 / 扫描 / 命令 / 调用(含同锁回归)/ LISTEN-NOTIFY / 咨询锁 / 引导器台账 / 清场终检) | 用 `_自测db_` 前缀 + 事务回滚 + 两道终检;跑完 8 张表**逐表行数与开跑前一致** |
| `内核/自测日志.py` | 86 | 级别门槛 / 行解析 / 写 / 尾读过滤 / **真轮转 6MB 超限文件** / **真起子进程跟日志** / 选项解析(含报错分支)/ JSON Lines / **真起进程验分隔头** / 清场 | 全部在 `tempfile.mkdtemp()` 里;开头结尾拍生产 `内核/logs/*.log` 快照核对 |
跑法:`./.venv/bin/python 内核/自测XXX.py`**0 退出 = 全过**`自测内核.py` 打印 `通过 58 / 失败 0`)。
## 3. 门 1:类型检查(微软 pyright 为准)
- 项目根必须有 `pyrightconfig.json``typeCheckingMode: strict` + `extraPaths: ["内核"]`)和 `.vscode/settings.json`
`python.analysis.extraPaths: ["内核"]`)。**没这两个**,中文模块名(`import db/进程/日志`)解析不了 →
先报 3 条"无法解析导入",再级联出**几百条**"类型未知"假警报。
- **判据是微软 `uvx pyright`**(与 Pylance 同引擎)。`uvx --from basedpyright basedpyright` 只当补充:
2026-09-16 实测它把同一份 `扫描.py` 的 6 errors + 2 warnings 报成 0/0,拿它交差被老板抓过。
- 两个已知告警怎么收干净:
1. `field(default_factory=list)` → 写全类型参数:`list[dict[str, Any]]` / `dict[str, str]` / `list[tuple[str, str]]`
否则 pyright 拿工厂返回类型当字段类型,判 `list[Unknown]`
2. `import psycopg2``reportMissingModuleSource`(编译轮子只有 `.so`):`.venv``types-psycopg2`
(已记进 `环境.efi.json``packages``required: false`+ 那两行加 `# pyright: ignore[reportMissingModuleSource]`
- 它是**能抓真 bug 的**:同一轮里靠它抓出 `main()` 漏传 `配置` 参数(裸跑直接 TypeError)。
## 4. 门 3:AST 等价(只改注释时用)
```bash
cp 内核/内核.py /tmp/基准.py # 1 先备份
# ... 改注释 / 换标点 ...
python3 内核/自测AST等价.py /tmp/基准.py 内核/内核.py
```
判据:输出 `[OK] AST 完全等价` 就过;若打印 diff,**只剩"字符串常量里的标点差异"也算通过**
(那是文案,不是逻辑)。人工重抄上千行抄错一行自己看不出来,这个工具 30 秒给结论。
## 5. 门 4:端到端 11 项(`试跑引导器.py`
老板亲自跑的验收:把 `UEFI.boot.py` 每条命令**真跑一遍**,每步打"命令原文 + 原样输出 + 判定",末尾汇总表。
覆盖:用法 / 体检 / 环境 `--json` / 包台账 / 环境状态快照 / 内核状态 / 透传列表 / 裸跑 / `内核 启动 --守护` /
`内核 日志` / `内核 停止`;收尾再扫一遍 `/proc` 确认**零残留**。
`--假内核` 会临时把内核入口换成只会心跳的假内核(演示"引导器管住一个常驻进程"),跑完按原样字节还原。
```
合计: PASS 11 | 预期 0 | FAIL 0
残留: 无
结论: 全过
```
> 第 8 项"裸跑"判定是 **-1 = PASS**:裸跑 = 前台起常驻内核,会一直挂着(设计如此);脚本超时收掉前台壳,
> 真正的常驻内核留给第 11 项收。
## 6. 发布前 checklist(照着勾)
- [ ] `uvx --from pyright pyright` → **0 errors / 0 warnings / 0 informations**
- [ ] 五份自测**全 0 退出**(`自测内核.py``失败 0`
- [ ] `python3 试跑引导器.py``PASS 11 | FAIL 0 | 残留: 无`
- [ ] `ps -eo pid,cmd | grep -E "内核/内核.py|心跳.py|请求.py" | grep -v grep` → **空**
- [ ] PG 里没有自测残留:8 张表按 `_自测db_` 前缀各查一次 = 0 行
- [ ] `calls` 里没有卡住的 `pending/waiting/running`(除非真的正在跑)
- [ ] `git status` 干净,且**没有** `logs/``.venv/``__pycache__/``运行.efi.json` 被加进来
- [ ] 文档同步(`09-扩展指南.md` 末尾的同步清单逐项过一遍)
- [ ] `设计/` 与代码一致(字段表 / 判定表 / DDL / CLI
- [ ] 新坑写进技能 `kernel-driver-framework` 的坑表(否则下次自己再踩)
## 7. 已知坑总表(按"踩到的顺序",改代码前扫一眼)
| # | 坑 | 防法 |
|---|---|---|
| 1 | `UPDATE ... ORDER BY` 在 PG 里非法 | 排序 + 跳锁必须写子查询(`WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED)` |
| 2 | 调用成环不能拿"调用方自己"判(它就是链尾) | 判据 = **要的契约**或**匹配出的提供方**已在链上 |
| 3 | `killpg` 会把自己也收了(同一进程组) | `组 == os.getpgrp()` 时只 `os.kill(pid)` |
| 4 | `oneshot` 跑完就退被判成"崩了" | 状态机按 `mode` 分支(`复核``拉起一个` 两处) |
| 5 | 前台起常驻内核会一直阻塞 | 脚本给超时;**超时只杀直接子进程,孙进程(常驻内核)会活下来**,调用方自己收 |
| 6 | 杀调度进程时 `pgrep -f "内核/内核.py 调度"` **匹配到自己** | 读 `/proc/*/cmdline` 拆 argv 精确比对;`pkill` 与启动分两条执行 |
| 7 | 模块级常量被同名函数遮蔽(`内核日志` 打印成 `<function …>` | 路径常量统一叫 `XX路径` |
| 8 | `RETURNING id` 配默认游标返回 tuple → `行["id"]` 报错 | 用 `cursor_factory=RealDictCursor` |
| 9 | 僵尸 `Z` 杀不动 → 停止永远报"还有 N 个没收掉" | 按"死透"判:组员跳 Z、`_等死` 把 Z 当已死;判活仍如实报 `zombie` |
| 10 | 秒退时读整份日志 → 把上次的输出当本次死因 | 按**本次启动的字节偏移**读(`从位置读日志`;分隔头写完后取偏移) |
| 11 | 同锁两条请求**互相排队、双双卡死**(2026-09-16 实测) | `领调用``waiting` 一起领;`同锁在跑` 只认 `running`;排队也立 `deadline` |
| 12 | 命令输出混进日志文件(引导器把内核 stdout 一起重定向) | 三条道分家 + 守护模式 `EFI_LOG_CONSOLE=0` |
| 13 | 日志轮转在运行中做 → 进程继续写老 inode(日志"丢了" | 只在**拉起进程之前**轮转 |
| 14 | `-f` 跟日志读到半行 → 乱码 | 按行缓冲(`rpartition(b"\n")`),半个留到下一轮;轮转/清空时位置归零 |
| 15 | 自测里 `日志.跟` 子进程传 `str` 而非 `Path` → 子进程秒退 | 一律传 `Path`(自测第 6 组抓到的) |
| 16 | 无参数命令分发写成 `if 命令 == "日志" and 参数:` → 台账形态进不去 | 去掉 `and 参数``f"日志 {参数[0]}"` 换成 `' '.join(参数)` |
| 17 | 自测残留检查用宽关键词(`"自测日志" in cmdline`)把自己算进去 → 假残留 | 认子进程特征(如 `"日志.跟("` + 临时目录)并跳过读不到的 `/proc/<pid>` |
| 18 | 批量补空格用 `\]` 通配正则 → 一次改坏 430 处(`-> list[str] :` | 只匹配来源字符 `re.sub(r"】(?=\S)", "] ", s)`;改前整目录备份 |
| 19 | `basedpyright` 漏报(0/0 而微软 pyright 报 6 errors | 判据一律以 `uvx pyright` 为准 |
| 20 | `field(default_factory=list)``list[Unknown]` | 工厂写全类型参数 |
| 21 | 缺 `extraPaths` → 390 条"类型未知"假警报 | 项目根配 `pyrightconfig.json` + `.vscode/settings.json` |
| 22 | 自测里 `通知()` 完立刻 `poll()` → 误报(NOTIFY 要等发方提交) | 用 `等通知(连接, 超时)` 轮询(`poll + sleep 0.05` |
| 23 | 自测按下标核对真值用了 `db._查`(私有)| 用公开的 `db.查(连接, sql, 参数)` |
| 24 | "上游只是没启动"被算成下游"依赖失效" → 天天假警 | 只有上游 `崩了`/`失败` 才算(`停止` 不算) |
| 25 | 自测想"行在就取某列"写成 `行["x"] if 行 else None` → 被判 `reportOptionalSubscript` | 统一走 `值(行, "列名")` 助手 |
| 26 | 一组抛异常拖停整个自测 | `跑组(名称, 动作)` 每组装 try/except + `traceback`,末了出汇总表 |
> 新踩到的坑:**先修代码,再补进这张表**(以及技能里的坑表)—— 坑表是这套底座最贵的资产之一。