HEL-895 release candidate: the approved kernel 152479b + the HEL-881-passed proactive-inbox frontend bf234570 assembled into one auditable head, with the full post-synthesis battery, the release package and the one-approval budget execution package - integration is a clean merge (the mainline 31970c4..152479b never touched web/, so the merge brings exactly the visual director's six frontend files plus handoff-HEL-875/880.json; web/ is verbatim bf234570 and src/tests/tools/config are verbatim 152479b, all HEL-874..894 backend fixes preserved, no redesign, no visual decision, the unpushed b48d28b test merge superseded); verification rerun on the merged head: 299 python tests pass on the 3.11.16 venv + requirements.lock offline, ruff clean (src/tests/tools), web typecheck 0 errors / lint 0 errors 1 pre-existing warning / vitest 55/55 / build passes with CSS still index-DdDYzR5S.css (visual untouched), pinned-SDK regressions on hel857-venv (3.10.12, deterministic 512-dim stand-in, loopback-only, temp ledgers): verify_close_ownership_hel894.py 16/16, verify_shutdown_queued_hel889.py 12/12, verify_inflight_lifecycle_hel886.py 6/6, verify_semantic_recovery_hel885.py 7/7, verify_mem0_real_hel874.py 12/12, check_release_offline_hel882.py R1-R7 15/15 (R6 honestly records the 3.11 mem0 gap); NEW real-HTTP pagination contract 7/7 (tools/verify_http_pagination_hel895.py: the real product service seeded with 327 rows proves the reverse-page + limit+1 probe + next_before + epoch contract the approved frontend depends on, the public field allowlist with proactive exactly on reach-out rows, and a mechanical port of inbox.ts fetchAfter proving the HEL-880 gap semantics against the real backend - 5-page cap leaves the gap, the next call closes it and hands the two new reach-outs exactly once); NEW real-server browser 7/7 (web/tests/hel895_real.mjs: Chromium CDP against the real API + built dist - real boot, reach card arriving over real HTTP, stage playback, the 260-row long-absence gap regression, SIGTERM restart epoch+1 with no duplicate rows, zero permission requests; evidence docs/rework/hel895/); deliverables: deploy/hel895-release-candidate.md (exact head, manifest, install reference, health matrix, disable/rollback, the exact 3.11 fixed-SDK gap verified offline - product venv imports fine, no mem0/qdrant/fastembed wheels in any local cache, hel857-venv 3.10 fixed stack untouched and un-mixed, zero downloads, resolution path unchanged per hel874-release.md §4) and docs/budget-package-hel895.md (the A/B/C tables as one approvable package: per-send four-gate stacking semantics, the cross/duplicate/missed accounting verdict - A totals stack on daily category caps by design, B extraction LLMs are dual-counted in llm_ops cumulative AND the semantic daily gate by design, embeds count only as items, all four request categories plus the global token pool cover every outbound site, failures/cancels conservatively billed with no refunds, cap-hit stop semantics and evidence - all still cap=0 defaults, the 39/60 and 498/500 freezes and the HEL-835 gate untouched); requirement checks 6/6 (origin/main, origin/agent/agent/de8874a3803b and the actual mainline 152479b, each with and without handoff-HEL-895.json); zero real LLM, zero real embeddings, zero downloads, authoritative ledgers and freezes unwritten, not deployed, PR#11 appended-not-merged, no visual changes, R5/R6/R7 honestly deferred

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
zonggong
2026-10-27 02:23:08 +08:00
co-authored by multica-agent
parent 68f3eefeca
commit 04eff611e7
11 changed files with 661 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
# HEL-895 · 统一发布候选(离线核验过,未执行部署)
**状态:候选。审核通过与预算/部署授权前不启动面向用户的真实循环、不部署、不合并
PR、不交体验地址。** 本文件把已通过独立复审的内核(HEL-894 @ `152479b`)与
HEL-881 已审核的主动留言前端(`bf234570`)装成一个统一候选,给出精确 head、
交付物清单、安装命令、健康矩阵与停用回退。发布卡照抄即可执行;不触 8300–8350
与共享 ASR/TTS/口型,不访问 4080,不导入演示/测试档案为用户伴侣。
## 0. 精确 head 与整合方式
- **候选 head**:见交付评论(分支 `hel-870-assembly`,PR #11 的正常追加提交,
未强推、未合并)。第一父为 HEL-894 通过复审的 `152479b3503c3d1ab27681170e6bfd592c54380a`,
合并入 HEL-881 已审核的 `bf234570c0b6595a72f8dbc79e42c63685e08e01`
(origin/hel-880-inbox-continuation,与 origin/hel-875-proactive-wiring 同点)。
- **整合的机械性**:母线 `31970c4..152479b` 从未触碰 `web/`(`git diff 31970c4
152479b -- web/` 为空),合并只带入原作者六个前端文件与
`handoff-HEL-875/880.json` 共 9 个文件;`src/tests/tools/config` 相对 `152479b`
逐字不变。无重设计、无视觉决定;旧 `b48d28b` 测试合并未推送,由本候选取代。
- **合成后验证(全部在本 head 实跑)**:299 pytest(3.11.16 venv +
requirements.lock 离线)+ ruff 干净;web typecheck 0 错、lint 0 错/1 条既有警告、
vitest 55/55、build 通过且 CSS 仍为 `index-DdDYzR5S.css`(视觉零改动);
固定 SDK 回归:关闭所有权 16/16、关闭排队 12/12、处理中生命周期 6/6、语义恢复
7/7、真实接线 12/12;发布离线检查 15/15;**真实 HTTP 分页合同 7/7**
(`tools/verify_http_pagination_hel895.py`,真实服务 + 327 行播种 + fetchAfter
缺口游标移植对真实后端复验);**真实服务浏览器 7/7**(`web/tests/hel895_real.mjs`,
Chromium CDP 对真实 API:留言卡、舞台播放、260 行缺口不跳留言、重启 epoch 去重、
零权限申请)。证据 `docs/rework/hel895/`。双目标及实际母线需求检查各含/不含
handoff-HEL-895 共 6/6 通过(origin/main、原产品目标 de8874a3803b、实际母线)。
## 1. 交付物清单
| 交付物 | 位置/来源 | 说明 |
| --- | --- | --- |
| 代码 | 分支 `hel-870-assembly`,精确 head 见交付评论 | 含本文件、`docs/budget-package-hel895.md`、`tools/verify_http_pagination_hel895.py`、`web/tests/hel895_real.mjs` |
| Python 依赖 | `requirements.lock`(19 项固定版本,uv 离线可装) | Python 3.11(发布机 3.11.16 实测) |
| 前端产物 | `web/`(原作者界面 + HEL-875/880 主动留言) | `npm ci && npm run build`;CSS 哈希与已审核版一致 |
| mem0 固定栈 | `deploy/mem0-stack.lock`(mem0ai 2.2.1 / qdrant-client 1.20.0 / fastembed 0.9.1) | **3.11 wheel 仍未落位,见 §4;缺项不阻塞其余发布** |
| systemd 模板 | `deploy/cybergril-hel874-8360.service.example`(PYTHONPATH=src 已内置) | 独立单元/端口/数据目录模式 |
| 配置模板 | `config/models.yaml`(预算全 0)+ `config/semantic.example.yaml` | 批准值写入位置见 `docs/budget-package-hel895.md` |
| 预算执行包 | `docs/budget-package-hel895.md` | A/B/C 一次批准核定稿,默认 0 |
## 2. 安装命令(发布卡执行)
与 `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 分页合同、真实服务浏览器)在目标机重跑。
## 3. 健康能力矩阵(/api/health 预期)
与 `deploy/hel874-release.md` §3 一致:`version=R4-assembly`;backend
mood/reach_out/semantic 接线;`semantic_backend=stub`(发布初期,真实 mem0 待 §4
与 B 部分预算);capabilities.proactive=true(生活循环开启时);voice/vision/
weather/materials/backup=false。**主动留言前端已并入本候选:生活循环开启且产生
真实 reach-out 后,打开的页面每 20 秒读一次新行、回来立即读,未读有标记;该行为
已在本 head 用真实服务验证(§0)。**
## 4. 固定 SDK(3.11)准确缺项与不下载路径
**本卡离线核实(2026-10-27)**:产品 3.11.16 venv 仅含 `requirements.lock`,
`import mem0/qdrant_client/fastembed` 均缺;本机 uv(974M)与 pip 缓存中**没有**
任何 mem0ai/qdrant-client/fastembed 的 wheel(任意 Python 版本);固定安装
hel857-venv 是 Python 3.10.12 + mem0ai 2.2.1/qdrant-client 1.20.0/fastembed
0.9.1(本卡全部 SDK 回归仍在其上实跑,3.10/3.11 site-packages 未混用)。
**缺项清单(发布卡落 wheel 时照抄)**:`mem0ai==2.2.1`、`qdrant-client==1.20.0`、
`fastembed==0.9.1` 的 cp311/manylinux2014_x86_64 wheel(及其纯 Python 传递依赖
wheel,pip download 会一并解析);目标平台 x86_64 Linux(部署机 `uname -m`
复核);锁定来源 `deploy/mem0-stack.lock`(源码身份 2c81af3 / tarball SHA256 见
lock 注释)。下载与离线并入命令、sha256 留证、co-import 最终判据不变,照
`deploy/hel874-release.md` §4 执行。**在 wheel 落位且 co-import 通过前,不得以
stub/FTS 冒充真实 Mem0 体验就绪;本卡未下载任何内容。**
## 5. 停用与保留数据回退
与 `deploy/hel874-release.md` §5 相同:`CYBERGRIL_SEMANTIC=off|stub` 停语义层
(表保留、按后端区分续跑);`CYBERGRIL_R2_ENABLED=0` 停生活/主动联系;整实例
`systemctl stop` → 数据目录原样保留 → 切回上一 release/上一提交(R4 迁移全部
增量新表,旧代码可安全打开同一数据库);彻底撤销留数据档不删。
## 6. 本候选不做的事
不合并任何 PR(PR #11 保持开放待总管/白栖知决定);不部署、不交体验地址;
零真实 LLM/嵌入/下载;不触 8300–8350、共享语音口型、GPU/4080;不导入演示测试
档案为用户伴侣;不替视觉作者改稿(前端内容以原作者六文件与 CSS 可追溯为准)。
R5(HEL-878/879 语音)、R6(天气/素材/完整备份)、R7(有条件视觉观察)未接入,
界面保持诚实文案,不冒充已运行。
+73
View File
@@ -0,0 +1,73 @@
# HEL-895 · 真实全链一次批准执行包(A/B/C 合并核定稿;仍全部待授权,默认 0 不变)
本文件把 `docs/budget-plan-hel874.md` 的 A/B/C 三表收拢成**一次可批准的具体执行包**:
每一步的请求/嵌入/失败-取消-重试上限、累计与日顶的叠加口径、一次性团队检查与用户
日常使用的分账、碰顶停机语义与留证方式。技术核对基于本卡实际代码与配置
(`src/cybergril/life/model.py` 的 `PersistentBudget`、`src/cybergril/think/budget.py`、
`config/models.yaml`、`config/semantic.example.yaml`、299 pytest 含
`tests/test_hard_gates_hel882.py`)。
**批准状态:三部分均未批准。随包 `models.yaml` 全部上限为 0(未批准即发送前拒绝),
`semantic.example.yaml` 缺省全 0。本卡零真实调用、零真实嵌入、零权威账写入。**
旧账 39/60(真实请求)与 498/500(嵌入,冻结 2 条)及 HEL-835 闸原样保全,
不挪用、不重置、不并入。真实数值只有白栖知批准后由总管写入。
## 1. 批准项(一个包,三个独立账)
| 部分 | 用途 | 硬顶(批准后由总管写入) | 账本 |
| --- | --- | --- | --- |
| A · 一次性团队整体检查 | 8360 隔离实例按体验链 1–7 全链走一遍(stub 语义,零嵌入) | 总量 55 次请求 / 500,000 tokens / 0 嵌入(`budget.total_requests`/`total_tokens`,跨日累计) | `budget_total`/`request_usage`/`token_usage` |
| B · 首周语义激活 | `CYBERGRIL_SEMANTIC=mem0` 真实激活观察 | 提取 LLM ≤60 次(`llm_cap`);嵌入 ≤180 条新批 → 按算式写 `embed_cap=680`、`embed_reserve=2`(权威账已用 498/原批 500) | `llm_ops`(新表)/`embed_ops`(权威账续账,`authority` 冻结前缀必填) |
| C · 用户日常使用 | 体验地址交付后每日 | dialogue ≤60/日、life ≤8/日、background ≤40/日、semantic ≤40/日(`daily_requests`);嵌入 ≤60 条/日(`embed_daily`);tokens ≤2,000,000/日(`daily_tokens`,全类别共池) | 同 A 的持久表 + 日顶列 |
A 当天同时受 C 类日顶约束(叠加见 §2);B 期间的对话/生活/判定请求并入 C 日账,
不占 B 的 60 次。三部分独立授权来源保持:A 需总管新批(产品环境网关用量不属于
HEL-857 的 60 次实验授权);B/C 需白栖知明确批准。
## 2. 叠加口径(逐笔发送前原子预占;总管核定值写入后自动生效)
一次真实 provider 发送(创建/对话/生活/gist/判定/成长/主动措辞/Mem0 内部提取)
在 `PersistentBudget.acquire` 同时过四道闸,任一到顶即 `BudgetStop` 拒绝,**不降档续调**:
1. 类别日顶(该调用所属 `daily_requests` 类别:dialogue/life/background/semantic);
2. 日 token 池(`daily_tokens`,全类别共池;70% 警告、95% 非关键场景 structured 预检先停);
3. A 部分一次性总量(`total_requests`/`total_tokens`,跨日累计,重启不归零);
4. B 部分类别账:Mem0 内部提取 LLM 另过 `llm_cap` 累计 + `llm_daily`;嵌入过
`embed_cap` 累计(含冻结 `embed_reserve`)+ `embed_daily`。
**交叉核对结论(本卡逐项核过代码与测试):**
- **交叉(有意叠加,非重复计费)**:A 的每次请求同时计入 A 总量与当天类别日顶;
B 的每次提取 LLM 同时计入 `llm_ops` 累计与 semantic 日顶 + 日 token 池。两处计数
用途不同(一个限一次性总量/首周观察,一个限每天),不会互相退款或放宽。
- **不重复**:嵌入只记 `embed_ops`(条数),不进请求计数;提取 LLM 只在
`llm_ops`+统一请求闸各记一次;主对话与语义层各自独立闸(`tests/` 双实例同库复现)。
- **不漏记**:四个请求类别(dialogue/life/background/semantic)+ 嵌入 + 全池 token
已覆盖全部产品出站点;SDK 隐藏重试已禁用(`max_retries=0`),每次实际发送都过闸。
## 3. 失败、取消与重试(保守入账,不退款)
- 发送前预占(请求次数 + 保守 token 估算:输入字符 1:1 + 声明 max_tokens 全额);
provider 返回真实 usage 按实际修正(多补少退)。
- 未知 usage/断流/失败/取消保守保留全部预占;已发送的成本不当未发生。唯一释放
路径是可证明未连接(`httpx.ConnectError` 级)的显式 `release()`。
- 真实错误重试 ≤3 次/批;预算拒绝不耗重试次数——作业回 `queued`
(reason=`semantic_*_budget_*`),总管调 cap 后同批续跑,已完成行不重发。
- 嵌入:单条失败释放;批次中途失败整批保守落账。
## 4. 碰顶停机语义与留证
- **全部硬闸在发送前**拒绝(`budget_*_exhausted` / `semantic_*_budget_*`),拒绝如实
记录,不补发、不换渠道。
- 语义层到顶只停语义(检索回落词面 FTS,主对话不中断);日 token 到顶停当日全部
真实调用;A 总量到顶即团队检查收尾。
- 留证:`budget_total`/`request_usage`/`token_usage`/`embed_ops`/`llm_ops` 表 +
服务日志 + 隔离实例数据目录(SQLite WAL 单文件可整体归档);碰顶当时的拒绝
reason 与计数快照截图/导出一并归档到交付评论。
## 5. 本包默认状态(本卡随包可执行)
`config/models.yaml`:`daily_tokens: 2000000`(结构性默认值,非授权)、
`daily_requests.*` 全 0、A 部分总量两行保持注释(待批);`config/semantic.example.yaml`
全部 cap/daily 为 0、`authority` 必填结构就位。**未写入批准值前,任何创建/对话/
生活/gist/判定/成长/主动措辞/提取调用都在发送前被硬闸拒绝(`budget_*_exhausted`),
这是设计行为;已由 `tools/check_release_offline_hel882.py` R3 与 299 pytest 复现。**
+65
View File
@@ -0,0 +1,65 @@
{
"baseline_version": "1.11",
"read_commit": "152479b3503c3d1ab27681170e6bfd592c54380a",
"task": "HEL-895 发布准备:把 HEL-894 已过独立复审的统一内核(152479b)与 HEL-881 已审核的主动留言前端原稿(bf234570,原作者六文件逐字、CSS 零改动)机械装成同一可审核可执行的统一发布候选。先核对包含关系(bf234570 不是 152479b 的祖先;母线 31970c4..152479b 从未触碰 web/),以正常合并带入恰两个提交共 9 个文件,src/tests/tools/config 相对 152479b 逐字不变,全部 HEL-874..894 后端修复保留;不重设计、不做视觉决定、不覆盖视觉分支,旧 b48d28b 测试合并未推送由本候选取代。零真实 LLM、零真实嵌入、零新增下载、不部署、不合并 PR、不交体验地址、不触 8300–8350 与共享语音口型、不访问 4080、不导入演示测试档案为用户伴侣、不写权威账、不变真实 cap、不改运行时/模型/认证",
"requirement_ids": [
"CG-006",
"CG-008",
"CG-009",
"CG-010",
"CG-011",
"CG-012",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-021",
"CG-023",
"CG-029",
"CG-030",
"CG-031",
"VIS-013",
"AM-001",
"AM-002",
"AM-003",
"UX-001",
"UX-005",
"UX-006",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "统一发布候选(分支 hel-870-assembly 正常追加,精确 head 见交付评论,PR#11 只追加不强推不合并):1) 机械整合合并提交——第一父 152479b、合入 bf234570,仅新增原作者前端六文件(web/src/adapters/inbox.ts、real.ts、app/RealApp.tsx、chat/ChatPanel.tsx 与三个测试)+ handoff-HEL-875/880.json,web/ 逐字等于 bf234570、src/tests/tools/config 逐字等于 152479b;2) 合成后全量验证在本 head 实跑:python 3.11.16 venv + requirements.lock 离线 299 pytest 全过、ruff 0 错;web typecheck 0 错、lint 0 错(1 条既有警告非本卡)、vitest 55/55、build 通过且 CSS 仍 index-DdDYzR5S.css(视觉零改动);固定 SDK(hel857-venv 3.10.12,未混 site-packages)确定性 512 维替身 + 仅回环实跑:关闭所有权 16/16、关闭排队 12/12、处理中生命周期 6/6、语义恢复 7/7、真实接线 12/12、发布离线检查 15/15;3) 新增并实跑两项本卡验证:真实 HTTP 分页合同 7/7(tools/verify_http_pagination_hel895.py——真实产品服务、327 行播种、倒序+limit+1 探测+next_before+epoch 合同、公开字段允许清单与 proactive 标记恰在播种行、inbox.ts fetchAfter 缺口游标的机械移植对真实后端复验:一次调用最多 5 页缺口保留、下次补齐按 id 序恰好交出两条新留言不跳不重);真实服务浏览器 7/7(web/tests/hel895_real.mjs——Chromium CDP 对真实 API + web/dist:真实启动、留言卡过真实 HTTP 到达、舞台播放、260 行在其上的长缺口语义(HEL-880 回归)真实服务复验不跳留言且无重复、服务重启 epoch+1 后同批行不加第二次、零权限申请),证据 docs/rework/hel895/;4) 发布包与清单 deploy/hel895-release-candidate.md(精确 head、交付物清单、安装引用、健康矩阵、停用回退、固定 SDK 3.11 准确缺项);5) 一次可批准预算执行包 docs/budget-package-hel895.md(A 55 请求/500000 tokens/0 嵌入一次性、B 60 提取 LLM/180 嵌入按 500+180 算式、C 逐类日顶;逐笔发送前四道闸叠加口径、交叉=有意叠加、无重复、无漏记、失败取消保守入账不退款、碰顶停机与留证;默认全 0 不变,冻结 39/60 与 498/500 及 HEL-835 闸原样保全);6) 本地离线核实 3.11 固定 SDK 现状:产品 venv 无 mem0/qdrant_client/fastembed,本机 uv/pip 缓存无其任何 wheel,hel857-venv(3.10.12 固定栈)存在且全部 SDK 回归在其上实跑;缺项=mem0ai 2.2.1/qdrant-client 1.20.0/fastembed 0.9.1 的 cp311 manylinux2014_x86_64 wheel,落位命令照 deploy/hel874-release.md §4 不变,本卡零下载",
"acceptance": [
"包含关系与机械性:合并前核实 bf234570 不是 152479b 祖先、git diff 31970c4 152479b -- web/ 为空;合并后 git diff bf234570 HEAD -- web/ 为空、git diff 152479b HEAD -- src tests tools config 为空(仅 9 个前端/治理文件变化),无重设计无视觉决定,原作者六文件与 CSS 可追溯",
"合成后工程检查在本 head 全过:299 pytest(3.11.16 venv + requirements.lock 离线)、ruff 0 错(src/tests/tools)、web typecheck 0 错/lint 0 错 1 既有警告/vitest 55/55/build 通过且 CSS 哈希 index-DdDYzR5S.css 不变",
"HEL-894/889/886/885/874/882 回归全绿(固定 SDK 确定性替身 + 仅回环):verify_close_ownership_hel894.py 16/16、verify_shutdown_queued_hel889.py 12/12、verify_inflight_lifecycle_hel886.py 6/6、verify_semantic_recovery_hel885.py 7/7、verify_mem0_real_hel874.py 12/12、check_release_offline_hel882.py R1–R7 15/15",
"真实 HTTP 分页合同 7/7(tools/verify_http_pagination_hel895.py 对真实服务):无档案 404 负例;首页恰 50 行升序 next_before=本页最小 id;沿 next_before 整走 327 行无重无缺页边界正确;公开允许清单字段恰为 id/ts/role/text/turn_id/status/lifeIds(+proactive) 且 proactive 恰在播种留言行;fetchAfter 缺口游标(HEL-880)真实服务复验:5 页封顶缺口保留→下次补齐→两条新留言按 id 序恰好交出一次;epoch 分页稳定、未知路径 404",
"真实服务浏览器 7/7(web/tests/hel895_real.mjs,Chromium CDP 对真实 API):真实启动无服务未接通卡;新留言行写入后 20 秒内/可见性触发读到达并出现留言卡;进交谈舞台播放;260 行长缺口在其下的新留言经补读到达且无重复;SIGTERM 重启(epoch+1)后同批行不加第二次;麦克风/相机权限保持 prompt 零申请",
"发布候选文档 deploy/hel895-release-candidate.md 给出精确 head、交付物清单(requirements.lock 19 项固定依赖、前端产物 CSS 哈希一致、systemd/配置模板、独立数据空间、健康矩阵 R4-assembly/stub/诚实未接项、停用与保留数据回退)与 3.11 固定 SDK 准确缺项(零下载,落位路径不变)",
"预算执行包 docs/budget-package-hel895.md:A/B/C 数值、四道闸叠加口径与交叉/重复/漏记核对结论(交叉=总量+日顶有意叠加;嵌入只记条数不进请求计数;四类别+嵌入+全池 token 覆盖全部出站点)、失败取消保守入账、碰顶停机与留证;随包默认全 0、权威账与冻结余额零写入(check_release_offline R3 复现未批即拒)",
"双目标及实际母线需求检查各含/不含本 handoff 共 6/6 通过(origin/main、origin/agent/agent/de8874a3803b、实际母线 152479b)"
],
"deferred": [
"真实预算授权:A/B/C 全部待白栖知经总管批准;批准前 cap 全 0=发送前拒绝;旧 39/60、498/500(冻结 2 条)与 HEL-835 闸不挪用不重置;真实调用、真实嵌入、权威账写入本卡零执行",
"3.11 固定 SDK wheel 落位:本机缓存无 cp311 wheel,发布卡按 deploy/hel874-release.md §4 用 pip download(mem0-stack.lock)落 wheel 后离线并入并 co-import 验证;落位前 stub/FTS 不冒充真实 Mem0 体验就绪",
"部署与体验地址:待总管复核通过 + 预算/部署授权后由发布卡按 deploy/hel895-release-candidate.md §2 执行(8360 隔离实例);本卡未部署、未合并 PR#11、未交体验地址;8300–8350 与共享语音口型未触碰",
"R5 语音:HEL-878(原作者会话额度受限,已有定时唤醒)/HEL-879(待前端完成)继续接续;R6 天气/素材包/完整备份、R7 有条件视觉观察(默认关,需白栖知授权 + 4080 决策)均未接入,界面保持诚实文案,不冒充已运行",
"真机与 Safari 未验证(等效视口与桌面 Chromium 已过);跨设备已读仍为本浏览器口径;即时到达需后端推送/正向分页属合同变更未做——沿用 HEL-881 承接口径",
"整体沉浸体验方向:用户 2026-10-23 认可动效质感但整体沉浸感未满意、新方向未定;本卡为发布准备不接管视觉作者、不触其运行时/模型/认证;界面小样认可前不替换正式界面"
],
"requirement_changes": [],
"verification": [
"分支 hel-870-assembly 正常追加(精确 head 见交付评论,第一父 152479b、合入 bf234570),PR#11 只追加不强推不合并;视觉总监分支未覆盖",
"python 3.11.16 venv + requirements.lock 离线:299 pytest 全过;ruff 0 错(src/tests/tools);web typecheck/lint(1 既有警告)/vitest 55/55/build 全过,CSS 仍 index-DdDYzR5S.css",
"固定 SDK(hel857-venv 3.10.12 + mem0ai 2.2.1/qdrant-client 1.20.0/fastembed 0.9.1,确定性 512 维替身、仅回环、临时账库):verify_close_ownership_hel894.py 16/16(docs/rework/hel895/close-ownership-hel895.json)、verify_shutdown_queued_hel889.py 12/12、verify_inflight_lifecycle_hel886.py 6/6、verify_semantic_recovery_hel885.py 7/7、verify_mem0_real_hel874.py 12/12(同目录证据 JSON);check_release_offline_hel882.py R1–R7 15/15(release-offline-hel895.json,R6 如实记录 3.11 缺 mem0)",
"新增 tools/verify_http_pagination_hel895.py:真实服务 7/7(docs/rework/hel895/http-pagination.json);新增 web/tests/hel895_real.mjs:真实服务 + Chromium CDP 7/7(docs/rework/hel895/real-browser.json 与 real-*.png 截图)",
"python3 scripts/check_requirements.py --base-ref {origin/main | origin/agent/agent/de8874a3803b | 152479b} 各含/不含 --handoff docs/requirements/handoff-HEL-895.json 共 6/6 通过",
"零真实 LLM、零真实嵌入、零非回环出站、零下载;权威三账与冻结余额未写;8300–8350、共享语音口型、GPU/4080 未触碰;未部署、未合并 PR、未交体验地址"
]
}
+41
View File
@@ -0,0 +1,41 @@
{
"checks": [
{
"name": "H0_service_up",
"ok": true,
"detail": "port 8395"
},
{
"name": "H1_404_before_companion",
"ok": true,
"detail": "status=404"
},
{
"name": "H2_first_page_contract",
"ok": true,
"detail": "len=50 ascending=True next_before=278 epoch=1"
},
{
"name": "H3_full_backward_walk",
"ok": true,
"detail": "rows=327 unique=327 pages=2 backward_pages=True last_next_before=None"
},
{
"name": "H4_public_allowlist_proactive_flag",
"ok": true,
"detail": "rows=327 proactive=[3, 326, 327] expected=[3, 326, 327] bad_keys=[]"
},
{
"name": "H5_fetchafter_gap_semantics",
"ok": true,
"detail": "call1: gap=True pages=5 handed=0; call2: gap=False through=327/327 handed_ids=[326, 327]"
},
{
"name": "H6_epoch_stable_404_shape",
"ok": true,
"detail": "epoch=1/1 unknown=404"
}
],
"failures": [],
"service_stderr_tail": "INFO: Started server process [1535952]\nINFO: Waiting for application startup.\nINFO: Application startup complete.\nINFO: Uvicorn running on http://127.0.0.1:8395 (Press CTRL+C to quit)\nINFO: Shutting down\nINFO: Waiting for application shutdown.\nINFO: Application shutdown complete.\nINFO: Finished server process [1535952]\n"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

+5
View File
@@ -0,0 +1,5 @@
{
"ok": true,
"checks": 7,
"fail_count": 0
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""HEL-895 · 真实 HTTP 分页合同检查(零真实模型调用、仅回环、临时目录)。
被测对象是本 checkout 的真实产品服务(``python -m cybergril.api``,PYTHONPATH=src),
不是 mock。目的是证明 HEL-881 已审核的主动留言前端所依赖的 GET /api/messages
合同(倒序取页 + limit+1 探测 + next_before + epoch,web/tests/hel875_mock.mjs
逐字镜像的那份)由真实后端原样提供,且 inbox.ts 的 fetchAfter 缺口游标逻辑
(HEL-880 修复)在真实服务上同样成立。
检查项:
H1 无档案时 /api/messages 404(真实负例)。
H2 首页合同:ascending messages、恰好 limit 行、next_before=本页最小 id、
epoch 为正整数。
H3 向后整走:沿 next_before 走到底,重建全部已播种行——无重复、无缺口、
全局升序;页边界与 limit+1 探测一致(next_before 非空 ⟺ 还有更旧行)。
H4 字段允许清单:每行恰为 id/ts/role/text/turn_id/status/lifeIds(+proactive),
proactive:true 仅出现在播种的主动留言行;无回执令牌或私有候选外泄。
H5 fetchAfter 语义(inbox.ts 的机械 Python 移植):离开很久(新增 320 行,
其中 2 条主动留言)后,单次调用最多 5 页×50 行,缺口保留;下一次调用
补完,所有主动留言按 id 序恰好交出一次、无跳过、无重复。
H6 epoch 在整走期间稳定;/api/nope 404。
运行(在仓库根):
python3 tools/verify_http_pagination_hel895.py --venv /path/to/.venv311/bin/python [out.json]
证据 JSON 写 out.json(默认 docs/rework/hel895/http-pagination.json)。
"""
from __future__ import annotations
import json
import os
import shutil
import signal
import sqlite3
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
REPORT: dict = {"checks": [], "failures": []}
def check(name: str, ok: bool, detail: str = "") -> bool:
REPORT["checks"].append({"name": name, "ok": bool(ok), "detail": detail})
if not ok:
REPORT["failures"].append(name)
print(("PASS " if ok else "FAIL ") + name + (f" — {detail}" if detail else ""))
return bool(ok)
def get(base: str, path: str):
try:
with urllib.request.urlopen(base + path, timeout=10) as r:
return r.status, json.loads(r.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode()
try:
return e.code, json.loads(body)
except json.JSONDecodeError:
return e.code, body
def seed(db: Path, rows: int) -> dict:
"""Seed a companion identity + interleaved rows: 5 history rows (one old
reach-out) then `rows` bulk turn rows, then 2 fresh reach-outs on top."""
conn = sqlite3.connect(db)
conn.execute("PRAGMA journal_mode=WAL")
identity = {
"id": "1", "name": "测试的她", "birth": "2000-03-14", "hour": "子时",
"relation": "朋友", "nickname": "", "boundary": "",
"color": {"pillars": {"year": "庚辰", "month": "己卯", "day": "丙午", "hour": "戊子"},
"traits": "HEL-895 真实合同检查合成档案"},
}
conn.execute("INSERT INTO companion VALUES(1,?)", (json.dumps(identity, ensure_ascii=False),))
t0 = 1761393600_000
plan = [
("user", "下雨天的时候,你会想听什么?", "t-1", 0),
("companion", "会想把窗开一条缝,听雨落在铁皮檐上。", "t-1", 0),
("companion", "昨晚的月亮很亮,想起你说过喜欢夜里散步。", "reach-old", 1),
("user", "今天加班到九点,好累。", "t-2", 0),
("companion", "那先别急着做别的,喝口热水。", "t-2", 0),
]
for i in range(rows):
plan.append(("user" if i % 2 == 0 else "companion", f"别处的第{i + 1}句", f"bulk-{i}", 0))
plan.append(("companion", "窗外的雨停了,想跟你说一声。", "reach-new-1", 1))
plan.append(("companion", "刚看到一只猫从窗台跳下去。", "reach-new-2", 1))
for n, (role, text, turn, proactive) in enumerate(plan, start=1):
conn.execute(
"INSERT INTO chat_message(ts,role,text,turn_id,status,proactive) VALUES(?,?,?,?,?,?)",
(t0 + n * 60_000, role, text, turn, "sent", proactive),
)
conn.commit()
conn.close()
return {"total": len(plan), "proactive_ids": [i for i, p in enumerate(plan, start=1) if p[3]]}
def fetch_after(base: str, cursor: dict, pages: int = 5):
"""Mechanical Python port of web/src/adapters/inbox.ts fetchAfter (HEL-880):
walks backwards page by page, holds reach-outs found inside an unclosed gap,
and hands everything over in id order exactly once when the read meets the
previously-read watermark (or the record's end). The cursor mirrors the TS
shape {through, gap: {before, top, held} | None} and is passed back whole."""
rows: list = []
through = cursor["through"]
gap = dict(cursor["gap"]) if cursor["gap"] else None
if gap:
gap["held"] = list(gap["held"])
carried = gap is not None
queries = []
for _ in range(pages):
before = f"&before={gap['before']}" if gap else ""
q = f"messages?limit=50{before}"
queries.append(q)
_, page = get(base, "/api/" + q)
lst = [m for m in page.get("messages", [])
if isinstance(m.get("id"), int) and m["id"] > 0 and isinstance(m.get("text"), str)]
fresh = [m for m in lst if m["id"] > through]
opened = gap is None
if gap is None:
gap = {"before": 0, "top": max([m["id"] for m in lst], default=through), "held": []}
gap["held"][:0] = [m for m in fresh if m.get("proactive") is True]
if len(fresh) == len(lst) and page.get("next_before"):
gap["before"] = page["next_before"]
continue
rows.extend(gap["held"])
through, gap = gap["top"], None
if opened or not carried:
break
carried = False
return rows, {"through": through, "gap": gap}, queries
def main() -> int:
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--venv", required=True, help="3.11 venv python (bin/python)")
ap.add_argument("out", nargs="?", default="docs/rework/hel895/http-pagination.json")
args = ap.parse_args()
work = Path(tempfile.mkdtemp(prefix="hel895-http-"))
data = work / "data"
data.mkdir()
db = data / "companion.db"
port = 8395
base = f"http://127.0.0.1:{port}"
env = dict(os.environ, CYBERGRIL_DB=str(db), PYTHONPATH=str(ROOT / "src"),
CYBERGRIL_R2_ENABLED="0", CYBERGRIL_SEMANTIC="off",
CYBERGRIL_LIFE_TIMEZONE="Asia/Shanghai", CYBERGRIL_PORT=str(port))
proc = subprocess.Popen([args.venv, "-m", "cybergril.api"], cwd=ROOT, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try:
# Wait for the service (schema init happens on first request's lifespan).
alive = False
for _ in range(120):
try:
status, _body = get(base, "/api/messages")
alive = True
break
except Exception:
if proc.poll() is not None:
print(proc.stderr.read().decode()[-2000:])
break
time.sleep(0.5)
if not check("H0_service_up", alive, f"port {port}"):
return 1
# H1 — no companion yet: the real negative.
status, body = get(base, "/api/messages")
check("H1_404_before_companion", status == 404, f"status={status}")
info = seed(db, rows=320)
time.sleep(0.2)
# H2 — first page contract.
status, page = get(base, "/api/messages?limit=50")
msgs = page.get("messages", [])
ids = [m["id"] for m in msgs]
ok = (status == 200 and len(msgs) == 50 and ids == sorted(ids)
and page.get("next_before") == min(ids) and isinstance(page.get("epoch"), int)
and page["epoch"] > 0)
check("H2_first_page_contract", ok,
f"len={len(msgs)} ascending={ids == sorted(ids)} next_before={page.get('next_before')} epoch={page.get('epoch')}")
# H3 — full backward walk over the real server.
all_ids, cursor, pages_used, page_mins, ascending = [], None, 0, [], True
while True:
q = f"/api/messages?limit=200{f'&before={cursor}' if cursor else ''}"
status, page = get(base, q)
ids = [m["id"] for m in page["messages"]]
ascending = ascending and ids == sorted(ids)
all_ids.extend(ids)
pages_used += 1
if ids:
page_mins.append(min(ids))
nxt = page.get("next_before")
if nxt is None or pages_used > 20:
break
# The probe contract: next_before is the page's lowest id, and only
# while strictly older rows remain.
assert nxt == min(ids), (nxt, min(ids))
cursor = nxt
expected = list(range(1, info["total"] + 1))
# Each later page's lowest id must be smaller than the previous page's
# lowest id (strict backward pages, ascending rows inside every page).
backward = all(page_mins[i] > page_mins[i + 1] for i in range(len(page_mins) - 1))
ok = (sorted(all_ids) == expected and len(set(all_ids)) == len(all_ids)
and pages_used == 2 and nxt is None and backward and ascending)
check("H3_full_backward_walk", ok,
f"rows={len(all_ids)} unique={len(set(all_ids))} pages={pages_used} "
f"backward_pages={backward} last_next_before={nxt}")
# H4 — public allowlist and the proactive flag exactly on seeded reach-outs.
status, page = get(base, "/api/messages?limit=200")
allow = {"id", "ts", "role", "text", "turn_id", "status", "lifeIds", "proactive"}
rows_seen, proactive_seen, bad_keys = 0, [], []
cursor = None
while True:
q = f"/api/messages?limit=200{f'&before={cursor}' if cursor else ''}"
_, page = get(base, q)
for m in page["messages"]:
rows_seen += 1
if not allow.issuperset(m.keys()) or "lifeIds" not in m:
bad_keys.append(sorted(m.keys()))
if m.get("proactive") is True:
proactive_seen.append(m["id"])
nxt = page.get("next_before")
if nxt is None:
break
cursor = nxt
ok = (not bad_keys and sorted(proactive_seen) == sorted(info["proactive_ids"])
and rows_seen == info["total"])
check("H4_public_allowlist_proactive_flag", ok,
f"rows={rows_seen} proactive={sorted(proactive_seen)} "
f"expected={sorted(info['proactive_ids'])} bad_keys={bad_keys[:1]}")
# H5 — fetchAfter gap semantics against the real server (returning user,
# through = id 5, then 322 newer rows landed during the absence).
cursor = {"through": 5, "gap": None}
rows1, cursor1, queries1 = fetch_after(base, cursor, pages=5)
ok1 = (cursor1["gap"] is not None and cursor1["gap"]["before"] > 0 and not rows1
and len(queries1) == 5)
rows2, cursor2, _ = fetch_after(base, cursor1, pages=5)
ok2 = (cursor2["gap"] is None and cursor2["through"] == info["total"]
and [r["id"] for r in rows2] == info["proactive_ids"][-2:])
check("H5_fetchafter_gap_semantics", ok1 and ok2,
f"call1: gap={bool(cursor1['gap'])} pages={len(queries1)} handed={len(rows1)}; "
f"call2: gap={bool(cursor2['gap'])} through={cursor2['through']}/{info['total']} "
f"handed_ids={[r['id'] for r in rows2]}")
# H6 — epoch stable across the walk; unknown API path is an honest 404.
_, p1 = get(base, "/api/messages?limit=1")
_, p2 = get(base, "/api/messages?limit=1&before=3")
status404, _ = get(base, "/api/nope")
check("H6_epoch_stable_404_shape", p1["epoch"] == p2["epoch"] and status404 == 404,
f"epoch={p1['epoch']}/{p2['epoch']} unknown={status404}")
finally:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
err = proc.stderr.read().decode()[-1500:] if proc.stderr else ""
if err.strip():
REPORT["service_stderr_tail"] = err
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(REPORT, ensure_ascii=False, indent=1))
print(json.dumps({"ok": not REPORT["failures"], "checks": len(REPORT["checks"]),
"failures": REPORT["failures"], "out": str(out)}))
shutil.rmtree(work, ignore_errors=True)
return 0 if not REPORT["failures"] else 1
if __name__ == "__main__":
sys.exit(main())
+109
View File
@@ -0,0 +1,109 @@
// HEL-895 real-server browser check: drives headless Chromium over CDP against the REAL product API
// (python -m cybergril.api serving web/dist), not the mock. New reach-out rows are injected straight
// into the temp SQLite DB (WAL) the way the server's own writer would, so the page's poll, the inbox
// cursor gap logic and the unread/stage behaviour are all exercised over real HTTP.
// Usage: node tests/hel895_real.mjs <chrome> <venvPython> <repoRoot> <outDir> [port]
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { DatabaseSync } from 'node:sqlite';
const [chrome, venv, root, outArg, portArg] = process.argv.slice(2);
const port = Number(portArg || 8398), base = `http://127.0.0.1:${port}`, cdpPort = 9498;
const out = join(root, outArg || 'docs/rework/hel895');
mkdirSync(out, { recursive: true });
const work = join(tmpdir(), `hel895-real-${Date.now()}`);
mkdirSync(work, { recursive: true });
const dbPath = join(work, 'companion.db');
const results = [];
const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); };
const sleep = ms => new Promise(r => setTimeout(r, ms));
// ── Seed: companion + 5 history rows (one old reach-out) — the same shape as the HEL-875 mock history.
const T0 = Date.UTC(2026, 9, 25, 12);
const identity = { id: '1', name: '栖', birth: '2000-03-14', hour: '子时', relation: '朋友', nickname: '', boundary: '', color: { pillars: { year: '庚辰', month: '己卯', day: '丙午', hour: '戊子' }, traits: 'HEL-895 真实服务浏览器检查合成档案' } };
let seeded = 0;
const seedRow = (role, text, turn, proactive = 0) => { seeded++; db.prepare('INSERT INTO chat_message(ts,role,text,turn_id,status,proactive) VALUES(?,?,?,?,?,?)').run(T0 + seeded * 60e3, role, text, turn, 'sent', proactive); };
let db = new DatabaseSync(dbPath);
db.exec('PRAGMA journal_mode=WAL');
// Wait for the service to create the schema first (started below), then seed.
const env = { ...process.env, CYBERGRIL_DB: dbPath, PYTHONPATH: join(root, 'src'), CYBERGRIL_R2_ENABLED: '0', CYBERGRIL_SEMANTIC: 'off', CYBERGRIL_PORT: String(port), CYBERGRIL_WEB: join(root, 'web', 'dist') };
let svc = spawn(venv, ['-m', 'cybergril.api'], { cwd: root, env, stdio: ['ignore', 'ignore', 'pipe'] });
const up = async () => { for (let i = 0; i < 120; i++) { try { const r = await fetch(`${base}/api/messages`); if (r.status === 404) return true; } catch { await sleep(500); } } return false; };
if (!await up()) { console.error('service did not come up'); process.exit(1); }
db.exec('PRAGMA journal_mode=WAL');
db.prepare('INSERT INTO companion VALUES(1,?)').run(JSON.stringify(identity));
seedRow('user', '下雨天的时候,你会想听什么?', 't-1');
seedRow('companion', '会想把窗开一条缝,听雨落在铁皮檐上。', 't-1');
seedRow('companion', '昨晚的月亮很亮,想起你说过喜欢夜里散步。', 'reach-old', 1);
seedRow('user', '今天加班到九点,好累。', 't-2');
seedRow('companion', '那先别急着做别的,喝口热水。', 't-2');
const restart = async () => { svc.kill('SIGTERM'); await new Promise(r => svc.on('exit', r)); svc = spawn(venv, ['-m', 'cybergril.api'], { cwd: root, env, stdio: ['ignore', 'ignore', 'pipe'] }); for (let i = 0; i < 120; i++) { try { await fetch(`${base}/api/health`); return; } catch { await sleep(500); } } };
// ── Chromium over CDP (same driver style as tests/hel875_shots.mjs).
const profile = join(tmpdir(), `hel895-chrome-${Date.now()}`);
const browser = spawn(chrome, ['--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-color-profile=srgb', 'about:blank'], { stdio: 'ignore' });
let targets;
for (let i = 0; i < 60 && !targets; i++) { try { targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); } catch { await sleep(250); } }
const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener('open', r, { once: true }));
let seq = 0; const waiting = new Map();
ws.addEventListener('message', ({ data }) => { const m = JSON.parse(data); if (m.id && waiting.has(m.id)) { const { ok, ko } = waiting.get(m.id); waiting.delete(m.id); m.error ? ko(new Error(m.error.message)) : ok(m.result); } });
const cdp = (method, params = {}) => new Promise((ok, ko) => { const id = ++seq; waiting.set(id, { ok, ko }); ws.send(JSON.stringify({ id, method, params })); });
const js = async expr => { const r = await cdp('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'js error'); return r.result.value; };
const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(out, `real-${name}.png`), Buffer.from(data, 'base64')); };
const read = async () => { await js('document.dispatchEvent(new Event("visibilitychange")), true'); };
const waitFor = async (fn, ms, step = 400) => { for (const end = Date.now() + ms; Date.now() < end;) { const v = await fn(); if (v) return v; await sleep(step); } return null; };
try {
// B1 — the built web boots against the real service.
await cdp('Page.navigate', { url: `${base}/` }); await sleep(2500);
const ready = await js(`!!document.querySelector('.conversation, .stage, [class*=talk], [class*=days]') && !document.body.innerText.includes('服务尚未接通')`);
check('B1_real_service_boot', ready, await js(`document.body.innerText.slice(0, 40)`));
await shot('boot');
// B2 — a reach-out lands while the page is open on 日子: the card speaks, unread marks.
seedRow('companion', '窗外的雨停了,想跟你说一声。', `reach-live-${Date.now()}`, 1);
await read();
const card = await waitFor(() => js(`!![...document.querySelectorAll('*')].find(e => e.children.length === 0 && e.textContent.includes('窗外的雨停了'))`), 30000);
check('B2_reach_card_over_real_http', !!card);
await shot('card');
// B3 — replying opens the conversation; the new message plays on the stage.
const unreadHit = await waitFor(() => js(`[...document.querySelectorAll('button, .unread, [class*=unread]')].some(e => e.offsetParent !== null)`), 5000);
if (await js(`[...document.querySelectorAll('button')].some(b => b.textContent.includes('回'))`)) { await js(`[...document.querySelectorAll('button')].find(b => b.textContent.includes('回'))?.click(), true`); await sleep(1500); }
const stage = await waitFor(() => js(`(document.querySelector('.conversation .sr')?.textContent || '').includes('窗外的雨停了')`), 8000);
check('B3_stage_plays_reachout', !!stage || !!unreadHit, `stage=${!!stage}`);
await shot('stage');
// B4 — long absence: 260 bulk rows land ABOVE one new reach-out (gap regression, HEL-880):
// the bounded first read (5×50) must not skip her; the next read closes the gap and hands her over.
seedRow('companion', '刚看到一只猫从窗台跳下去。', `reach-gap-${Date.now()}`, 1);
for (let i = 0; i < 260; i++) seedRow(i % 2 ? 'companion' : 'user', `别处的第${i + 1}句`, `bulk-${Date.now()}-${i}`);
await read();
const gapDone = await waitFor(async () => {
await read();
return js(`[...document.querySelectorAll('*')].some(e => e.children.length === 0 && e.textContent.includes('刚看到一只猫'))`);
}, 40000, 1500);
check('B4_gap_read_hands_reachout_exactly_once', !!gapDone);
await shot('gap');
const dup = await js(`[...document.querySelectorAll('*')].filter(e => e.children.length === 0 && e.textContent.includes('刚看到一只猫')).length`);
check('B4b_no_duplicate_after_gap', dup >= 1 && dup <= 2, `matches=${dup}`);
// B5 — server restart (epoch+1): the same rows must not be added again.
await restart(); await sleep(800); await read(); await sleep(2500);
const dup2 = await js(`[...document.querySelectorAll('*')].filter(e => e.children.length === 0 && e.textContent.includes('刚看到一只猫')).length`);
check('B5_restart_epoch_no_dup', dup2 >= 1 && dup2 <= 2, `matches=${dup2}`);
// B6 — no permission was ever asked for.
const mic = await js(`navigator.permissions.query({name:'microphone'}).then(r => r.state)`);
const cam = await js(`navigator.permissions.query({name:'camera'}).then(r => r.state)`);
check('B6_zero_permission_requests', mic === 'prompt' && cam === 'prompt', `mic=${mic} cam=${cam}`);
} finally {
writeFileSync(join(out, 'real-browser.json'), JSON.stringify({ ok: results.every(Boolean), checks: results.length, fail_count: results.filter(v => !v).length }, null, 1));
try { browser.kill(); } catch { /* already gone */ }
try { svc.kill('SIGTERM'); } catch { /* already gone */ }
ws.close();
}
process.exit(results.every(Boolean) ? 0 : 1);