diff --git a/deploy/hel874-release.md b/deploy/hel874-release.md index f768508..f11211c 100644 --- a/deploy/hel874-release.md +++ b/deploy/hel874-release.md @@ -20,6 +20,10 @@ mem0 同进程导入的准确缺项与不下载解决路径、空账初始化、 ## 2. 安装命令(发布卡执行) +> **已被取代(HEL-879,2026-10-27)**:本节安装命令缺 `--offline`/`--no-index` +> 强制项。唯一照抄入口改为 `deploy/hel895-release-candidate.md` §2。本文件其余 +> 内容作为历史交付证据保留原样。 + ```bash # 全新目录,逐条照抄;任何一步失败即停 # 2.1 服务账号与目录(独立可写数据路径,权限边界明确) @@ -63,6 +67,11 @@ Mem0 提取都会在发送前被硬闸拒绝(`budget_*_exhausted`)——这 ## 4. 真实语义激活前置(B 部分批准后;准确缺项与不下载路径) +> **已被取代(HEL-879,2026-10-27)**:本节 4.1/4.2 按索引拉 `mem0ai==2.2.1` +> 同名 wheel,不能保证固定源码身份 2c81af3;固定 SDK 激活的唯一照抄入口改为 +> `deploy/hel895-release-candidate.md` §4(固定 tarball 哈希 → 固定源码构建轮 → +> cp311 依赖准备 → 仅本地轮安装 → 同进程 co-import)。以下保留为历史证据。 + **当前事实(HEL-882 离线核实,tools/check_release_offline_hel882.py R6):** 固定安装 hel857-venv 是 Python 3.10.12,产品发布 venv 是 3.11.16,两者 site-packages 不能混用;本机 uv/pip 缓存里没有 mem0ai/fastembed/qdrant-client diff --git a/deploy/hel895-release-candidate.md b/deploy/hel895-release-candidate.md index 52fbbaf..eba97c6 100644 --- a/deploy/hel895-release-candidate.md +++ b/deploy/hel895-release-candidate.md @@ -1,5 +1,17 @@ # HEL-895 · 统一发布候选(离线核验过,未执行部署) +> **HEL-879 发布工具收口(2026-10-27,隔离候选分支 `hel-879-assembly`)**:按总管 +> 对 HEL-896 的独立复核完成四项有限收口——①归档一致性:checkpoint 返回值必查、 +> 源/快照行数不一致或 checkpoint busy 一律 CLI 非 0 且不形成可交付包,verify 拒绝 +> 记录了 `rows_match=False` 的旧包(§5);②目录内真实 SQLite(history_root 的 +> `mem0_public_history.db` 等)停服口径走 checkpoint+逐表核对,在线模式只允许显式 +> 单库 backup API、含 SQLite 的目录直接拒绝;③提前封账:`tools/seal_budget_hel879.py` +> 显式封账(真实用量保留、跨重启/归档拒绝,负例 S6);④安装入口唯一化:本文件 §2/§4 +> 为唯一照抄入口(全 offline/no-index;mem0ai 只认固定源码轮),`deploy/hel874-release.md` +> §2/§4 已标注被取代。产物清单与精确提交见 `docs/rework/hel879/sdk-audit/` 与 +> `docs/rework/hel879/release-artifacts.json`。**不要用旧父提交(04eff61/152479b) +> 的说明冒充本候选 head:以交付评论与 handoff JSON 记录的精确提交为准。** + > **HEL-896 修订(2026-10-27,正常追加 PR#11)**:按总管独立复审补齐——①浏览器 > 「真实服务 7/7」中 B3 原为已证实误报,测试已修为精确交互+严格断言(9 项检查, > 含权限 API 调用计数与非回环出站守卫,证据 `docs/rework/hel896/browser/`); @@ -51,13 +63,36 @@ HEL-881 已审核的主动留言前端(`bf234570`)装成一个统一候选 | 预算执行包 | `docs/budget-package-hel895.md`(含 §6 HEL-896 分账修订) | A/B/C 一次批准核定稿,默认 0 | | HEL-896 工具 | `tools/verify_budget_split_hel896.py`、`tools/audit_sdk_offline_hel896.py`、`tools/release_snapshot_hel896.py`、修订版 `web/tests/hel895_real.mjs` | 分账切换验证、SDK 余缺只读审计、一致性快照/回退验证、严格浏览器检查 | -## 2. 安装命令(发布卡执行) +## 2. 安装命令(发布卡执行;本节是唯一照抄入口——HEL-879 收口) -与 `deploy/hel874-release.md` §2 完全相同(服务账号 → 全新空目录 clone 本 head → -`uv venv --python 3.11` + `uv pip install -r requirements.lock` → `npm ci && -npm run build` → 安装 service → `curl /api/health`),此处不重复抄写;唯一差别是 -`git checkout <本文件 §0 的精确 head>`。发布前必过检查同样按 `deploy/hel874-release.md` -§6 执行,另加本卡两个新工具(真实 HTTP 分页合同、真实服务浏览器)在目标机重跑。 +```bash +# 全新目录,逐条照抄;任何一步失败即停。全部安装强制 offline/no-index: +# 缓存命中即装,缓存缺失即停并留证;联网补齐只发生在获批的发布窗口内, +# 逐项 sha256 留证(seq48–51 勘误教训:不写 offline 的安装发生过网络获取)。 +# 2.1 服务账号与目录(独立可写数据路径,权限边界明确) +useradd --system --home-dir /opt/cybergril-hel874 --shell /usr/sbin/nologin cybergril || true +mkdir -p /opt/cybergril-hel874/data # data 必须为空:新账从零初始化 +chown -R cybergril:cybergril /opt/cybergril-hel874 + +# 2.2 代码 checkout:全新空目录 clone(空目录里没有仓库,不能直接 git fetch) +git clone ssh://git@192.168.200.36:222/leefer/CyberGril.git /opt/cybergril-hel874/current +git -C /opt/cybergril-hel874/current checkout <交付评论/handoff JSON 记录的精确head> + +# 2.3 依赖与前端(uv/npm 一律 offline;缓存缺失即停) +cd /opt/cybergril-hel874/current +uv venv --python 3.11 .venv311 +uv pip install --python .venv311/bin/python --offline -r requirements.lock +(cd web && npm ci --offline && npm run build) + +# 2.4 服务单元(单元内已带 PYTHONPATH=/src:requirements.lock 只装依赖, +# 产品代码经 PYTHONPATH 进程内可 import) +cp deploy/cybergril-hel874-8360.service.example /etc/systemd/system/cybergril-hel874.service +systemctl daemon-reload && systemctl enable --now cybergril-hel874 +curl -s http://127.0.0.1:8360/api/health | python3 -m json.tool +``` + +发布前必过检查按 §6 与 `deploy/hel874-release.md` §6 的清单执行(该节仍有效), +另加本卡工具(真实 HTTP 分页合同、真实服务浏览器)在目标机重跑。 ## 3. 健康能力矩阵(/api/health 预期) @@ -68,38 +103,60 @@ weather/materials/backup=false。**主动留言前端已并入本候选:生活 真实 reach-out 后,打开的页面每 20 秒读一次新行、回来立即读,未读有标记;该行为 已在本 head 用真实服务验证(§0)。** -## 4. 固定 SDK(3.11)准确缺项与不下载路径 +## 4. 固定 SDK(3.11)激活——唯一照抄入口(HEL-879 收口) -**HEL-896 只读审计修订(2026-10-27,`tools/audit_sdk_offline_hel896.py`,证据 -`docs/rework/hel896/sdk-audit/`)**:此前「只缺 mem0ai/qdrant-client/fastembed 三个 -cp311 wheel 及纯 Python 传递依赖」的概括**不准确**,勘误如下: +**本节取代 `deploy/hel874-release.md` §4(历史文件,已标注被取代,不得再照抄)。** +旧 4.1/4.2 按 `deploy/mem0-stack.lock` 的 `mem0ai==2.2.1` 向索引拉同名 wheel—— +**索引同名 wheel 不能证明固定源码身份 2c81af3**,这条路已废除。入口只有一条: -- 三个顶层包固定版本(mem0ai 2.2.1 / qdrant-client 1.20.0 / fastembed 0.9.1)的 - WHEEL 均为 `py3-none-any` 纯 Python,本身无 cp311 专用轮一说;**mem0ai 的安装来源 - 是本地固定源码**(hel857-src,`deploy/mem0-stack.lock` 源码提交 2c81af3、tarball - SHA256 `9dcef930…dda23` 已在本机 hel857-data 实测一致)——从索引下载同名 2.2.1 - wheel 不证明与固定源码相同,发布必须用固定 tarball/源码离线构建安装。 -- 按 3.11 Linux markers + extras 从 3.10 固定栈安装元数据求闭包共 **55 项,产品 - 3.11 venv 缺 41 项**(28 纯 Python + 13 原生扩展:grpcio/numpy/onnxruntime/ - tokenizers/sqlalchemy/protobuf/greenlet/pillow 等)。这是**依赖闭包清单**,不是 - 已完成的 cp311 解析或下载结果。 -- 当前本机缓存对 41 项缺项**立即可复用**的只有 3 项(greenlet 3.5.6 cp311、 - pillow 12.3.0 cp311、truststore 0.10.5 py3-none-any,uv wheels-v6/v7 缓存实测); - 其余 37 项 + mem0ai 固定源码离线构建所需 build backend(hatchling 未在缓存)由 - 发布卡获授权后按 `deploy/hel874-release.md` §4 拉取,逐项 sha256 留证,下载清单 - 保持 `deploy/mem0-stack.lock` 与固定源码身份;缺项不阻塞其余发布工作。 -- **fastembed 模型缓存实测**:hel857-data/fastembed-cache 含真实模型 - `Qdrant/bge-small-zh-v1.5` 与 `Qdrant/bm25`(约 92MB);发布机落位=实例数据目录 - 内该缓存的拷贝,真实 mem0 模式的 cache_dir 指向它;本卡零下载、不推理。 -- **seq48–51 pip 事件勘误(缓存取证)**:HEL-895 运行中去掉 `--offline` 安装的 - pip 26.2.2 在 uv 缓存的 wheel 条目创建于 2026-10-27 02:10:04(该运行窗口内, - 来源 files.pythonhosted.org,且为窗口内唯一新增缓存条目)——该步发生了网络 - 获取,前卡「零下载」对此步不成立;requirements.lock 装机同期未产生新缓存条目 - (全部命中既有缓存)。字节级完整下载与条件请求无法由缓存取证区分,按保守口径 - 记为下载。**后续一切安装强制 `--offline`/`--no-index`。** +**固定 tarball 哈希 → 固定源码构建轮 → 目标 cp311 依赖准备 → 仅本地轮安装 → 同进程 co-import。** + +- **第 0 步 · 产物清单核对**:`docs/rework/hel879/sdk-audit/sdk-audit.json`(本候选 + 精确提交上重跑)与 `docs/rework/hel879/release-artifacts.json` 记录闭包 55 项、 + 缺 41 项、缓存可复用 3 项与逐项哈希;发布卡先核对清单与目标提交一致。 + 事实口径(HEL-896 审计核定,HEL-879 重跑仍成立):三个顶层固定版本 + (mem0ai 2.2.1 / qdrant-client 1.20.0 / fastembed 0.9.1)的 wheel 均为 + `py3-none-any` 纯 Python;闭包按 3.11 Linux markers+extras 共 **55 项,产品 + 3.11 venv 缺 41 项**(28 纯 Python + 13 原生:grpcio/numpy/onnxruntime/ + tokenizers/sqlalchemy/protobuf 等),这是闭包清单不是已完成的 cp311 下载结果。 + seq48–51 pip 事件勘误保留:不写 offline 的安装发生过网络获取(uv 缓存取证), + 故本入口全程 `--offline`/`--no-index`。 +- **第 1 步 · 固定 tarball 哈希核对**(零下载): + ```bash + sha256sum /mem0-2c81af3.tar.gz + # 必须等于 deploy/mem0-stack.lock 记录的 + # 9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23 + ``` + 不一致即停;不得改用索引 `mem0ai==2.2.1` wheel 冒充固定源(audit 已证明两者 + 身份不可互证)。 +- **第 2 步 · 固定源码构建轮**:`python3 -m pip wheel --no-deps --no-index + --no-build-isolation -w wheels/ mem0-2c81af3.tar.gz`。**前置缺口如实保留**: + build backend `hatchling` 当前不在任何本机缓存(audit 实测),未获批不联网补齐; + hatchling 落位后本命令完全离线,构建出的 wheel 逐项 sha256 留证。 +- **第 3 步 · 目标 cp311 依赖准备**:41 项缺项中**缓存今天即可复用 3 项** + (greenlet 3.5.6 cp311、pillow 12.3.0 cp311、truststore 0.10.5 py3-none-any, + uv wheels-v6/v7 缓存实测,HEL-879 重跑核实仍命中);其余 37 项 + hatchling + **由总管获批后发布窗口拉取**(`python3 -m pip download --only-binary :all: + --implementation cp --python-version 3.11 --platform manylinux2014_x86_64 + --abi cp311 -r <缺项清单> -d wheels/`),逐项 sha256 留证;本卡零下载、零联网。 + 某固定版本若无 cp311 wheel,按 lock 注释如实上报缺项,不换版本号、不混 sdist。 +- **第 4 步 · 仅本地轮安装**(--no-index 全程,目录里只有上两步产物): + ```bash + uv pip install --python .venv311/bin/python --no-index \ + --find-links <本地wheels目录> mem0ai==2.2.1 qdrant-client==1.20.0 fastembed==0.9.1 <41缺项> + ``` + `deploy/mem0-stack.lock` 的 mem0ai 行只作版本记录;安装来源必须是第 2 步的 + 固定源码轮(同版本号不同来源不互认)。 +- **第 5 步 · 同进程 co-import 与模型落位**: + ```bash + .venv311/bin/python -c "import cybergril.api, mem0, qdrant_client, fastembed; print('co-import ok')" + ``` + fastembed 模型缓存(hel857-data/fastembed-cache,Qdrant/bge-small-zh-v1.5 + + bm25 约 92MB,实测存在)拷入实例数据目录,真实 mem0 模式 cache_dir 指向它。 产品 venv 目前 `import mem0/qdrant_client/fastembed` 均缺、产品代码导入不受影响; 在 wheel 落位且 co-import 通过前,不得以 stub/FTS 冒充真实 Mem0 体验就绪。 +真缺的下载(37 项 + hatchling)仍待主卡授权,本卡不为补命令联网。 ## 5. 停用与保留数据回退 @@ -108,26 +165,39 @@ cp311 wheel 及纯 Python 传递依赖」的概括**不准确**,勘误如下 `systemctl stop` → 数据目录原样保留 → 切回上一 release/上一提交(R4 迁移全部 增量新表,旧代码可安全打开同一数据库);彻底撤销留数据档不删。 -**HEL-896 归档/回退修订(2026-10-27)**:此前「SQLite WAL 单文件可整体复制/归档」 -的口径**不安全**——在线库只复制主 `.db` 会丢 WAL 里已提交的资料(总管复现件连 -`companion` 表都不在;本卡 `tools/release_snapshot_hel896.py` 自测同样复现 -`no such table: companion`,证据 `docs/rework/hel896/release-snapshot/selftest.json`)。 -发布归档一律二选一:**停服务 + `PRAGMA wal_checkpoint(TRUNCATE)` 后整目录归档** -(推荐),或**在线用 SQLite backup API 逐库一致性快照**。照抄命令: +**HEL-896 归档/回退修订 + HEL-879 收口(2026-10-27)**:在线库只复制主 `.db` 会丢 +WAL 里已提交的资料(总管复现件连 `companion` 表都不在)。发布归档一律二选一: +**停服务 + `PRAGMA wal_checkpoint(TRUNCATE)` 后整目录归档**(推荐),或**在线用 +SQLite backup API 对显式单库做一致性快照**。工具已按收口语义强制执行(v2): + +- checkpoint 返回 busy≠0 或源/快照行数不一致 → CLI 非 0、不产生可交付归档 + (「先停服」是前提,不是忽略失败结果的理由); +- 目录内真实 SQLite(history_root 的 `mem0_public_history.db`、向量目录内库等, + 按文件头魔数递归识别)在停服口径一并 checkpoint+逐表核对; +- 在线模式只接受显式 `--db`(backup API);传入含 SQLite 的 `--file` 目录直接拒绝; +- `verify` 拒绝记录了 `rows_match=False` 的旧包,回退演练按源路径映射解包到隔离 + 新目录、实际打开各库核对行数与哈希,输出 rollback_plan。 + +照抄命令: ```bash # 覆盖范围:产品库(CYBERGRIL_DB)、语义向量目录 vector_root、推导史 history_root、 # 权威嵌入账 ledger_path(按部署实例 config/semantic.yaml 实际路径逐项代入) +# 停服口径(推荐;目录内 SQLite 一并一致性处理): .venv311/bin/python tools/release_snapshot_hel896.py snapshot \ --name --db <产品库> [--db <权威账>] \ --file --file --out /opt/<实例>/archives/ +# 在线兜底口径:仅显式单库(backup API),不得传含 SQLite 的目录: +.venv311/bin/python tools/release_snapshot_hel896.py snapshot --name <名> \ + --db <产品库> --out /opt/<实例>/archives/ --online .venv311/bin/python tools/release_snapshot_hel896.py verify --archive <产物>.tar.gz ``` 产物含逐文件 SHA256 清单、**代码精确提交**(快照时刻 `git rev-parse HEAD`)、 逐表行数核对与回退演练结果;不删档,旧归档永久保留。回退=停服务 → 解包快照到 -新数据目录 → 指回上一 release/上一提交 → 起 service → `verify` 通过后交付。 -本节是**发布归档/回退要求**,不冒充 R6(CG-009 每日自动备份/手动完整导出)完成。 +新数据目录(按 rollback_plan 的源路径映射)→ 指回上一 release/上一提交 → 起 +service → `verify` 通过后交付。本节是**发布归档/回退要求**,不冒充 R6(CG-009 +每日自动备份/手动完整导出)完成。 ## 6. 本候选不做的事 diff --git a/deploy/mem0-stack.lock b/deploy/mem0-stack.lock index 4d8829d..4616cfb 100644 --- a/deploy/mem0-stack.lock +++ b/deploy/mem0-stack.lock @@ -1,11 +1,13 @@ # CyberGril 语义记忆层固定栈清单(HEL-882;B 部分批准后由发布卡拉取) # 固定源:mem0ai 2.2.1(源码提交 2c81af3bbe27d9bccb09c772d583df9e9be72ee5, # tarball SHA256 9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23)。 -# 用于 deploy/hel874-release.md §4:在具备内网源权限的环境按 3.11 平台拉全 -# wheel 后离线并入发布 venv;本卡不执行下载。版本来自 hel857-venv 实测安装 +# HEL-879 唯一安装入口:deploy/hel895-release-candidate.md §4(固定 tarball 哈希 → +# 固定源码构建轮 → cp311 依赖准备 → 仅本地轮安装 → 同进程 co-import)。 +# 本文件的 mem0ai==2.2.1 行只作版本记录——不得按此行向索引拉同名 wheel 冒充固定源 +# (旧 deploy/hel874-release.md §4 已被取代)。若某包无 3.11 wheel,发布卡须如实 +# 上报缺项,不得换版本号冒充固定安装。版本来自 hel857-venv 实测安装 #(Python 3.10.12 侧 mem0ai 2.2.1 / qdrant-client 1.20.0 / fastembed 0.9.1), -# 3.11 侧需同版本 wheel;若某包无 3.11 wheel,发布卡须如实上报缺项,不得 -# 换版本号冒充固定安装。 +# 3.11 侧需同版本 wheel。 mem0ai==2.2.1 qdrant-client==1.20.0 fastembed==0.9.1 diff --git a/docs/budget-package-hel895.md b/docs/budget-package-hel895.md index 9ad721d..95bca75 100644 --- a/docs/budget-package-hel895.md +++ b/docs/budget-package-hel895.md @@ -113,10 +113,15 @@ HEL-857 的 60 次实验授权);B/C 需白栖知明确批准。 `docs/budget-plan-hel874.md` §1 体验链 1–7 逐步执行:创建/诠释/习惯 4、对话 16、 生活 8、判定/成长/gist 20、主动措辞 3,其余额度备用至 55;每步在四道闸下原子 预占(类别日顶→日 token 池→一次性总量;S1 验证叠加取最紧)。 -2. **A 结束即封账**,两种到达方式同义:额度用满(`budget_total_requests_exhausted:55/55` - 如实拒绝、不补发)或体验链走完。封账动作:`systemctl stop` 8360 实例 → - `tools/release_snapshot_hel896.py` 做一致性快照(停服+checkpoint/backup API)→ - 归档(数据目录 + 哈希清单)交交付评论。封账跨重启、跨归档不失效(S2 验证), +2. **A 结束即封账**,两种到达方式都有可执行动作(HEL-879 收口):额度用满时 + `budget_total_requests_exhausted:55/55` 如实拒绝、不补发;**体验链提前走完 + (如 51/55)时运行 `tools/seal_budget_hel879.py --db <团队实例库>` 显式封账** + ——真实用量 51 原样写入并保留(不伪造 55、不清零),此后该库任何类别任何 + 余量的 `acquire` 都拒绝 `budget_sealed:51/55`,跨重启、跨 backup API 归档/ + 快照恢复同样拒绝(S6 负例:51/55 提前收尾、误重启、归档恢复均拒绝;重复 + seal 幂等不覆盖原标记;无解除封账代码路径)。封账动作顺序:`systemctl stop` + 8360 实例 → `seal_budget_hel879.py` → `tools/release_snapshot_hel896.py` + 一致性快照(封账标记随库一同归档)→ 归档(数据目录 + 哈希清单)交交付评论。 `budget_total` 永不手工清零。 3. **旧测试数据不会转成用户伴侣**:测试档案、聊天、生活数据只存在于团队实例库与 归档;用户实例是全新空库,由用户自己创建档案;产品无任何「测试库导入用户库」 diff --git a/docs/rework/hel879/release-snapshot/selftest.json b/docs/rework/hel879/release-snapshot/selftest.json new file mode 100644 index 0000000..96a1ba0 --- /dev/null +++ b/docs/rework/hel879/release-snapshot/selftest.json @@ -0,0 +1,52 @@ +{ + "ok": true, + "code_commit": "c121c42e39174e0f627f129e7f7e5700f33acf26", + "tool_version": 2, + "checks": [ + { + "name": "naive_online_copy_loses_wal_reproduced", + "ok": true, + "detail": "error=no such table: companion" + }, + { + "name": "busy_checkpoint_state_reproduced", + "ok": true, + "detail": "checkpoint=(1, 9, 8) naive_snapshot_events=error=DatabaseError:database disk image is malformed" + }, + { + "name": "fix1_busy_checkpoint_fails_no_archive", + "ok": true, + "detail": "error=yes leftovers=0" + }, + { + "name": "fix1b_row_mismatch_fails_no_archive", + "ok": true, + "detail": "error=源/快照行数不一致,不打包:/tmp/multica-task-29494715/hel879-snapshot-sel artifacts=[]" + }, + { + "name": "fix_verify_rejects_rows_match_false_legacy_package", + "ok": true, + "detail": "ok=False checks=[{'db': 'databases/companion.db', 'sha256_ok': True, 'rows_ok': False, 'counts': {'companion': 1, 'chat_message': 1, 'events': 0}, 'source_counts': {'companion': 1, 'chat_message': 1, 'events': 1}, 'rows_match_recorded': False, 'rejected_legacy_package': True}]" + }, + { + "name": "fix2_online_mode_refuses_dir_with_sqlite", + "ok": true, + "detail": "在线模式只允许显式单库 backup API(--db);--file /tmp/multica-task-29494715/hel879-snapshot-selftest-uapq8_ua/instance/history_root 内" + }, + { + "name": "fix2_stop_mode_embeds_sqlite_via_consistency_path", + "ok": true, + "detail": "embedded=['/tmp/multica-task-29494715/hel879-snapshot-selftest-uapq8_ua/instance/history_root/mem0_public_history.db']" + }, + { + "name": "fix3_verify_maps_source_paths_and_passes", + "ok": true, + "detail": "ok=True commit=c121c42e39174e0f627f129e7f7e5700f33acf26 plan_entries=4" + }, + { + "name": "backup_api_snapshot_complete", + "ok": true, + "detail": "{\"companion\": 1, \"chat_message\": 1, \"events\": 1}" + } + ] +} \ No newline at end of file diff --git a/docs/rework/hel879/sdk-audit/sdk-audit.json b/docs/rework/hel879/sdk-audit/sdk-audit.json new file mode 100644 index 0000000..95c0002 --- /dev/null +++ b/docs/rework/hel879/sdk-audit/sdk-audit.json @@ -0,0 +1,886 @@ +{ + "head": "c121c42e39174e0f627f129e7f7e5700f33acf26", + "scope": "只读审计:3.10 固定栈安装元数据按 3.11 Linux markers+extras 求闭包,对照产品 3.11 venv 与本机 uv/pip 缓存;这是依赖闭包清单,不是已完成的 cp311 解析或下载结果", + "read_only": true, + "zero_downloads_this_card": true, + "closure_total": 55, + "missing_count": 41, + "missing_pure_python": [ + "backoff", + "coloredlogs", + "distro", + "fastembed", + "filelock", + "flatbuffers", + "fsspec", + "h2", + "hpack", + "httpcore2", + "httpx2", + "huggingface-hub", + "humanfriendly", + "hyperframe", + "loguru", + "mem0ai", + "mpmath", + "openai", + "portalocker", + "posthog", + "pytz", + "qdrant-client", + "requests", + "sniffio", + "sympy", + "tqdm", + "truststore", + "urllib3" + ], + "missing_native": [ + "charset-normalizer", + "greenlet", + "grpcio", + "hf-xet", + "jiter", + "mmh3", + "numpy", + "onnxruntime", + "pillow", + "protobuf", + "py-rust-stemmers", + "sqlalchemy", + "tokenizers" + ], + "cache_reusable_now": [ + "greenlet", + "pillow", + "truststore" + ], + "top_level": { + "mem0ai": { + "name": "mem0ai", + "version": "2.2.1", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": "{\"dir_info\": {}, \"url\": \"file:///home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-src\"}", + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "fixed-source" + }, + "qdrant-client": { + "name": "qdrant-client", + "version": "1.20.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + "fastembed": { + "name": "fastembed", + "version": "0.9.1", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + } + }, + "missing_detail": [ + { + "name": "backoff", + "version": "2.2.1", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "charset-normalizer", + "version": "3.5.2", + "wheel_tags": [ + "cp310-cp310-manylinux_2_17_x86_64", + "cp310-cp310-manylinux2014_x86_64", + "cp310-cp310-manylinux_2_28_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "missing-download-at-release" + }, + { + "name": "coloredlogs", + "version": "15.0.1", + "wheel_tags": [ + "py2-none-any", + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "distro", + "version": "1.9.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "fastembed", + "version": "0.9.1", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "filelock", + "version": "4.1.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "flatbuffers", + "version": "25.12.19", + "wheel_tags": [ + "py2-none-any", + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "fsspec", + "version": "2026.9.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "greenlet", + "version": "3.5.6", + "wheel_tags": [ + "cp310-cp310-manylinux_2_24_x86_64", + "cp310-cp310-manylinux_2_28_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [ + { + "kind": "uv:wheels-v7", + "path": "/home/xiaomo/.cache/uv/wheels-v7/pypi/greenlet/3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64", + "name": "3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64", + "sha256": null + }, + { + "kind": "uv:wheels-v6", + "path": "/home/xiaomo/.cache/uv/wheels-v6/pypi/greenlet/3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64", + "name": "3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64", + "sha256": null + } + ], + "cache_hits_usable_for_311": [ + "3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64" + ], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "cache-reusable" + }, + { + "name": "grpcio", + "version": "1.84.1", + "wheel_tags": [ + "cp310-cp310-manylinux_2_17_x86_64", + "cp310-cp310-manylinux2014_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "missing-download-at-release" + }, + { + "name": "h2", + "version": "4.4.1", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "hf-xet", + "version": "1.7.0", + "wheel_tags": [ + "cp38-abi3-manylinux_2_17_x86_64", + "cp38-abi3-manylinux2014_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "missing-download-at-release" + }, + { + "name": "hpack", + "version": "4.2.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "httpcore2", + "version": "2.14.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "httpx2", + "version": "2.14.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "huggingface-hub", + "version": "1.33.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "humanfriendly", + "version": "10.0", + "wheel_tags": [ + "py2-none-any", + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "hyperframe", + "version": "6.1.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "jiter", + "version": "0.18.0", + "wheel_tags": [ + "cp310-cp310-manylinux_2_17_x86_64", + "cp310-cp310-manylinux2014_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "missing-download-at-release" + }, + { + "name": "loguru", + "version": "0.7.3", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "mem0ai", + "version": "2.2.1", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": "{\"dir_info\": {}, \"url\": \"file:///home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-src\"}", + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "fixed-source" + }, + { + "name": "mmh3", + "version": "5.3.1", + "wheel_tags": [ + "cp310-cp310-manylinux_2_5_x86_64", + "cp310-cp310-manylinux1_x86_64", + "cp310-cp310-manylinux_2_28_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "missing-download-at-release" + }, + { + "name": "mpmath", + "version": "1.3.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "numpy", + "version": "2.2.6", + "wheel_tags": [ + "cp310-cp310-manylinux_2_17_x86_64", + "cp310-cp310-manylinux2014_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "missing-download-at-release" + }, + { + "name": "onnxruntime", + "version": "1.23.2", + "wheel_tags": [ + "cp310-cp310-manylinux_2_27_x86_64", + "cp310-cp310-manylinux_2_28_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "missing-download-at-release" + }, + { + "name": "openai", + "version": "3.37.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "pillow", + "version": "12.3.0", + "wheel_tags": [ + "cp310-cp310-manylinux_2_27_x86_64", + "cp310-cp310-manylinux_2_28_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [ + { + "kind": "uv:wheels-v7", + "path": "/home/xiaomo/.cache/uv/wheels-v7/pypi/pillow/12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64", + "name": "12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64", + "sha256": null + }, + { + "kind": "uv:wheels-v6", + "path": "/home/xiaomo/.cache/uv/wheels-v6/pypi/pillow/12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64", + "name": "12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64", + "sha256": null + }, + { + "kind": "uv:wheels-v6", + "path": "/home/xiaomo/.cache/uv/wheels-v6/pypi/pillow/12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64", + "name": "12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64", + "sha256": null + } + ], + "cache_hits_usable_for_311": [ + "12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64" + ], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "cache-reusable" + }, + { + "name": "portalocker", + "version": "4.4.1", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "posthog", + "version": "7.75.11", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "protobuf", + "version": "6.33.6", + "wheel_tags": [ + "cp39-abi3-manylinux2014_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "missing-download-at-release" + }, + { + "name": "py-rust-stemmers", + "version": "0.1.8", + "wheel_tags": [ + "cp310-cp310-manylinux_2_28_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "missing-download-at-release" + }, + { + "name": "pytz", + "version": "2026.5", + "wheel_tags": [ + "py2-none-any", + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "qdrant-client", + "version": "1.20.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "requests", + "version": "2.34.2", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "sniffio", + "version": "1.3.1", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "sqlalchemy", + "version": "2.0.55", + "wheel_tags": [ + "cp310-cp310-manylinux_2_17_x86_64", + "cp310-cp310-manylinux2014_x86_64", + "cp310-cp310-manylinux_2_28_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "missing-download-at-release" + }, + { + "name": "sympy", + "version": "1.14.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "tokenizers", + "version": "0.23.3", + "wheel_tags": [ + "cp310-abi3-manylinux_2_17_x86_64", + "cp310-abi3-manylinux2014_x86_64" + ], + "native_extension": true, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages", + "classification": "missing-download-at-release" + }, + { + "name": "tqdm", + "version": "4.70.1", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + }, + { + "name": "truststore", + "version": "0.10.5", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [ + { + "kind": "uv:wheels-v7", + "path": "/home/xiaomo/.cache/uv/wheels-v7/pypi/truststore/0.10.5-py3-none-any", + "name": "0.10.5-py3-none-any", + "sha256": null + }, + { + "kind": "uv:wheels-v6", + "path": "/home/xiaomo/.cache/uv/wheels-v6/pypi/truststore/0.10.5-py3-none-any", + "name": "0.10.5-py3-none-any", + "sha256": null + } + ], + "cache_hits_usable_for_311": [ + "0.10.5-py3-none-any", + "0.10.5-py3-none-any" + ], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "cache-reusable" + }, + { + "name": "urllib3", + "version": "2.8.0", + "wheel_tags": [ + "py3-none-any" + ], + "native_extension": false, + "installed_sdk": true, + "present_product_311": false, + "product_version": null, + "direct_url": null, + "cache_hits": [], + "cache_hits_usable_for_311": [], + "resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用", + "classification": "missing-download-at-release" + } + ], + "mem0_identity": { + "direct_url_of_installed": "{\"dir_info\": {}, \"url\": \"file:///home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-src\"}", + "fixed_source_dir": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-src", + "fixed_source_dir_exists": true, + "pinned_tarball": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-data/mem0-2c81af3.tar.gz", + "pinned_tarball_sha256_measured": "9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23", + "pinned_tarball_sha256_lock": "9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23", + "tarball_matches_lock": true, + "index_wheel_2_2_1_proves_same_code": false, + "note": "索引下载的 mem0ai==2.2.1 wheel 不自动等于固定源码;发布必须用本 tarball/源码目录离线构建安装", + "built_wheel_from_hel857_install": { + "filename": "mem0ai-2.2.1-py3-none-any.whl", + "size": 352586, + "sha256": "698284b6083ab8b7761454d65876a6d1bb983d278ad21a7bee38d760cb20e9db", + "ephemeral_wheel_cache": "/tmp/multica-task-1816511421 (不再存在)", + "persisted_anywhere": false + }, + "offline_build_backend": { + "hatchling_cached_or_local": true, + "note": "离线构建 mem0 wheel 需要 hatchling 及其依赖可离线获得;未命中则发布卡需一并拉取" + } + }, + "fastembed_models": { + "cache_dir": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-data/fastembed-cache", + "exists": true, + "models": { + "models--Qdrant--bge-small-zh-v1.5": { + "files": 13, + "bytes": 190444780 + }, + "models--Qdrant--bm25": { + "files": 39, + "bytes": 56247 + } + }, + "default_cache_dir": "/home/xiaomo/.cache/fastembed", + "default_cache_exists": false, + "release_note": "真实 mem0 模式把 fastembed cache_dir 指向发布实例数据目录内的该缓存拷贝;本次零下载、不推理" + }, + "pip_2622_event": { + "claim_under_review": "HEL-895 seq48–51:uv pip install --offline pip 失败后去掉 --offline 安装 pip 26.2.2 成功;前卡日志仅存尾两行", + "installed_into": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-895-0261e950b9c2/workdir/CyberGril/.venv311/lib/python3.11/site-packages/pip-26.2.2.dist-info", + "installer": "uv", + "uv_cache_entry_created": "2026-10-27T02:10:04.960958", + "uv_http_cache_created": "2026-10-27T02:10:04.972959", + "recorded_source_url": "https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl", + "recorded_sha256": "fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70", + "other_new_cache_entries_in_window": [], + "verdict": "勘误成立:uv 缓存中 pip-26.2.2 wheel 条目创建于 2026-10-27 02:10:04 +0800(HEL-895 运行窗口内),来源 files.pythonhosted.org,且是该窗口唯一新增缓存条目——该步发生了网络获取,前卡『零下载』对此步不成立;requirements.lock 装机同期未产生任何新缓存条目(全部命中既有缓存)。字节级『完整下载 vs 条件请求』无法由缓存取证区分,按保守口径记为下载。" + } +} \ No newline at end of file diff --git a/docs/rework/hel879/sdk-audit/summary.md b/docs/rework/hel879/sdk-audit/summary.md new file mode 100644 index 0000000..1113110 --- /dev/null +++ b/docs/rework/hel879/sdk-audit/summary.md @@ -0,0 +1,9 @@ +# HEL-896 · 固定 SDK 离线余缺审计摘要(只读) + +- 闭包 55 项,产品 3.11 venv 缺 41 项:纯 Python 28 项、原生扩展 13 项(charset-normalizer, greenlet, grpcio, hf-xet, jiter, mmh3, numpy, onnxruntime, pillow, protobuf, py-rust-stemmers, sqlalchemy, tokenizers)。 +- 三个顶层包 WHEEL 均 py3-none-any(纯 Python,可跨 3.10/3.11)。 +- mem0ai 固定源码身份:tarball SHA256 一致(9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23);索引同名 wheel 不作数;hel857 构建过的 wheel(698284b6083a…)在临时缓存、未持久。 +- 当前缓存可直接复用的缺项 wheel:['greenlet', 'pillow', 'truststore']。 +- 其余缺项(纯 Python wheel 与 cp311 原生 wheel)由发布卡按 deploy/hel874-release.md §4 获授权后下载并 sha256 留证;缺项不阻塞其余发布工作。 +- fastembed 模型缓存实测:{"models--Qdrant--bge-small-zh-v1.5": {"files": 13, "bytes": 190444780}, "models--Qdrant--bm25": {"files": 39, "bytes": 56247}}(hel857-data/fastembed-cache,随发布实例落位,零下载)。 +- pip 26.2.2 事件:勘误成立:uv 缓存中 pip-26.2.2 wheel 条目创建于 2026-10-27 02:10:04 +0800(HEL-895 运行窗口内),来源 files.pythonhosted.org,且是该窗口唯一新增缓存条目——该步发生了网络获取,前卡『零下载』对此步不成立;requirements.lock 装机同期未产生任何新缓存条目(全部命中既有缓存)。字节级『完整下载 vs 条件请求』无法由缓存取证区分,按保守口径记为下载。 diff --git a/docs/rework/hel896/budget-split/budget-split.json b/docs/rework/hel896/budget-split/budget-split.json index ed82628..b6b5192 100644 --- a/docs/rework/hel896/budget-split/budget-split.json +++ b/docs/rework/hel896/budget-split/budget-split.json @@ -1,5 +1,5 @@ { - "head": "04eff611e7d85e539d43717711172441420ac853", + "head": "c121c42e39174e0f627f129e7f7e5700f33acf26", "no_provider_calls": true, "temp_only": true, "scenes": { @@ -59,6 +59,20 @@ "after_restart": "budget_total_requests_exhausted:55/55", "archive_after_backup_api": "budget_total_requests_exhausted:55/55" }, + "S6_early_seal": { + "plan_total": 51, + "used_at_seal": 51, + "marker": { + "requests": 51, + "tokens": 510, + "sealed_at": 1793042553 + }, + "next_after_seal": "budget_sealed:51/55(封账于 1793042553,真实用量保留,不伪造、不清零)", + "other_scene_after_seal": "budget_sealed:51/55(封账于 1793042553,真实用量保留,不伪造、不清零)", + "after_restart": "budget_sealed:51/55(封账于 1793042553,真实用量保留,不伪造、不清零)", + "after_archive_restore": "budget_sealed:51/55(封账于 1793042553,真实用量保留,不伪造、不清零)", + "idempotent_reseal": true + }, "S3_user_instance": { "B_after_A": "allowed", "C_after_A": "allowed", @@ -103,6 +117,16 @@ "ok": true, "detail": "restart=budget_total_requests_exhausted:55/55 archive=budget_total_requests_exhausted:55/55 used=55" }, + { + "name": "S6a_early_finish_51_seals_real_usage_no_fake_no_zero", + "ok": true, + "detail": "used=51 dialogue_next=budget_sealed:51/55(封账于 1793042553,真实用量保留,不伪造、不清零) semantic_next=budget_sealed:51/55(封账于 1793042553,真实用量保留,不伪造、不清零) after=51 dialogue_daily=19/60" + }, + { + "name": "S6b_seal_survives_restart_and_archive_restore", + "ok": true, + "detail": "restart=budget_sealed:51/55(封账于 1793042553,真实用量保留,不伪造、不清零) archive=budget_sealed:51/55(封账于 1793042553,真实用量保留,不伪造、不清零) arch_used=51 marker={'requests': 51, 'tokens': 510, 'sealed_at': 1793042553}" + }, { "name": "S3_user_instance_B_and_C_unblocked_after_A", "ok": true, diff --git a/src/cybergril/life/model.py b/src/cybergril/life/model.py index 374c120..d58535a 100644 --- a/src/cybergril/life/model.py +++ b/src/cybergril/life/model.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import json import logging +import time from contextlib import aclosing from ..contracts import LLMMessage @@ -61,6 +62,50 @@ class PersistentBudget(BudgetGuard): ) return row["v"] if row else 0 + # ------------------------------------------------------------ 封账(HEL-879) + + def sealed(self): + """封账标记(None=未封)。体验链提前走完时的可执行关闭,跨重启/归档不失效。""" + row = self.store.query_one("SELECT value v FROM budget_total WHERE key='sealed'") + if not row or row["v"] != 1: + return None + def _val(key, default=0): + r = self.store.query_one(f"SELECT value v FROM budget_total WHERE key='{key}'") + return r["v"] if r else default + return {"requests": _val("sealed_requests"), "tokens": _val("sealed_tokens"), + "sealed_at": _val("sealed_at")} + + def seal(self): + """显式封账:把当前真实用量连同时间戳原子写入账本,此后一切 acquire 拒绝。 + + HEL-879:A(团队测试实例)在额度未耗尽(如 51/55)时体验链走完,没有可执行 + 的封账就会在重启/归档恢复后放行第 52 次。封账标记与用量同库持久——重启、 + backup API 归档、快照恢复后同样生效;真实用量保持 51,不伪造 55、不清零。 + 幂等:已封账时保留原标记(含原时间戳),不覆盖。解除封账没有代码路径: + 复检 = 新批准 + 新实例/新库(budget-package §6.2.4)。 + """ + self._roll() + with self.store.conn: + self.store.execute("BEGIN IMMEDIATE") + existing = self.sealed() + if existing is not None: + return existing + marker = {"requests": self.total_requests_used(), + "tokens": self.total_tokens_used(), + "sealed_at": int(time.time())} + self.store.execute("INSERT INTO budget_total VALUES('sealed',1) " + "ON CONFLICT(key) DO UPDATE SET value=1") + self.store.execute("INSERT INTO budget_total VALUES('sealed_requests',?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (marker["requests"],)) + self.store.execute("INSERT INTO budget_total VALUES('sealed_tokens',?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (marker["tokens"],)) + self.store.execute("INSERT INTO budget_total VALUES('sealed_at',?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (marker["sealed_at"],)) + return marker + def reserve(self, tokens, scene): """Legacy background-scene token reservation (95% stop), kept for compatibility.""" self._roll() @@ -85,6 +130,15 @@ class PersistentBudget(BudgetGuard): day = self.state.day with self.store.conn: self.store.execute("BEGIN IMMEDIATE") + # 封账最优先(HEL-879):已显式封账后,任何类别/任何余量都拒绝—— + # 提前收尾(如 51/55)与误重启/归档恢复一样不放行。 + sealed = self.sealed() + if sealed is not None: + cap = self.config.total_requests + suffix = f"/{cap}" if cap is not None else "" + raise BudgetStop( + f"budget_sealed:{sealed['requests']}{suffix}(封账于 " + f"{sealed['sealed_at']},真实用量保留,不伪造、不清零)") row = self.store.query_one( "SELECT coalesce(sum(used),0) n FROM token_usage WHERE day=?", (day,) ) diff --git a/tools/audit_sdk_offline_hel896.py b/tools/audit_sdk_offline_hel896.py index 553e99a..c028898 100644 --- a/tools/audit_sdk_offline_hel896.py +++ b/tools/audit_sdk_offline_hel896.py @@ -34,6 +34,7 @@ import hashlib import importlib.metadata as md import json import re +import sys from datetime import datetime from pathlib import Path @@ -85,7 +86,15 @@ def cache_hits(name: str, version: str) -> list[dict]: def main() -> int: - out_dir = REPO / "docs/rework/hel896/sdk-audit" + import subprocess as _sp + try: + head = _sp.run(["git", "rev-parse", "HEAD"], cwd=REPO, capture_output=True, + text=True, check=True).stdout.strip() + except Exception: + head = "unknown" + # HEL-879:head 动态取当前提交,不再硬编码旧父提交冒充最新 head; + # 可选参数改写证据目录(默认保持 HEL-896 历史目录不动,本卡证据写 hel879/)。 + out_dir = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else REPO / "docs/rework/hel896/sdk-audit" out_dir.mkdir(parents=True, exist_ok=True) installed = {key(d.metadata["Name"]): d for d in md.distributions(path=[str(SDK)])} base = {key(d.metadata["Name"]): d for d in md.distributions(path=[str(PROD)])} @@ -215,7 +224,7 @@ def main() -> int: top = {n: rows.get(n) for n in ("mem0ai", "qdrant-client", "fastembed")} result = { - "head": "04eff611e7d85e539d43717711172441420ac853", + "head": head, "scope": ("只读审计:3.10 固定栈安装元数据按 3.11 Linux markers+extras 求闭包,对照产品 3.11 venv 与本机 uv/pip 缓存;" "这是依赖闭包清单,不是已完成的 cp311 解析或下载结果"), "read_only": True, "zero_downloads_this_card": True, diff --git a/tools/release_snapshot_hel896.py b/tools/release_snapshot_hel896.py index d1c8010..08faa99 100644 --- a/tools/release_snapshot_hel896.py +++ b/tools/release_snapshot_hel896.py @@ -1,24 +1,40 @@ #!/usr/bin/env python3 -"""HEL-896 · 发布归档一致性快照与回退证据(修复 WAL 丢失;不冒充 R6 完整备份)。 +"""HEL-879 · 发布归档一致性快照与回退证据(HEL-896 有限收口版;不冒充 R6 完整备份)。 -总管独立复审(HEL-896 附件 §4)复现:在线库只复制主 `.db` 会丢 WAL 里已提交的资料 -(复现件连表都不在;SQLite backup API 则完整)。本工具给出发布卡可直接照抄的归档方式: +HEL-896 工具的总管独立复审发现四个失败边界,本版全部收口: - 停服务 + `PRAGMA wal_checkpoint(TRUNCATE)` 后整目录归档(离线口径,推荐);或 - 在线用 SQLite backup API 逐库做一致性快照(在线兜底口径)。 +1. checkpoint 返回值必须核对:`PRAGMA wal_checkpoint(TRUNCATE)` 返回 (busy, log, + checkpointed),busy≠0 即未完成——旧版照抄复制并在 manifest 记 rows_match=False + 仍打包成功。本版 busy≠0 / 源表行数≠快照行数一律**明确失败**:CLI 非 0、不产生 + 可交付归档(tar 不生成,半成品目录清除)。「先停服」是操作前提,不是忽略实际 + 失败结果的理由。 +2. 目录内真实 SQLite 走一致性路径:history_root/vector_root 等数据目录里的 + `mem0_public_history.db`、qdrant 存储等 SQLite 文件按文件头魔数递归识别 + (`semantic.py` 的 history_db_path 就在 history_root 内)。停服口径对它们逐个 + checkpoint+复制+逐表核对;**在线口径只允许显式单库 --db(backup API),传入含 + SQLite 的目录直接拒绝**——在线目录裸复制正是丢 WAL 的不安全口径。 +3. verify 拒绝 rows_match=False 的旧包,并按源路径映射做回退演练:解包到隔离新 + 目录、实际打开每个库核对行数与哈希(对照 manifest 记录的源行数与快照行数, + 两者必须相等且与解包实测一致),非只打开 tar 中快照自证;输出 rollback_plan + (源路径 → 解包恢复路径)。 +4. 完整实例归档只支持停服/释放全部写入方;在线模式仅显式单库 backup API。 -产物:`//` 下的 `databases/`(快照库)、`files/`(其余数据目录原样)、 -`MANIFEST.json`(每个文件的 SHA256、源路径、代码精确提交、逐表行数核对结果)、 -`.tar.gz`。`--verify` 对既有归档重算哈希并重开快照库核对行数,作为回退演练。 +产物:`//` 下的 `databases/`(一致性快照库,含目录内发现的 SQLite)、 +`files/`(其余数据文件原样)、`MANIFEST.json`(逐文件 SHA256、源路径、代码精确 +提交、逐表行数核对结果)、`.tar.gz`。归档永不删除;本工具不实现 R6。 -自测(`--selftest`,临时目录,零产品数据)复现两种口径的差异并证明修复,输出 -`docs/rework/hel896/release-snapshot/selftest.json`。 +自测(`--selftest`,临时目录,零产品数据)复现:旧版失败形状(busy checkpoint 仍 +打包、rows_match=False 旧包 verify 仍 ok、在线目录含 SQLite)与本版修复,输出 +`docs/rework/hel879/release-snapshot/selftest.json`。 用法: python3 tools/release_snapshot_hel896.py --selftest [outDir] python3 tools/release_snapshot_hel896.py snapshot --name hel8xx-release \ --db path/a.db --db path/b.sqlite3 --file path/vecdir --file path/hist \ - --out archives/ [--online] + --out archives/ # 停服口径(推荐,目录内 SQLite 一并一致性处理) + python3 tools/release_snapshot_hel896.py snapshot --name ... --db path/a.db \ + --out archives/ --online # 在线口径:仅显式单库 backup API,含 SQLite 的 + # --file 目录会被拒绝 python3 tools/release_snapshot_hel896.py verify --archive archives/hel8xx-release.tar.gz """ @@ -36,6 +52,12 @@ import time from pathlib import Path REPO = Path(__file__).resolve().parents[1] +SQLITE_MAGIC = b"SQLite format 3\x00" +TOOL_VERSION = 2 + + +class SnapshotError(RuntimeError): + """快照/验证明确失败:CLI 非 0,不形成可交付成功包。""" def sha256(path: Path) -> str: @@ -46,6 +68,26 @@ def sha256(path: Path) -> str: return h.hexdigest() +def is_sqlite(path: Path) -> bool: + """按文件头魔数识别真实 SQLite 库(覆盖 .db/.sqlite3/无后缀;qdrant 本地存储)。""" + try: + with open(path, "rb") as f: + return f.read(16) == SQLITE_MAGIC + except OSError: + return False + + +def find_sqlite(root: Path) -> list[Path]: + """递归找出目录内全部真实 SQLite 文件(按魔数,不按扩展名)。""" + if root.is_file(): + return [root] if is_sqlite(root) else [] + found: list[Path] = [] + for p in sorted(root.rglob("*")): + if p.is_file() and is_sqlite(p): + found.append(p) + return found + + def table_counts(db: Path) -> dict[str, int]: con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) try: @@ -56,18 +98,25 @@ def table_counts(db: Path) -> dict[str, int]: con.close() -def checkpoint(db: Path) -> None: +def checkpoint(db: Path) -> tuple[int, int, int]: + """TRUNCATE checkpoint,返回 (busy, log, checkpointed)。busy≠0 = 未完成。""" con = sqlite3.connect(str(db)) try: - con.execute("PRAGMA wal_checkpoint(TRUNCATE)") + row = con.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() + return (row[0], row[1], row[2]) if row else (1, -1, -1) finally: con.close() def snapshot_db(src: Path, dst: Path, *, online: bool) -> str: - """一致性快照单库。online=False 先 checkpoint(停服口径);True 用 backup API。""" + """一致性快照单库。online=False 先 checkpoint(必须完成);True 用 backup API。""" if not online: - checkpoint(src) + busy, log, ckpt = checkpoint(src) + if busy != 0: + raise SnapshotError( + f"checkpoint 未完成(busy={busy}, log={log}, checkpointed={ckpt}):" + f"{src} 仍有读取/写入方持有 WAL。停服口径要求先停止全部相关写入方," + f"不得忽略该失败继续打包。") shutil.copy2(src, dst) return "stop+checkpoint+copy" with sqlite3.connect(str(src)) as source, sqlite3.connect(str(dst)) as target: @@ -84,61 +133,135 @@ def git_head() -> str: def make_snapshot(name: str, dbs: list[Path], files: list[Path], out_dir: Path, *, online: bool) -> dict: + """做一致性快照。任一环节失败:不产生 tar、清除半成品、抛 SnapshotError。 + + 收口语义(HEL-879): + - 全部 --db 与目录内发现的 SQLite 都走一致性路径并逐表核对源/快照行数; + - 行数不一致或 checkpoint busy = 明确失败(旧版记 rows_match=False 仍打包); + - 在线模式含 SQLite 的 --file 目录直接拒绝(在线目录裸复制丢 WAL)。 + """ stamp = time.strftime("%Y%m%d-%H%M%S") root = out_dir / f"{name}-{stamp}" - (root / "databases").mkdir(parents=True, exist_ok=True) - manifest: dict = {"name": name, "created": stamp, "code_commit": git_head(), "tool": "release_snapshot_hel896.py", + manifest: dict = {"name": name, "created": stamp, "code_commit": git_head(), + "tool": "release_snapshot_hel896.py", "tool_version": TOOL_VERSION, "mode": "online-backup-api" if online else "stop-checkpoint-copy", "databases": [], "files": [], "verify": "not-run"} - for db in dbs: - dst = root / "databases" / db.name - method = snapshot_db(db, dst, online=online) - src_counts = table_counts(db) - dst_counts = table_counts(dst) - ok = src_counts == dst_counts - manifest["databases"].append({"source": str(db), "snapshot": str(dst.relative_to(root)), - "sha256": sha256(dst), "method": method, - "source_table_counts": src_counts, "snapshot_table_counts": dst_counts, - "rows_match": ok}) - for f in files: - src = Path(f) - dst = root / "files" / src.name - if src.is_dir(): - shutil.copytree(src, dst) - entries = {str(p.relative_to(dst)): sha256(p) for p in sorted(dst.rglob("*")) if p.is_file()} - else: - shutil.copy2(src, dst) - entries = {src.name: sha256(dst)} - manifest["files"].append({"source": str(src), "snapshot": str(dst.relative_to(root)), "entries": entries}) - manifest["verify"] = "rows_match=" + str(all(d["rows_match"] for d in manifest["databases"])) - (root / "MANIFEST.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") - tar = out_dir / f"{name}-{stamp}.tar.gz" - with tarfile.open(tar, "w:gz") as tf: - tf.add(root, arcname=root.name) - manifest["archive"] = str(tar) - manifest["archive_sha256"] = sha256(tar) - return manifest + try: + (root / "databases").mkdir(parents=True, exist_ok=True) + for db in dbs: + if not Path(db).is_file(): + raise SnapshotError(f"--db 不是文件:{db}") + dst = root / "databases" / db.name + method = snapshot_db(Path(db), dst, online=online) + src_counts = table_counts(Path(db)) + dst_counts = table_counts(dst) + if src_counts != dst_counts: + raise SnapshotError( + f"源/快照行数不一致,不打包:{db} source={src_counts} snapshot={dst_counts}") + manifest["databases"].append({"source": str(db), "snapshot": str(dst.relative_to(root)), + "sha256": sha256(dst), "method": method, + "source_table_counts": src_counts, + "snapshot_table_counts": dst_counts, + "rows_match": True}) + for f in files: + src = Path(f) + if not src.exists(): + raise SnapshotError(f"--file 不存在:{src}") + embedded = find_sqlite(src) + if online and embedded: + raise SnapshotError( + f"在线模式只允许显式单库 backup API(--db);--file {src} 内含 SQLite:" + f"{[str(e) for e in embedded]}。含活动库的目录在线裸复制会丢 WAL," + f"拒绝。完整实例归档请用停服口径(默认模式)。") + for emb in embedded: + # 停服口径:目录内真实 SQLite 一并走 checkpoint+复制+逐表核对。 + rel = emb.relative_to(src) if emb.is_relative_to(src) else Path(emb.name) + dst = root / "databases" / f"{src.name}__{rel}".replace("/", "__") + method = snapshot_db(emb, dst, online=False) + src_counts = table_counts(emb) + dst_counts = table_counts(dst) + if src_counts != dst_counts: + raise SnapshotError( + f"目录内 SQLite 源/快照行数不一致,不打包:{emb} " + f"source={src_counts} snapshot={dst_counts}") + manifest["databases"].append({"source": str(emb), "snapshot": str(dst.relative_to(root)), + "sha256": sha256(dst), "method": method + "+dir-scan", + "source_table_counts": src_counts, + "snapshot_table_counts": dst_counts, + "rows_match": True}) + dst = root / "files" / src.name + if src.is_dir(): + shutil.copytree(src, dst) + entries = {str(p.relative_to(dst)): sha256(p) for p in sorted(dst.rglob("*")) if p.is_file()} + else: + shutil.copy2(src, dst) + entries = {src.name: sha256(dst)} + manifest["files"].append({"source": str(src), "snapshot": str(dst.relative_to(root)), + "entries": entries}) + if not manifest["databases"]: + raise SnapshotError("没有任何数据库进入一致性路径(--db 为空)") + manifest["verify"] = "rows_match=True (all, enforced; mismatch aborts packaging)" + (root / "MANIFEST.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + tar = out_dir / f"{name}-{stamp}.tar.gz" + with tarfile.open(tar, "w:gz") as tf: + tf.add(root, arcname=root.name) + manifest["archive"] = str(tar) + manifest["archive_sha256"] = sha256(tar) + return manifest + except Exception: + # 失败不形成可交付包:清除半成品目录与可能已生成的 tar。 + shutil.rmtree(root, ignore_errors=True) + for partial in out_dir.glob(f"{name}-{stamp}.tar.gz"): + partial.unlink(missing_ok=True) + raise def verify_archive(archive: Path) -> dict: + """回退演练:隔离新目录解包,实际打开各库核对行数与哈希,按源路径映射。 + + 收口语义(HEL-879): + - manifest 记录 rows_match=False(旧版失败包)→ ok=False 拒绝; + - 行数同时对照 source_table_counts 与 snapshot_table_counts:两者必须相等, + 且与解包实测一致(旧版只比快照自证,源=1 行/快照=0 行的漏行包仍 ok)。 + """ result: dict = {"archive": str(archive), "sha256": sha256(archive), "checks": []} - with tempfile.TemporaryDirectory(prefix="hel896-verify-") as tmp: + with tempfile.TemporaryDirectory(prefix="hel879-verify-") as tmp: with tarfile.open(archive) as tf: tf.extractall(tmp) root = next(p for p in Path(tmp).iterdir() if p.is_dir()) manifest = json.loads((root / "MANIFEST.json").read_text(encoding="utf-8")) result["code_commit"] = manifest.get("code_commit") result["mode"] = manifest.get("mode") + result["tool_version"] = manifest.get("tool_version", 1) ok_all = True + rollback_plan = [] for d in manifest["databases"]: snap = root / d["snapshot"] digest = sha256(snap) - counts = table_counts(snap) - ok = digest == d["sha256"] and counts == d.get("snapshot_table_counts") + try: + counts = table_counts(snap) + except sqlite3.Error as exc: + result["checks"].append({"db": d["snapshot"], "sha256_ok": digest == d["sha256"], + "rows_ok": False, "error": str(exc), + "rows_match_recorded": d.get("rows_match")}) + ok_all = False + continue + recorded_rows_match = d.get("rows_match") + src_counts = d.get("source_table_counts") + snap_counts = d.get("snapshot_table_counts") + rows_ok = counts == snap_counts and ( + recorded_rows_match is True or (src_counts is not None and counts == src_counts)) + legacy_reject = recorded_rows_match is False or ( + recorded_rows_match is None and src_counts != snap_counts) + ok = digest == d["sha256"] and rows_ok and not legacy_reject ok_all &= ok result["checks"].append({"db": d["snapshot"], "sha256_ok": digest == d["sha256"], - "rows_ok": counts == d.get("snapshot_table_counts"), - "counts": counts}) + "rows_ok": rows_ok, "counts": counts, + "source_counts": src_counts, + "rows_match_recorded": recorded_rows_match, + "rejected_legacy_package": legacy_reject}) + rollback_plan.append({"source": d.get("source"), "restored_path": str(snap), + "restore_action": "停服后复制回源路径(行数与哈希已在本演练核对)"}) for f in manifest["files"]: base = root / f["snapshot"] for rel, want in f["entries"].items(): @@ -146,26 +269,36 @@ def verify_archive(archive: Path) -> dict: ok = sha256(p) == want ok_all &= ok result["checks"].append({"file": str(p.relative_to(root)), "sha256_ok": ok}) + rollback_plan.append({"source": f.get("source"), "restored_path": str(base), + "restore_action": "停服后按相对结构复制回源目录"}) + result["rollback_plan"] = rollback_plan result["ok"] = ok_all return result +def _make_wal_db(path: Path) -> sqlite3.Connection: + path.parent.mkdir(parents=True, exist_ok=True) + con = sqlite3.connect(str(path)) + con.execute("PRAGMA journal_mode=WAL") + con.execute("CREATE TABLE companion(id INTEGER PRIMARY KEY, payload TEXT)") + con.execute("CREATE TABLE chat_message(id INTEGER PRIMARY KEY, text TEXT)") + con.execute("CREATE TABLE events(id INTEGER PRIMARY KEY, kind TEXT)") + con.commit() + con.execute("INSERT INTO companion VALUES(1,'her')") + con.execute("INSERT INTO chat_message VALUES(1,'hello')") + con.commit() + return con + + def selftest(out_dir: Path) -> int: - """临时库复现:在线裸复制主 .db 丢 WAL 已提交行;checkpoint 与 backup API 都完整。""" + """临时库复现全部失败边界并证明修复(零产品数据、零网络)。""" checks: list[dict] = [] - with tempfile.TemporaryDirectory(prefix="hel896-snapshot-selftest-") as tmp: + with tempfile.TemporaryDirectory(prefix="hel879-snapshot-selftest-") as tmp: tmp = Path(tmp) db = tmp / "instance" / "companion.db" - db.parent.mkdir(parents=True) - con = sqlite3.connect(str(db)) - con.execute("PRAGMA journal_mode=WAL") - con.execute("CREATE TABLE companion(id INTEGER PRIMARY KEY, payload TEXT)") - con.execute("CREATE TABLE chat_message(id INTEGER PRIMARY KEY, text TEXT)") - con.commit() - con.execute("INSERT INTO companion VALUES(1,'her')") - con.execute("INSERT INTO chat_message VALUES(1,'hello')") - con.commit() - # WAL 保持持有(连接不关、不 checkpoint):已提交行仍在 WAL,不在主 .db。 + con = _make_wal_db(db) + + # ── 旧失败形状 1 复现:WAL 持有(读者未放)时 naive 复制丢已提交行。 ── naive = tmp / "naive-copy.db" shutil.copy2(db, naive) try: @@ -174,37 +307,180 @@ def selftest(out_dir: Path) -> int: naive_result = f"rows={row}" except sqlite3.OperationalError as exc: naive_result = f"error={exc}" - checks.append({"name": "naive_online_copy_loses_wal", "ok": "rows=1" not in naive_result, - "detail": naive_result}) - # backup API(在线口径) - via_api = tmp / "via-backup-api.db" - with sqlite3.connect(str(db)) as source, sqlite3.connect(str(via_api)) as target: + checks.append({"name": "naive_online_copy_loses_wal_reproduced", + "ok": "rows=1" not in naive_result, "detail": naive_result}) + + # ── 旧失败形状 2 复现(总管 edges 场景):读者先持读事务,随后写入方提交 + # events=1;TRUNCATE checkpoint 返回 busy=[1,1,0],naive 快照丢该行, + # 旧工具此时仍打包成功。 ── + reader = sqlite3.connect(str(db)) + reader.execute("BEGIN") + reader.execute("SELECT count(*) FROM companion").fetchone() + con.execute("INSERT INTO events VALUES(1,'reach_out')") + con.commit() + busy_state = checkpoint(db) + naive2 = tmp / "naive2.db" + shutil.copy2(db, naive2) + try: + naive2_events = sqlite3.connect(f"file:{naive2}?mode=ro", uri=True).execute( + "SELECT count(*) FROM events").fetchone()[0] + except sqlite3.Error as exc: + naive2_events = f"error={type(exc).__name__}:{exc}" + checks.append({"name": "busy_checkpoint_state_reproduced", + "ok": busy_state[0] != 0 and naive2_events != 1, + "detail": f"checkpoint={busy_state} naive_snapshot_events={naive2_events}"}) + + # ── 修复 1:busy checkpoint 必须明确失败,不形成归档。 ── + out_busy = tmp / "archives-busy" + out_busy.mkdir() + failed_busy = None + try: + make_snapshot("busy-must-fail", [db], [], out_busy, online=False) + except SnapshotError as exc: + failed_busy = str(exc) + leftovers = list(out_busy.iterdir()) + checks.append({"name": "fix1_busy_checkpoint_fails_no_archive", + "ok": failed_busy is not None and "busy" in failed_busy and not leftovers, + "detail": f"error={'yes' if failed_busy else 'none'} leftovers={len(leftovers)}"}) + + reader.rollback() + reader.close() + + # ── 修复 1b:源/快照行数不一致(快照停在某刻、源继续前进的形状)必须 + # 明确失败、不形成归档。用停当快照替换法直接触发比对分支。 ── + out_mis = tmp / "archives-mismatch" + out_mis.mkdir() + stale = out_mis / "stale.db" + with sqlite3.connect(str(db)) as source, sqlite3.connect(str(stale)) as target: source.backup(target) - api_counts = table_counts(via_api) - checks.append({"name": "backup_api_snapshot_complete", "ok": api_counts == {"companion": 1, "chat_message": 1}, - "detail": json.dumps(api_counts)}) - # 停服 + checkpoint(离线口径) + con.execute("INSERT INTO events VALUES(2,'after')") + con.commit() + real_snapshot_db = snapshot_db + _self = globals() + + def _stale_snapshot(src: Path, dst: Path, *, online: bool) -> str: + shutil.copy2(stale, dst) + return "stop+checkpoint+copy(stale-standin)" + + _self["snapshot_db"] = _stale_snapshot + failed_mis = None + try: + make_snapshot("mismatch-must-fail", [db], [], out_mis, online=False) + except SnapshotError as exc: + failed_mis = str(exc) + finally: + _self["snapshot_db"] = real_snapshot_db + mis_leftovers = list(out_mis.glob("mismatch-must-fail*")) + checks.append({"name": "fix1b_row_mismatch_fails_no_archive", + "ok": failed_mis is not None and "不一致" in failed_mis + and not [p for p in mis_leftovers if p.suffix == ".gz"], + "detail": f"error={failed_mis[:60] if failed_mis else 'none'} " + f"artifacts={[p.name for p in mis_leftovers]}"}) + con.execute("DELETE FROM events WHERE id=2") + con.commit() + + # ── 旧失败形状 3 复现:手工构造 rows_match=False 的旧包,旧版 verify 仍 ok。 ── + legacy_root = tmp / "legacy-pkg" + legacy_root.mkdir() + (legacy_root / "databases").mkdir() + legacy_snap = legacy_root / "databases" / "companion.db" + with sqlite3.connect(str(db)) as source, sqlite3.connect(str(legacy_snap)) as target: + source.backup(target) + # 漏行形状:快照内删掉 events 行,manifest 记 rows_match=False(旧版真实输出)。 + with sqlite3.connect(str(legacy_snap)) as lk: + lk.execute("DELETE FROM events") + legacy_manifest = { + "name": "legacy", "code_commit": "legacy-shape", "tool": "release_snapshot_hel896.py", + "tool_version": 1, "mode": "stop-checkpoint-copy", + "databases": [{"source": str(db), "snapshot": "databases/companion.db", + "sha256": sha256(legacy_snap), "method": "stop+checkpoint+copy", + "source_table_counts": {"companion": 1, "chat_message": 1, "events": 1}, + "snapshot_table_counts": {"companion": 1, "chat_message": 1, "events": 0}, + "rows_match": False}], + "files": [], "verify": "rows_match=False", + } + (legacy_root / "MANIFEST.json").write_text( + json.dumps(legacy_manifest, ensure_ascii=False), encoding="utf-8") + legacy_tar = tmp / "legacy-pkg.tar.gz" + with tarfile.open(legacy_tar, "w:gz") as tf: + tf.add(legacy_root, arcname=legacy_root.name) + legacy_verify = verify_archive(legacy_tar) + checks.append({"name": "fix_verify_rejects_rows_match_false_legacy_package", + "ok": legacy_verify["ok"] is False, + "detail": f"ok={legacy_verify['ok']} checks={legacy_verify['checks'][:1]}"}) + + # ── 修复 2:目录内 SQLite(history_root 形状)。 ── + hist = tmp / "instance" / "history_root" + hist.mkdir(parents=True, exist_ok=True) + mem_hist = hist / "mem0_public_history.db" + mem_con = sqlite3.connect(str(mem_hist)) + mem_con.execute("PRAGMA journal_mode=WAL") + mem_con.execute("CREATE TABLE history(id INTEGER PRIMARY KEY, payload TEXT)") + mem_con.execute("INSERT INTO history VALUES(1,'mem')") + mem_con.commit() + mem_reader = sqlite3.connect(str(mem_hist)) + mem_reader.execute("BEGIN") + mem_reader.execute("SELECT count(*) FROM history").fetchone() + # 在线模式 + 含 SQLite 的目录 → 拒绝。 + out_online = tmp / "archives-online" + out_online.mkdir() + refused = None + try: + make_snapshot("online-dir-refused", [db], [hist], out_online, online=True) + except SnapshotError as exc: + refused = str(exc) + checks.append({"name": "fix2_online_mode_refuses_dir_with_sqlite", + "ok": refused is not None and "拒绝" in refused and not list(out_online.iterdir()), + "detail": refused[:120] if refused else "not refused"}) + mem_reader.rollback() + mem_reader.close() + # 停服口径:目录内 SQLite 走 checkpoint+复制+逐表核对(mem_reader 已放, + # 但 mem_con 仍持有 WAL——checkpoint 必须真实完成才算过)。 + mem_con.close() + + # 主库也关闭,进入停服状态。 con.close() - checkpoint(db) - via_ckpt = tmp / "via-checkpoint.db" - shutil.copy2(db, via_ckpt) - ckpt_counts = table_counts(via_ckpt) - checks.append({"name": "stop_checkpoint_copy_complete", "ok": ckpt_counts == {"companion": 1, "chat_message": 1}, - "detail": json.dumps(ckpt_counts)}) - # 完整归档 + 回退验证 - sem_dir = tmp / "instance" / "vec" - sem_dir.mkdir(exist_ok=True) - (sem_dir / "vectors.bin").write_bytes(b"\x00\x01\x02cg") + + vec = tmp / "instance" / "vec" + vec.mkdir(exist_ok=True) + (vec / "vectors.bin").write_bytes(b"\x00\x01\x02cg") out = tmp / "archives" out.mkdir() - manifest = make_snapshot("hel896-selftest", [db], [sem_dir], out, online=False) + manifest = make_snapshot("hel879-selftest", [db], [vec, hist], out, online=False) + # 从解包侧核对 manifest(目录内 SQLite 是否真走了逐表核对)。 + with tarfile.open(manifest["archive"]) as tf: + tf.extractall(out / "unpack") + unpack_root = out / "unpack" / Path(manifest["archive"]).name.replace(".tar.gz", "") + unpack_manifest = json.loads((unpack_root / "MANIFEST.json").read_text(encoding="utf-8")) + embedded_dbs = [d for d in unpack_manifest["databases"] if "+dir-scan" in d["method"]] + hist_ok = any("mem0_public_history.db" in d["source"] and d["rows_match"] is True + and d["source_table_counts"].get("history") == 1 for d in embedded_dbs) + checks.append({"name": "fix2_stop_mode_embeds_sqlite_via_consistency_path", + "ok": bool(embedded_dbs) and hist_ok, + "detail": f"embedded={[d['source'] for d in embedded_dbs]}"}) + + # ── 修复 3:回退演练按源路径映射、实测行数与哈希、旧包拒绝已验。 ── verify = verify_archive(Path(manifest["archive"])) - checks.append({"name": "archive_manifest_hashes_and_rows_verify", "ok": verify["ok"], - "detail": f"commit={verify.get('code_commit')} checks={len(verify['checks'])}"}) + plan_sources = {entry.get("source") for entry in verify.get("rollback_plan", [])} + mapped = str(db) in plan_sources and any( + "mem0_public_history.db" in (s or "") for s in plan_sources) + checks.append({"name": "fix3_verify_maps_source_paths_and_passes", + "ok": verify["ok"] and mapped, + "detail": f"ok={verify['ok']} commit={verify.get('code_commit')} " + f"plan_entries={len(verify.get('rollback_plan', []))}"}) + + # backup API(在线口径,仅显式单库)依旧完整。 + with sqlite3.connect(str(db)) as source, sqlite3.connect(str(tmp / "via-api.db")) as target: + source.backup(target) + api_counts = table_counts(tmp / "via-api.db") + checks.append({"name": "backup_api_snapshot_complete", + "ok": api_counts.get("companion") == 1 and api_counts.get("chat_message") == 1, + "detail": json.dumps(api_counts)}) ok = all(c["ok"] for c in checks) out_dir.mkdir(parents=True, exist_ok=True) (out_dir / "selftest.json").write_text( - json.dumps({"ok": ok, "code_commit": git_head(), "checks": checks}, ensure_ascii=False, indent=2), + json.dumps({"ok": ok, "code_commit": git_head(), + "tool_version": TOOL_VERSION, "checks": checks}, ensure_ascii=False, indent=2), encoding="utf-8") for c in checks: print(f"{'PASS' if c['ok'] else 'FAIL'} {c['name']} — {c['detail']}") @@ -220,15 +496,23 @@ def main() -> int: ap.add_argument("--db", action="append", default=[], type=Path) ap.add_argument("--file", action="append", default=[], type=Path) ap.add_argument("--out", type=Path, default=REPO / "deploy" / "archives") - ap.add_argument("--online", action="store_true", help="在线口径:backup API(默认停服+checkpoint)") + ap.add_argument("--online", action="store_true", + help="在线口径:仅显式单库 backup API(默认停服+checkpoint;含 SQLite 的 --file 目录会被拒绝)") ap.add_argument("--archive", type=Path) args = ap.parse_args() if args.selftest: - return selftest(REPO / "docs/rework/hel896/release-snapshot") + return selftest(REPO / "docs/rework/hel879/release-snapshot") if args.command == "snapshot": if not args.name or not args.db: ap.error("snapshot 需要 --name 与至少一个 --db") - manifest = make_snapshot(args.name, args.db, args.file, args.out, online=args.online) + out_dir = args.out + out_dir.mkdir(parents=True, exist_ok=True) + try: + manifest = make_snapshot(args.name, args.db, args.file, out_dir, online=args.online) + except SnapshotError as exc: + print(json.dumps({"ok": False, "error": str(exc), + "deliverable_archive": None}, ensure_ascii=False, indent=1)) + return 1 print(json.dumps({k: manifest[k] for k in ("archive", "archive_sha256", "code_commit", "mode", "verify")}, ensure_ascii=False, indent=1)) return 0 diff --git a/tools/seal_budget_hel879.py b/tools/seal_budget_hel879.py new file mode 100644 index 0000000..a52343e --- /dev/null +++ b/tools/seal_budget_hel879.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""HEL-879 · A(团队测试实例)显式封账工具——体验链提前走完时的可执行关闭授权。 + +总管独立复审复现的失败边界:临时库使用 51/55 → 停服/backup 归档 → 原配置重开 +后第 52 次仍 allowed——「走完体验链」与「耗尽 55」并不等价,没有可执行封账。 + +本工具把关闭授权落成一个显式、可审计的动作(budget-package-hel895.md §6.2): + + python3 tools/seal_budget_hel879.py --db /opt/<8360实例>/data/<产品库>.sqlite3 [--reason "体验链走完"] + +行为: +- 在该库 `budget_total` 原子写入 sealed/sealed_requests/sealed_tokens/sealed_at; +- 真实用量照实保留(如 51),不伪造 55、不清零、不改其他行; +- 此后该库任何 `PersistentBudget.acquire` 在发送前抛 `budget_sealed:/`, + 跨重启、跨 backup API 归档/快照恢复同样拒绝(负例见 + `tools/verify_budget_split_hel896.py` S6); +- 无解除封账的代码路径:团队复检 = 新批准 + 新实例/新库(§6.2.4)。 + +封账顺序:先停服 → 跑本工具 → `tools/release_snapshot_hel896.py` 一致性快照 +(封账标记随库一同归档)。本工具只写上述 4 行标记,零 provider 调用、零网络。 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from cybergril.life.model import PersistentBudget # noqa: E402 +from cybergril.memory.store import Store # noqa: E402 +from cybergril.think.budget import BudgetConfig, BudgetGuard, BudgetStop # noqa: E402 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--db", required=True, type=Path, help="团队实例产品库路径") + ap.add_argument("--reason", default="", help="封账原因(记录在交付评论,不写入库)") + args = ap.parse_args() + if not args.db.is_file(): + print(json.dumps({"ok": False, "error": f"库不存在:{args.db}"}, ensure_ascii=False)) + return 1 + store = Store(args.db, init=False) + guard = PersistentBudget(store, BudgetGuard(BudgetConfig())) + before = guard.total_requests_used() + marker = guard.seal() + # 自检:封账后 acquire 必须被拒且不落账(拒绝路径在任何写入之前)。 + try: + guard.acquire("dialogue_main", tokens=1) + rejected, message = False, "allowed(封账失效!)" + except BudgetStop as exc: + rejected, message = True, str(exc) + after = guard.total_requests_used() + ok = (marker["requests"] == before == after and rejected + and message.startswith(f"budget_sealed:{marker['requests']}")) + print(json.dumps({"ok": ok, "db": str(args.db), "reason": args.reason, + "sealed_marker": marker, "real_usage_preserved": before == after, + "post_seal_acquire": message}, ensure_ascii=False, indent=1)) + store.close() + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_budget_split_hel896.py b/tools/verify_budget_split_hel896.py index 115d2d3..a0ee4fe 100644 --- a/tools/verify_budget_split_hel896.py +++ b/tools/verify_budget_split_hel896.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""HEL-896 · A/B/C 分账与阶段切换验证(仅临时库 + 替身,零 provider、零网络)。 +"""HEL-896/879 · A/B/C 分账、阶段切换与提前封账验证(仅临时库 + 替身,零 provider、零网络)。 总管独立复审(HEL-896 附件 §1)复现的问题:把 A 的一次性总量 55 写进与 B/C 共用的 产品库 PersistentBudget 后,B 的 mem0_extract 与 C 的 dialogue_main 都被 @@ -12,6 +12,9 @@ budget_total_requests_exhausted:55/55 挡住,且 budget_total/request_usage/to S2 封账 —— 重启不归零;SQLite backup API 一致性快照归档后,归档件 仍是 55/55 且继续拒绝(封账跨重启、跨归档成立);测试档案 只留在团队库; + S6 提前封账(HEL-879) —— 体验链 51/55 走完即显式 seal:真实用量保留 51(不伪造 + 55、不清零),第 52 次任何类别拒绝 budget_sealed:51/55; + 误重启、backup API 归档恢复后同样拒绝;seal 幂等不覆盖; S3 全新用户实例(B/C) —— 独立新库、不配置一次性总量:A 耗尽后 B(mem0_extract) 与 C(dialogue_main)照常放行;日顶叠加(dialogue 60、 semantic 40、daily_tokens)逐项到顶拒绝;拒绝不落账; @@ -32,6 +35,7 @@ from __future__ import annotations import hashlib import json import sqlite3 +import subprocess import sys import tempfile from pathlib import Path @@ -42,7 +46,18 @@ from cybergril.life.model import PersistentBudget # noqa: E402 from cybergril.memory.store import Store # noqa: E402 from cybergril.think.budget import BudgetConfig, BudgetGuard, BudgetStop # noqa: E402 -HEAD = "04eff611e7d85e539d43717711172441420ac853" +REPO = Path(__file__).resolve().parents[1] + + +def git_head() -> str: + try: + return subprocess.run(["git", "rev-parse", "HEAD"], cwd=REPO, capture_output=True, + text=True, check=True).stdout.strip() + except Exception: + return "unknown" + + +HEAD = git_head() RESULTS: list[tuple[str, bool, str]] = [] @@ -150,6 +165,61 @@ def main() -> int: team_reopened.close() sealed.close() + # ── S6:提前封账(HEL-879)——体验链 51/55 走完即 seal,误重启/归档恢复均拒绝 ── + early = Store(tmp / "team-8360-early.db") + early_cfg = BudgetConfig(daily_tokens=2_000_000, + daily_requests={"dialogue": 60, "life": 8, "background": 40, "semantic": 40}, + total_requests=55, total_tokens=500_000) + early_guard = PersistentBudget(early, BudgetGuard(early_cfg)) + # A 表 51 次(创建/诠释/习惯4 + 对话16 + 生活8 + 判定/成长/gist20 + 主动措辞3) + # 就是体验链全部内容——走完即停,不补到 55。 + early_plan = [("fact_extract", 4), ("dialogue_main", 16), ("life_event", 8), + ("memory_gist", 20), ("proactive_message", 3)] + early_ok = True + for scene, n in early_plan: + for _ in range(n): + early_ok &= acquire(early_guard, scene) == "allowed" + used_before_seal = early_guard.total_requests_used() + marker = early_guard.seal() + s6_52nd = acquire(early_guard, "dialogue_main") + s6_other_scene = acquire(early_guard, "mem0_extract") + s6_daily_still_has_room = early_guard.requests_used("dialogue") # 19/60:日顶有余 + check("S6a_early_finish_51_seals_real_usage_no_fake_no_zero", + early_ok and used_before_seal == 51 and marker["requests"] == 51 + and s6_52nd.startswith("budget_sealed:51/55") and s6_other_scene.startswith("budget_sealed:51/55") + and early_guard.total_requests_used() == 51 and s6_daily_still_has_room == 19, + f"used={used_before_seal} dialogue_next={s6_52nd} semantic_next={s6_other_scene} " + f"after={early_guard.total_requests_used()} dialogue_daily={s6_daily_still_has_room}/60") + # seal 幂等:重复 seal 不覆盖原标记(sealed_at 不变)。 + again = early_guard.seal() + idempotent = again == marker + # 误重启:重开库后第 52 次仍拒绝。 + early.close() + early_reopened = Store(tmp / "team-8360-early.db", init=False) + early_guard2 = PersistentBudget(early_reopened, BudgetGuard(early_cfg)) + s6_after_restart = acquire(early_guard2, "dialogue_main") + # 归档恢复:backup API 归档封账库 → 打开归档件 → 同样拒绝,用量仍 51。 + early_archive = tmp / "team-8360-early-sealed.db" + snapshot_via_backup_api(tmp / "team-8360-early.db", early_archive) + early_arch = Store(early_archive, init=False) + early_arch_guard = PersistentBudget(early_arch, BudgetGuard(early_cfg)) + s6_after_archive = acquire(early_arch_guard, "dialogue_main") + arch_marker = early_arch_guard.sealed() + check("S6b_seal_survives_restart_and_archive_restore", + idempotent and s6_after_restart.startswith("budget_sealed:51/55") + and s6_after_archive.startswith("budget_sealed:51/55") + and early_arch_guard.total_requests_used() == 51 + and arch_marker is not None and arch_marker["requests"] == 51, + f"restart={s6_after_restart} archive={s6_after_archive} " + f"arch_used={early_arch_guard.total_requests_used()} marker={arch_marker}") + report["scenes"]["S6_early_seal"] = { + "plan_total": sum(n for _, n in early_plan), "used_at_seal": used_before_seal, + "marker": marker, "next_after_seal": s6_52nd, "other_scene_after_seal": s6_other_scene, + "after_restart": s6_after_restart, "after_archive_restore": s6_after_archive, + "idempotent_reseal": idempotent} + early_reopened.close() + early_arch.close() + # ── S3:全新用户实例(B/C)——独立新库、无一次性总量:A 耗尽后 B/C 照常放行,日顶叠加 ── user = Store(tmp / "user-fresh.db") user_cfg = BudgetConfig(daily_tokens=2_000_000,