Files
Cyrene-For-Android/devdocs/05-开发进度与交接.md
T

2347 lines
261 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 05 — 开发进度与交接
> 本文用于跨会话交接:记录当前状态、构建管线、环境要点、易忘的实现约定。
> 最后更新:2026-10-04
## 当前状态
- 分支 **`android`**,最新提交 **`664fd8d`**(= `origin/android`,全部已推)
## 构建(已全部在 PC 上)
开发已迁到 PC(Meowhome),**编译就在本地跑**:
```bat
cd /d D:\Project\Code\Uni\Cyrene-For-Android
gradlew.bat assembleDebug
gradlew.bat testDebugUnitTest
```
> 后端(Cyrene v2)也已迁到同一台 PC,App 的服务器地址指向它即可(见「后端」一节)。
增量编译约 20–40 秒。**每做完一个模块就 push 一次**(主人 2026-10-04 定的默认动作)。
## 验证方式(2026-10-04 主人修订)
**旧的「UI 改动不要自己截图验证」规矩已作废。** 那条规矩的前提是「项目跑在手机上、
主人当时在看别的屏幕,截图也截不到」。**现在开发在 PC 上,主人明确说可以直接抓模拟器窗口。**
所以现在**允许也应该**自己截图验证,方法两条一起用:
```bash
ADB="C:/Users/Aska/AppData/Local/Android/Sdk/platform-tools/adb.exe"
"$ADB" exec-out screencap -p > app/build/e2e-shots/xx.png # 像素
MSYS_NO_PATHCONV=1 "$ADB" shell uiautomator dump /sdcard/ui.xml # 结构化布局(含 bounds)
MSYS_NO_PATHCONV=1 "$ADB" shell cat /sdcard/ui.xml
```
- `uiautomator dump` 拿坐标最可靠(`bounds="[x1,y1][x2,y2]"`),驱动用 `input tap` / `input text`。
它读的是**结构化语义**而不是像素,所以定位点用它、判断观感用截图。
- 截图只落 `app/build/e2e-shots/`(该路径 gitignored),别散落到别处。
- **观感与设计取舍仍归主人拍板**——截图能让你把问题指清楚,但别替主人做审美结论。
### 凭据处理红线
- 真实账号口令**只许运行时用**,**绝不写进任何文件**(源码 / 测试 / 夹具 / 文档 / 临时文件 / commit)。
- **打印任何服务端响应时,`"token"` 和 `"refreshToken"` 都要盖掉**——
曾经只盖了 `token`,导致一个真实 refresh token 明文进了对话记录。
- **`adb logcat` 会记下 `input text` 的命令行本身**,所以用 `input text` 输入过凭据之后,
必须 `"$ADB" logcat -c` 清缓冲,且不要把含凭据的 logcat 存盘。
## 环境要点
| 角色 | 位置 / 说明 |
|---|---|
| 本机 SDK | `~/Android/Sdk`(`platforms/android-37.0` 手工装 + 手写 `package.xml`;`build-tools/37.0.0` 是 ARM64 原生版) |
| 本机 Gradle 配置 | `~/.gradle/gradle.properties`:`aapt2FromMavenOverride`、`sdkDownload=false`、`daemon=false` |
| PC SDK | `C:\Users\Aska\AppData\Local\Android\Sdk`(已补 `android-37.0`),`JAVA_HOME` = JDK 21 |
| PC 项目 | `D:\Project\Code\Uni\Cyrene-For-Android`(`local.properties` 指向 SDK) |
| 后端源码 | `D:\Project\Code\Uni\Cyrene_v2`(**开发期间只读,不得修改**) |
| 镜像 | Maven→阿里云、Gradle 发行包→腾讯、Android SDK 包→腾讯 |
## 后端(2026-10-04 实测确认在线)
| 项 | 值 |
|---|---|
| 端口 | **10942**。已实测确认:`cyrene` 监听 `0.0.0.0:10942`;`GET /api/v1/meta/enums` 无 token 回契约里的 `401 {"error":{"code":"unauthorized","message":"auth: unauthorized"}}` |
| 启停 / 状态 | `Cyrene_v2\scripts\ctrl.bat start|stop|status`(日志 `%USERPROFILE%\cy.log`,pid 文件 `%USERPROFILE%\.cyrene.pid`) |
| 局域网 / 真机 | `http://10.0.0.10:10942`(以太网,网关 `10.0.0.1`)|
| 其他可达地址 | ZeroTier `192.168.0.100` / `10.144.0.100`;Tailscale `100.99.64.15` |
| 登录凭据 | **不写进文档**(`.env` 属敏感,不读不记),问主人 |
**⚠️ 四个坑**:
1. **不要用 8080**。`.env.example` 的 `PORT` 默认值是 8080,但实际配置是 10942,而且
**这台机器的 8080 被一个 PHP 应用占着**(`X-Powered-By: PHP/8.4.1`,对 `/api/v1/*` 回 HTML 404)。
往 8080 打会得到一个「看起来像后端」的 HTML 404,很容易误判成后端起不来。
2. **后端不一定在跑**。2026-10-03 两次实测都是 down(无 cyrene 进程 / 10942 无人监听 /
`curl 127.0.0.1:10942` 连接被拒),2026-10-04 主人说「开了」之后确实在线。
用之前先 `scripts\ctrl.bat status` 确认,**别默认它在跑**。
3. **另一个会话正在实时开发后端**(主人 2026-10-03 说明),且后端仓库**开发期间只读、不得修改**。
因此上表的端口与 `02` 里记的端点形状**都可能随时漂移**——联调前重新核对一次
`Cyrene_v2/Docs/api-client.md` 的契约版本号,别拿旧版本号当依据。
4. **`.env.example` 里的账号只是模板**(`admin` / `admin12345`),2026-10-04 实测登录回
`401 invalid username or password`。
## 模拟器(2026-10-04 实测)
AVD:**`Pixel_10_Pro_XL`**(`emulator -list-avds` 里唯一一个)。SDK 工具在
`C:\Users\Aska\AppData\Local\Android\Sdk\{platform-tools\adb.exe, emulator\emulator.exe}`。
```bash
ADB="C:/Users/Aska/AppData/Local/Android/Sdk/platform-tools/adb.exe"
"$ADB" reverse tcp:10942 tcp:10942 # ← 必须先做,见下
"$ADB" install -r app/build/outputs/apk/debug/app-debug.apk
"$ADB" shell monkey -p top.yeij.cyrene -c android.intent.category.LAUNCHER 1
```
**⚠️ 这个 AVD 没有网络接口**:`ip -o addr` 只有 `lo` 和 `dummy0`,`ip route` 全空,
`ping 10.0.2.2` / `ping 10.0.2.1` 都是 `Network is unreachable`。
**所以模拟器里填 `http://10.0.2.2:10942` 是走不通的。**
绕法:用 **`adb reverse`**,然后把 App 的服务器地址填 **`http://127.0.0.1:10942`**。
已实测通过——设备内 `toybox nc 127.0.0.1 10942` 拿到了后端真实的
`HTTP/1.1 401 Unauthorized {"error":{"code":"unauthorized"}}`。
**设备内没有 `curl` / `wget` / `javac`**(`toybox` 无 `wget` 子命令),探活只能用 `nc`;
且 `nc` 要**把 stdin 撑住**才收得到响应体,否则只出一堆空行:
```bash
"$ADB" shell "(printf 'GET /api/v1/meta/enums HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n'; sleep 4) | toybox nc 127.0.0.1 10942"
```
**签名坑**:模拟器里原本装着一个 2026-05-26 的旧 `top.yeij.cyrene`,与本机 debug keystore
签名不匹配 → `INSTALL_FAILED_UPDATE_INCOMPATIBLE`。必须先 `adb uninstall top.yeij.cyrene` 再装。
**时钟提醒**:模拟器时钟比宿主机慢约 8 小时(约等于 UTC)。界面里 `at` 显示的「本地时间」
是模拟器时区格式化的结果,不是转换错误。
## 易忘的实现约定
- 包名 `top.yeij.cyrene`,应用名 **Cyrene**
- 版本:AGP **9.2.1**(**内置 Kotlin,不能再应用 `org.jetbrains.kotlin.android`**)/ Kotlin 2.4.0 / Compose BOM **2026.05.01** / Miuix **0.9.3** / Gradle 9.7.1 / compileSdk 37 / minSdk 33 / `buildToolsVersion = "37.0.0"`
- **基础图标是 `MiuixIcons.Basic.Xxx`**(文档写 `MiuixIcons.Xxx` 是错的,以 AAR 为准);部分图标在 `icon.extended.*` 下、按 `MiuixIcons.Xxx` 访问(如 `MiuixIcons.Add` / `MiuixIcons.Delete`)——加图标前先看现有 import,别猜
- 列表项一律用 **`BasicComponent`**(Miuix 所有 Preference 的底座,MD3 圆角卡片)——但**它本身没有背景和圆角**,必须外面包一层 `ui/common/PreferenceCard.kt`(`surfaceContainer` + 16dp 圆角);页面底色是 `surface`,两者在浅/深色下都能区分
- **本项目没有导航库**(无 NavHost、无 Navigation Compose)。路由是 `ChatHomeScreen.kt` 里手写的布尔状态 + `BackHandler` 分层。加新页面照这个模式来,别引入导航库
- 输入框在 `Scaffold.bottomBar` + `imePadding()`;**Manifest 必须 `windowSoftInputMode="adjustResize"`**(否则 IME 高度被算两次,输入框会被顶到顶部)
- 系统栏图标明暗在 `app/CyreneRoot.kt` 里随主题切换(`WindowInsetsControllerCompat`)
- 返回层级:管理页→聊天、设置页→聊天、抽屉打开→先关抽屉(`ChatHomeScreen` 里的 `BackHandler`),都关着才回桌面
- 聊天列表 `reverseLayout` + `followBottom` 逻辑(只在滚动结束时更新跟随状态)才能「新消息自动置底、用户上滑时不打扰」
- 文本选择用 `SelectionContainer` 逐块包裹(气泡/旁白/思维卡/工具卡各自独立,「全选」不跨块)
- Retrofit 3 的 kotlinx converter:Kotlin 侧必须用扩展函数 `json.asConverterFactory(mediaType)`(那个 `KotlinSerializationConverterFactory` 只是 JVM facade)
- 明文 HTTP 已放行(`res/xml/network_security_config.xml`),否则自部署地址会报「网络不可用」
- **DTO 不用 enum**:`mode` / `scope` / `kind` / `status` / `role` / `platform` / `mood` 在后端都是**开放集合**,一律 `String` + 客户端映射,未知值**原样显示**(不丢弃/不报错/不占位符)
- **服务端时间有两种形状**,不能共用一个解析器:**RFC3339 字符串**(trace / audit)与 **Unix 毫秒**(`backthink.at`)
- **`auth` 的 `expiresAt` 是 Unix 秒**(不是毫秒)——`AuthManager` 里已经 `* 1000` 再比,别改错方向
- **别假设「文本字段就是纯文本」**:后端的 `backthink.conclusion` 存的是**Web 端标记**
(`<p>` / `<action>` / `<narration>`,甚至有记录以一串空 `<p></p>` 开头)。
渲染前必须过 `domain/parse/ChatContentParser.parse()`——项目里就这么一处标记解析器,
**不要另写正则**。判断依据:只要字段可能来自 Web 渲染链路,就先 `parse()` 再渲染
### 管理区(C-2)的约定
- **权限真源是 `domain/admin/AdminAccess.kt` 的纯函数**(`AdminRole` / `AdminTier` / `AdminPage` /
`canAccess`)。**加一个管理页 = 在 `AdminPage` 里加一条枚举**,入口页、ViewModel、标题都按它驱动
- 角色取自 `container.authManager.state` 的 `SessionState.LoggedIn(baseUrl, role)`
- **后端明确要求客户端自行降级**(`Cyrene_v2/Docs/api-client.md`:「客户端需按本矩阵自行降级」)。
本端做三层 fail-closed:入口页禁用 / ViewModel 短路(**不发请求**)/ 服务端 403 也渲染「无权限」态。
**任何失败都不得渲染成「暂无数据」**——空态只在请求真的成功且列表为空时出现
- RBAC 分档(后端 `api-client.md` §2.0,与 `internal/services/gateway/routes.go` 逐条一致):
- **viewer 可读**(`viewerAuth`):`GET /admin/personas`、`/admin/observability/metrics`、`/admin/observability/trace` 等 12 条只读监控档
- **仅 owner/admin**:`/admin/memories`、`/admin/backthink`、`/admin/users`、`/admin/llm`、`/admin/observability/audit` 等 9 条
- **仅 owner**:35 条写操作 + 凭据路径
- **未知角色按 viewer 降级**(fail-closed),角色名显示原始字符串
- 后端路线:**手机端后续要做「全功能管理」**(`Cyrene_v2/DevDocs/工作小结.md` 主线②),
其凭据前置(`viewerAuth` 接线、契约 v0.40)**已完成**——所以管理区要做成**可加页的壳**,别写成只读死胡同
## C-2 的验证结论(2026-10-04)
**已实跑通过,不再是「只编译过」**。证据:
- 4 页均由 App 真实取数成功(logcat 里 `GET /api/v1/admin/...` 全 200,含响应字节数),
`FATAL EXCEPTION` 计数 0
- 5 个端点的**真实响应体**已作为夹具进 `AdminDtoRealPayloadTest`,全部一次解出,无需改动生产代码
- 截图留档(可自行复核):`app/build/e2e-shots/`
- 未覆盖:**角色降级路径**(手上只有 owner 账号,viewer/admin 的 403 分支、禁用行、
`AdminFailure.Forbidden` 只被单测覆盖);刷新、错误重试态、空列表态;系统返回键
## 坑与勘误(2026-10-04 汇总)
### 客户端结构陷阱(改代码前先知道)
- **每个 `ChatScreen` 的 VM 各建一条 `ChatSocket`**(`ChatScreen` 用 `viewModel(key = sessionId, …)`,
VM 内 `AppContainer.createChatSocket()`)。切会话后**旧 socket 与旧 VM 仍然活着、照收全量广播**
——而广播是发给**每条连接**、**没有按会话订阅**。所以任何「只看帧内容就渲染」的代码都会串台。
**凡流式帧,一律先按 `sessionId` 过滤。**(`2a49c25` 修的是 `history` 那一条)
- **结构化流帧本身就带 `sessionId` + `turnId`**(后端 v0.36 起)。历史上本端 `WsEvent.parse`
把这两个字段丢了,才让 A1/D14 挂了很久——**别再把「流式帧不带 sessionId」当事实**。
- **乐观消息归一化的真实规则是「待发列表里第一条文本完全相等」**(`ChatViewModel.onPersisted`),
**没有** role 判断、**没有**时间窗(`03-客户端细化开发方案.md:57` 的描述与代码不符,以代码为准)。
- **WS 帧超过 4096 字节不是报错,是直接断连**(`ReadMessage` 出错 → `readPump` 退出 → 关连接重连)。
所以**永远不要**把长文本 / base64 图片塞进 WS 帧。
- **`POST /chat` 的 `stream:true` 走有损总线、且先回 200 再问 orchestrator** →
**200 不等于已受理**,要以 `message_persisted` / `session_busy` 是否到达为准;全链路**无幂等**,
**绝不自动重试**。见 `02/G2`、`02/G4`。
- **base64 图片会被原样落库**,于是每页历史都把它再传一遍 → **发图前先缩放 / 重编码**。见 `02/G3`。
- **本地快照一律带 `baseUrl|username` 指纹闸门**:读侧过滤、登出清空(`EnumPreferences` /
`SessionPreferences` 两个实现已对齐)。新加 last-good 快照时照这个来,否则换账号后会闪上个账号的数据。
### 工作流教训
- **子代理的自述不是证据,必须自己复核。** 实例:一个子代理报「没有任何凭据写进文件」,
而 `EnumPrefsTest.kt` 里有真实账号名(已改成中性夹具)。**每个模块都要自己 grep + 直接读 JUnit XML。**
- **编译 + 单测全绿也可能功能静默失效,截图能抓到。** 实例:抽屉「打开时刷新」第一次错误地挂在
`onOpenChange` 上,而 `MiuixDrawer` **只在关闭时**回调它——功能完全没生效,是截图发现的。
这正是 2026-10-04 放开「允许自己截图验证」的直接价值。
- **看像素能抓到断言抓不到的排版问题。** 实例:预览剥标签时把旁白与正文粘成一个词
(`…凑近了一点今天下午…`),单测全绿,是看截图才发现的。
- **别给要常驻的进程设超时。** 实例:模拟器以 1800s 上限后台启动,到点被宿主收割,
连带 `adb reverse` 隧道一起没了。**模拟器不加超时。**
### 文档勘误(已被实测推翻,别再引用)
| 文档 | 写的 | 实际 |
|---|---|---|
| `02/A1` 正文、`03:114-115`(D14) | 流式帧**不带** `sessionId` | 后端 v0.36 起**带** `sessionId`+`turnId`;是本端解析器丢了字段(见上「结构陷阱」) |
| `03:57` | 乐观消息按「同 role + 时间窗 + 文本匹配」归一 | 实际只在待发列表里按**文本完全相等**匹配,无 role、无时间窗 |
| `Cyrene_v2/Docs/api-reference.md:1142`(§6.6) | OnStream 结构只填 `seq`、不带 turnId | 实际已打 `TurnID` 并上帧;`api-client.md:525`(v0.36)记的就是这事 |
| `Cyrene_v2/DevDocs/02-development-constraints.md:159` | 文件上传限 50MB | 实现是**硬 5 MiB**(`upload.go:24`),无配置项可覆盖(见 `02/F3`) |
### worktree 并行:约定与坑(下个会话必读)
**本轮新增约定 / 坑(下个会话必读)**
- **worktree 并行必须给绝对路径**:W1 曾用相对路径编辑,结果写进了**主树**(10 分钟),
靠「worktree 构建在改动后仍报 `UP-TO-DATE`」这个反常信号才发现。**派 agent 进 worktree 时,
任务书里必须写明「只用绝对路径」**。(当次已自检+窄回退,我复核主树逐字节等于 HEAD,无损伤。)
- **删 worktree 目录别用 git 自带的**:Windows 会报 `Filename too long`(`MAX_PATH`,Gradle 的
`build/` 路径太深)⇒ 用 **Git Bash 的 `rm -rf`**,它能处理。
- **`/upload` 产物的 URL 绑死当时的 host**(`r.Host` 拼绝对地址 + 原样落库)⇒ **换服务器地址后旧图取不回**。
已登记 `02/F6①`。**上传也不能突破 5 MiB**(`02/F6②`:那个上限只对 base64 形态成立,URL 受视觉层 50 MiB 约束)。
- **`intent` 共享 socket 债又加一条**:`SessionsViewModel` 现在**自己持有一条 socket**(登录连、登出/
`onCleared` 关)——因为 `AppContainer`/`ChatSocket` 归属别的 agent。收敛方向仍是**一条应用级共享 socket
+ 按会话路由**。
- **`chat.documents` 契约形状**(v0.59,别提前写死):**数组 ≤5**(不是单对象 —— 单对象会让 1 回合 5 个附件
里 4 个从历史消失);响应/历史 DTO 为 `"documents":[{name,mime,size,chars,truncated,status}]`;
`status:"unreadable"` 才是「昔涟读不到」的信号;**文档一律走 REST**;**只收服务端存储名、不收 URL**。
### 派 agent 进 worktree 的硬规矩
**派 agent 进 worktree 的硬规矩(沿用上轮教训)**:任务书必须写明「**只用绝对路径**」(上轮有 agent 用
相对路径把改动写进了主树);**不许**碰 adb/模拟器、**不许** push/merge、**不许**动构建文件
(`build.gradle.kts` / `settings.gradle.kts` / `libs.versions.toml` / `local.properties`)。
## 后端协同
见 `02-前后端协同事项.md`。开发中一旦遇到后端不支持或未实现,**当场写进该文件并反馈主人**。
**当前状态(2026-10-04)**:`02` 的 A/B 两档绝大多数已闭环——B4(枚举权威表)与 B2(token refresh)
本轮由客户端接入,A4/D5/B3(会话标题 / 置顶 / 预览)也已接入,**A1 已由后端修复、约束只剩客户端一份**
(见上方「文档勘误」)。**尚未闭环的高优先项**:`F3`(上传图片的 URL 在自部署链路取不到 →
B1 的大图场景疑似不通)、`G2`(`stream:true` 可能静默不生效)、`G4`(全链路无幂等)。
## 实机验收方法(2026-10-04 深夜晚补,含本轮踩到的坑)
**图片/大图路径的验收脚本**(已跑通两遍,可直接复用):
```bash
ADB="C:/Users/Aska/AppData/Local/Android/Sdk/platform-tools/adb.exe"
"$ADB" exec-out screencap -p > app/build/e2e-shots/bigtest.png # 天然 >64 KiB,正好走上传
MSYS_NO_PATHCONV=1 "$ADB" push app/build/e2e-shots/bigtest.png /sdcard/Pictures/cyrene-bigtest.png
MSYS_NO_PATHCONV=1 "$ADB" shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE \
-d file:///sdcard/Pictures/cyrene-bigtest.png
# 然后:抽屉 → 进临时会话 → 点「发送图片」(content-desc) → Photo Picker 选图 → 点「发送」
```
**⚠️ Photo Picker 的坐标每轮都不同**(同一个 picker,缩略图两次分别在 `[0,1709][329,2038]` 与
`[0,743][329,1072]`)。**必须每轮重新 `uiautomator dump` 取 `content-desc="Photo taken on …"` 的
bounds 再点**,别复用上一轮坐标(本轮第一次就是按旧坐标点空、白等一轮)。同理,`uiautomator dump`
在**转场动画期间会拿到旧内容**——出手前先睡 3 秒并确认顶部是 `MainActivity`。
**给临时会话起唯一标题**(`POST /sessions` **不收** title,要再 `PATCH` 一次):
```bash
SID=$(curl -s -X POST $BASE/api/v1/sessions -H "Authorization: Bearer $TOK" \
-H 'Content-Type: application/json' -d '{}' \
| /c/Python312/python -c "import sys,json;print(json.load(sys.stdin)['data']['sessionId'])") # ← 键是 sessionId
curl -s -X PATCH "$BASE/api/v1/sessions/$SID" -H "Authorization: Bearer $TOK" \
-H 'Content-Type: application/json' -d '{"title":"E2E-XXX"}' # 抽屉里就能按标题定位行
```
- 本机**没有 `jq`**(`which jq` 为空)→ 解析 JSON 用 **`/c/Python312/python`**。
- 驱动 UI 用 `uiautomator dump` + `tr -d '\r' | sed 's/></>\n</g'` 抽 `text` / `content-desc` 与 `bounds`;
**像素截图只用来判断观感**(本轮就是靠截图发现「缩略图里其实是『App 设置』页」这个关键事实的)。
**从「另一个 App」触发分享的验收手段**(不需要装第二个 App):
```bash
"$ADB" shell am start -a android.intent.action.SEND -t text/plain \
--es android.intent.extra.TEXT "分享测试"
"$ADB" shell content query --uri content://media/external/images/media \
--projection _id:_display_name --where "_display_name='cyrene-bigtest.png'" # 取 media id
"$ADB" shell am start -a android.intent.action.SEND -t image/png -f 0x00000001 \
--eu android.intent.extra.STREAM content://media/external/images/media/<id>
```
- **改动前的基线**(2026-10-04 实测):`am start -a …SEND -t text/plain` 起的是系统 resolver,
Cyrene **不在候选里**(`pm query-activities -a android.intent.action.SEND -t text/plain` 只有 Chrome /
Drive 等)。分享实现后这条命令应当能选中 Cyrene。
- `-f 0x00000001` = `FLAG_GRANT_READ_URI_PERMISSION`,**必须带**,否则 App 读不到那个 `content://`。
- 「未登录先暂存」的验法:先在 App 里登出 → 分享 → 再登录 → 看内容是否落地。
### 三条待拍板项的**定案**(2026-10-04,主人:「按你推荐的来」)
1. **概览页「按路径」呈现** —— **保持 W4 的实现,不再改**:分组键 = 剥掉 `/api/v1` 后的首段;条数 >4 的组默认收起、≤4 的默认展开;只有一条路径的组不出组头;路径等宽字体 + 段级中间省略。
2. **新建会话选角色的交互** —— **保持 W5 的实现,不再改**:抽屉「+」→ 弹层(点行选中 → 底部「创建」);角色名挂在**会话行摘要前**(未绑 = 不显示;角色表里查不到该 id = 原样显示 id,不猜、无硬编码映射)。
3. **文档 / 「超限走文件」的两个产品决定**(W6 只做了数据层,接线按这两条来):
- **不做「长正文自动转文件」。** 理由:`POST /api/v1/chat` **没有正文长度上限**(`02` 已核:那个 4000 rune 只属于 `PATCH /api/v1/messages/{id}`),长正文**已经**由既有 `ChatSendRouting` 走 REST;把用户打的正文悄悄换成附件,只会让「历史里搜不到、气泡变成 chip」,是净损失。⇒ **转文件只用于真正的文件附件**(从别的 App 分享进来的文件、或用户自己选的文件)。
- **附件与正文各自独立存在**:用户自己打的字照发,附件随 `chat.documents` 一起发(服务端两种都支持,**纯附件回合也合法**)。**绝不把正文搬进附件**,所以也就没有「转文件后正文还发不发」这个问题。
- 接线时必须如实告知:`document_unreadable`(400) 客户端**基本触发不到**(那要显式索取正文,而 `document-read` 是服务端工具);客户端实际能看到的信号是 DTO 里的 `status:"unreadable"`。`truncated` 只表示**注入被预算截断**,**不是**「读不到」——两个信号不许互相顶替。
- 附件上行载荷:`"documents":[{"name":"<32hex>.<ext>"}]`,**≤5 条、只给服务端存储名**;带附件一律走 REST(客户端策略)。**`name` 是唯一上行字段**。
## ★ 当前状态与待办(恢复锚点 · 2026-10-04 深夜)
> **压缩/换会话后先读这一节**,它是对「此刻到底在哪」的一句话答案;细节在上面各批次里。
> ⚠️ **历史批次的细节(W4–W7 起各批)已移到同目录 `05-archive-历史批次.md`** —— 这里说的「上面各批次」都在那份里,按原文顺序保留;本节的各锚点仍在主文件。
### 状态
- **`android` = `origin/android` = `85a922c`,工作区干净**,**891 测试 / 0 失败 / 0 错误**(`--rerun-tasks` 全量重跑后自己读 JUnit XML 求和)。
- 可安装包:`app/build/outputs/apk/debug/app-debug.apk`。
- 那轮「装机验收」的**四项全部有设备证据**(含 ① 的更正:本来就是好的,是我测量假象):见上一节「装机验收轮」。
- **联调凭据(压缩后最容易丢,务必先看这条)**:后端调试用的 `X-API-Key` **已由主人授权落地到本机 `dev-secrets.local.md`**(仓库根目录,**已 gitignore、不在仓库里**——已用 `git check-ignore` / `git ls-files` 双重核对)。用法:`curl -H "X-API-Key: <该文件里的值>" $BASE/api/v1/…`,**owner 级**(`/sessions`、`/admin/observability/{metrics,logs}`、`/admin/system/storage`、`/admin/personas` 全 `200`;不带 key 回 `401`)。**上一个 key(`rG14nMNX…`)已失效(实测 `401`),不要再试。** 轮换只改那个文件,代码/文档里没有任何硬编码。
- **`devdocs/` 本身是入库的**(主人 2026-10-04 授权),所以本锚点跟着仓库走(`git log devdocs/` 可查);但**凭据绝不进 `devdocs/`**,只在那份 gitignore 的文件里。
- 模拟器:AVD `Pixel_10_Pro_XL`,**启动需加 `-memory 6144`**(否则 2 GB 会自己 ANR,见「踩到的坑」),`adb reverse tcp:10942 tcp:10942`,App 服务器地址填 `http://127.0.0.1:10942`。
### 本轮(W8→W22)已落地并推送的模块(一行一个)
| # | 内容 | 提交 |
|---|---|---|
| W8 | 设置页「版本」行 → **关于页** | `3ed2377`→`f24877d` |
| W10 | **可观测性三帧**(metrics/trace/log)→ 概览实时刷新 + 最近日志 | `b5e6f78`→`93941cd` |
| W9 | **文档附件全链路**(选文件 / 待发芯片 / 上传 / `chat.documents` / 气泡芯片 / 分享文件走文档) | `30180ab`→`ab76798` |
| — | 契约 v0.59 已发布 → 更正旧注释;`devdocs/` **入库** | `2973391` `2369dd2` |
| W13 | **队列满处置那条气泡**(`pending_full` / 定向 `chat_busy` 的 `clientMsgId`) | `3b4a790`→`9854cc4` |
| W14 | **图片全屏查看** + 打开/保存 | `557ef3f`→`f109846` |
| W15 | **重试复用幂等键**(按内容指纹) | `af11a94`→`8b90703` |
| W16 | **会话内搜索**(顶栏入口 / 面板 / 点结果定位) | `23b46f9`→`46ac15a` |
| W17 | 重试记忆改**多槽** | `d3fc0c8`→`440e441` |
| W19 | **单条消息编辑与删除** | `3518917`→`ea16f48` |
| W18 | **跟上 v0.61 的 `.pdf`**(App 内预览对非文本拒收,不再出乱码) | `48e4188`→`1f9d1dd` |
| W21 | 重试记忆加 **10 分钟时间上界** | `8938fff`→`df63783` |
(W20/W22 是**修「非 bug」的空转**,零改动,见上一节的教训。)
### ★ 本轮(压缩恢复后 · 2026-10-04 深夜续)已落地并推送
| # | 内容 | 提交 |
|---|---|---|
| — | 收下后端**回执十一**(四点确认 + `knowledge` 已立案 + IM-1/U1 在实施) | `e4915a6` |
| A5 | **`after` 正向增量**(断线补洞):掉线记游标 / 重连正向追 / 补拉循环抽成纯函数 | `28c3142` |
| — | **`404 session_not_found` 兜底**:非主会话读不到 ⇒ 如实说一句 + 自动退回主会话 | `28c3142` |
| W23 | 接上契约 v0.63 的 **`documents[].reason`**(只在 `unreadable` 时下发,原样显示) | `2d382b1`(`e5fa1b8`) |
| W24 | **编辑弹层只显示纯文本**(真有标记时才剥;弹层明说会替换掉旁白) | `1794e53`(`66c2291`) |
**这一轮的两条关键事实(复用价值高)**
1. **可编辑的只有 `user` 消息,而 user 消息存的是用户原文、从不带标记** —— 我扫过线上主会话 64 条 user 消息,**零条含标记**;所有 `<p>` / `<action>` 都在 assistant 消息里(assistant 不可编辑)。所以 W24 的「剥标记」在今天是一条**防御性口径**,对真实数据是恒等变换;`hasMarkup` 的判据因此取的是「`text != content`」(= 保存真的会替换掉什么),而不是「出现过 `<`」。
2. **`after` 是升序、`before` 是倒序**,且 `after` 方向的下一条游标是**本页最新**那条(不是最旧的)。服务端按 `limit` 从最旧一端给,所以取最旧当游标等于原地不动。同毫秒并列超过一页时纯 `createdAt` 游标推不动(契约 §A5 明写的边界)⇒ 循环里必须有「游标严格前进」+「轮数上限」两道闸,否则无限请求。
### 待办(按优先级)
1. **W19 剩下的那一处**:**删除后抽屉里的「最后消息预览」不刷新**(本端只改了消息列表,没触发 `GET /sessions`)。① 编辑口径已由主人拍板并落地(W24)。
2. ~~**遗留 ①:连接所有权**~~ ✅ **已闭环(W28,`7b97440`)**:现在**进程内共享一条 `ChatSocket`**(`AppContainer` 唯一持有、`SessionsViewModel` 是唯一 owner),连接数从 **N+2 降到 1**。装机验收见「补记四」。
3. **`ChatSocket.sendInterrupt` 零调用方**(「切会话时打断」这个出口从未接线,是死 API)。
4. `observability_audit` 帧(**后端未做**,审计仍是 REST);`POST /admin/users` 仍是 `501` ⇒ 铸不出 `viewer` 账号,viewer 降级只能单测背书。
5. 子会话流程(主人已确认搁置)。
### 本轮**未验证**的(别当成已验证)
- **`after` 的设备侧验收**:真机上**确实打出过**真请求 —— `GET /sessions/web/messages?limit=50&after=1791121966668` → `200`(说明「重连补洞」那条路真的在跑)。但**「断线超过 50 条」那个洞没造出来**(要造得让后端不可达再灌消息,而后端当时正被另一个会话实时开发,我实测到一次 `HTTP 000`)⇒ **补洞的端到端仍是未验证**。逻辑侧由 `ChatRecoveryTest`(循环两道闸)与 `MessageAfterRepositoryTest`(真 OkHttp 断言线上 URI)钉住。
- **`404 session_not_found` 兜底:已装机端到端验通**(`b5c0b13`)。证据:logcat `GET /sessions/web:{已删}/messages?limit=50` → **404** →(0.9 s 后)`GET /sessions` → 界面回主会话;红字横幅「这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话」可见(`app/build/e2e-shots/w7-gone-2.png`)。**第一次验收它是失败的** —— 切换对了、横幅一个字没显示(原因见下面那条新教训),修完才通过。
- **W24 的编辑弹层:弹层本身在设备上开过**(user 消息 → ⋯ → 编辑;弹层正常,计数 `0 / 4000`、保存键按预期禁用)。**但没验到「非空初值」**:主会话里那一片 user 消息**全是纯附件回合、`content` 长度为 0**(已对着 `GET /messages` 逐条核过),初值本来就是空串。**也没验到「原文带标记」那句告知**(线上 64 条 user 消息零条含标记,见上)。⇒ 「剥标记」那条路径仍**只有单测背书**。
- **W23 的 `reason` 弹层显示**:纯逻辑链(DTO → chips → previewPlan → 弹层文案)由 17 条单测打通,**没有在设备上点开过一个真正 `unreadable` 的附件**。
- **已用设备证据确证的那一条**:待发附件的**小预览框 + 叉**(主人点名要的那个)**本来就存在且可用** —— 截图 `app/build/e2e-shots/v4-draft.png`(`attach-demo.txt / TXT · 45 B` + 叉,挂在输入框上方)与 `v5-removed.png`(点叉后预览框消失、发送键复灰)。**零代码改动**。
### 家务(**2026-10-04 深夜续已办完**)
- **三条验收会话已删**(主人放行后执行,带证据):`E2E-DOC`(1 条消息)/ `E2E-PDF`(2 条)/
`E2E-RETRY`(2 条)三条 `DELETE /api/v1/sessions/{id}` 全 `200`;现在服务端只剩主会话 `web`。
- **18 个 worktree 已全部清除**(W4–W19 + W23/W24),用的是 **Git Bash 的 `rm -rf`**(Windows 直接用
git 自带的删会撞 `MAX_PATH`),随后 `git worktree prune`;`git worktree list` 现在只剩主树。
**分支引用全部保留**(每个都 `git merge-base --is-ancestor <branch> android` 验过已并入,
留着方便回退)。
- 清理手法留在下面的「构建与环境」一节:这两个动作都是**不可逆**的,删之前先跑一次
「每个分支是否已并入」的检查。
### 与本轮配套的两条新纪律(已进上面的教训段,这里再点一次)
1. **动代码之前先用像素确认症状**;`uiautomator dump` 只用来取坐标。
2. **症状与代码结构矛盾时,先怀疑自己的测量**。
### 还有一条:那个「未响应弹窗」的真相(主人问过两次)
- 现象:模拟器隔一阵弹 `Cyrene isn't responding` / `Process system isn't responding`。
- **证据**:我在 40 秒内连跑 3 次 `uiautomator dump`,紧接着一次 `adb shell input tap` **48 秒才返回**;
WindowManager 报 `ANR in top.yeij.cyrene … Waited 5121ms for MotionEvent`,**而同一段日志里还有
`PointerEventDispatcher0 is not responding. Waited 37729ms`** —— 系统自己的输入分发器都卡了
37 秒,那不是 App 的 bug。紧接着就弹了 `Process system isn't responding`。
- ⇒ **`uiautomator dump` 要省着用**(它是整棵视图树的重量级遍历),一次够用就够;一次交互超过
几秒没回来就当**环境卡了**,别记成 App 的 bug。真卡住了就 `adb emu kill` 重启(本次重启后
同一操作的延迟从 48 s 回到 0.27 s)。
> **★ 锚点补记(2026-10-04 深夜 · 压缩前落)**:后端回了 **回执十**,契约升到 **v0.63**,其中两条**直接冲我们**:
>
> 1. **`documents[].reason`(可选)** —— 只在 `status:"unreadable"` 时下发;文案是**他们注入模型清单里的同一份字符串**(10 条,例如「无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」)。⚠️ **别做整句相等判断**(真实字符串比登记里给我看的更完整),要分支就用稳定子串(如「需要 OCR」),**默认原样显示**。
> ⇒ **新待办**:客户端接上它(现在我们只能显示通用的「读不到」,W18 那条缺口就是它)。
> 2. **`GET /sessions/{id}/messages` 对不存在/已删除会话改为 `404 session_not_found`**(行为变更;**存在但空**仍是 `200 {"data":[]}`)。**隔离会话 `sess-*` 也回 404**,而**我们的 App 不会按 `sess-*` 拉消息**(已核代码:id 只来自 `GET/POST /sessions`;`sess-` 只在两处注释里出现)⇒ 已答复他们「不必加例外」。
> ⇒ **新待办**:用这个 404 做「**当前会话已被删除 ⇒ 自动退回主会话 + 如实说一句**」的兜底(就是主人那次「图片凭空消失」的体验问题)。
>
> 另有 **v0.62** 的 `POST /admin/llm/reset-from-env`(owner-only 写、要 `confirm:true`)——客户端暂无 LLM 管理页,**纯增量、不用管**。
### 又一条硬教训:**一次性告知不能挂在「会被销毁的状态」上**
404 兜底的第一版把「会话已删」那句横幅放在 `ChatUiState.sessionGone` 上。逻辑看着完全对,真机上
**用户一个字都看不到**:
`onSessionGone` 一被调用 ⇒ `activeSessionId` 收敛回主会话 ⇒ `ChatScreen` 的 `key` 变化 ⇒
**承载那面横幅的 VM 连同状态立刻被销毁**。
⇒ 规矩:**告知要挂到活得比它久的那一层**(这里是 `ChatHomeScreen` 的 `remember`),并且
**先立告知、再触发刷新** —— 顺序反了同样看不到。
这类 bug 单测抓不到、编译也不报,只有**真机看一眼**才会发现。→ 又一个「UI 改动必须真的看一遍」的证据。
> **★ 锚点补记二(2026-10-04 深夜续 · 压缩前落)**:本轮把**三条待办做完了两条半**,另收了后端
> **回执十二**(`knowledge` 根因 + 并行 `tool_use` 配对的新 P0 + **契约 v0.64 的 `GET /admin/llm/usage`**)。
>
> 1. **`after` 正向增量已接**(`28c3142`):掉线记游标 / 重连正向追 / 循环抽成纯函数 `loadForwardPages`
> (两道闸:游标严格前进 + 20 轮上限)。**真机打出过真请求**,但「断线超 50 条」的洞没造出来。
> 2. **`documents[].reason` 已接**(W23 / `2d382b1`):弹层里**原样显示**他们那句,芯片仍是「读不到」。
> ⚠️ 一处**我们主动选的口径**:`unreadable` 且 `mime` 是 `text/` 时让 `reason` **压过**正文渲染
> (理由与代价都写进了 `02` 的登记,等他们确认)。
> 3. **`404 session_not_found` 兜底已装机验通**(`28c3142` + 修复 `b5c0b13`)。
> 4. **W24 编辑弹层只显示纯文本**(`1796e53`→`66c2291` 合并为 `1794e53`):真有标记才剥;
> `hasMarkup` 取「`text != content`」(= 保存真的会替换掉什么),弹层明说会替换掉旁白/动作。
> 5. **家务已办完**(3 条验收会话 + 18 个 worktree,见上一节)。
> 6. **后端那边与我们有关的两条**:`knowledge` 的修法已经落地(**历史里会落一条诚实说明**,我们在
> `89776fe3…` 那条会话里亲眼看到);并行 `tool_use` 配对的 P0 在修,**客户端不需要适配**。
> 7. **`.env` 新增 `LLM_MULTIMODAL`**(主人加的)⇒ 主模型能识图,`H1` 那条结构上消失。
> **我们还没做「如果主模型本身能识图」的客户端侧跟进**,等主人指路。
---
> **★ 锚点补记三(2026-10-04 深夜续二 · 自主推进一批)**:主人去睡了,我按主线往下推 + 做了一次只读审计。
> 这一批**全部是「界面上/文档上声称能做到、代码里其实没接线」类的问题**(主人最恨的一类)。
>
> ### 本批落地(HEAD `85a922c`,891 测试 / 0 失败)
>
> | # | 内容 | 提交 |
> |---|---|---|
> | 审计 | **只读审计**(假开关 / 死代码 / UI 规范 / 文档漂移四张清单,每条带 `文件:行号`) | 无提交(产出清单) |
> | W25 | **设置页的「打断模式」真接线** —— 它此前是个**假开关** | `e4c6c7c`(`cfdc094`) |
> | W26 | **`stream_chunk` 不再静默丢正文** + **错误横幅可关闭** + **分享不再自相打断** + 两处死代码 | `b7ac6b8`(`eacc65a`) |
> | W27 | 删只被测试养的 `availablePages` + **图片 base64 上限公式去重** + `03` 四处文档更正 | `b7ac6b8`(`805d8b5`) |
> | — | `WsEvent.SessionInterrupted.dropped` 的过时 KDoc 更正 | `85a922c` |
>
> ### 三个「假开关 / 静默」的真相(这批最值钱的东西)
>
> 1. **设置页的「她正忙时按发送的行为」是个假开关**:`InterruptMode` 能读能写能显示,
> **全仓没有任何发送路径读它**(连 `ChatSocket.sendInterrupt` 都是零调用方的死 API)。
> 用户切成「打断」,行为与「追加队列」一模一样。⇒ 现已接上:默认档**零行为变化**,
> 「打断」档且会话正忙时先发 `interrupt{scope:"current"}`(**保留**待处理队列)。
> 判据抽成纯函数 `interruptScopeForSend(mode, busy)`,8 条测试钉住两档 × 忙闲穷举。
> 2. **`stream_chunk` 被静默丢掉**:契约明写它是**非流式回退路径**(「未映射/无法确定的事件」),
> 而 `onEvent` 的兜底是 `else -> Unit` ⇒ **只要服务端走那条路,整段回复一个字都不显示、且没有任何信号**。
> 现已把它当 `delta` 喂进同一个流式解析器。(它与结构化帧**互斥**,依据是契约那句「回退路径」。)
> 3. **分享多图会自相打断**:W25 接线后暴露 —— 分享 N 张图 = N 次投递,第 2 张起各发一条 `interrupt`
> 把前一张刚起的回合取消掉。⇒ 分享路径一律 `allowInterrupt = false`(批量动作自相残杀一定是错的)。
>
> ### 两条「查完发现不是 bug」的(**别再返工**)
>
> 1. **「删除后抽屉里的最后消息预览不刷新」——不是 bug**(W19 留下的待办)。
> 证据两面:① **服务端实时重算** —— 我用 REST 实测:删掉某会话最后一条 assistant 消息后,
> `GET /sessions` 的 `lastMessage` **立刻**退回前一条 user 消息;② **客户端每次打开抽屉都会
> `refresh(silent = true)`**(`ChatHomeScreen` 的 `onOpenDrawer`)⇒ 用户看到的是新预览。
> 唯一剩下的极端场景是「抽屉**正开着**时别的端删了消息」——那属于「抽屉不订阅 `message_deleted`」,
> 是**另一个**(更小的)话题,别和这条混起来。
> 2. **`session_interrupted{dropped}` 的「已打断,丢弃 N 条」在本端不可达**:契约 v0.56 明文
> `scope:"current"` 时 `dropped` 恒为 0,而本端**从不发 `scope:"all"`**(见上 W25)。
> ⇒ 只更正了文档与 KDoc(`03` §4 与 `WsEvent.kt`),**没有**去实现一个用不上的计数。
>
> ### 装机验证(有设备证据的)
>
> - **错误横幅的关闭叉:验通**。造错手法:**撤掉 `adb reverse` 后重开 App**(老教训:`--remove`
> 断不开已建立的连接,得让 App 重开会话)⇒ `ConnectException` ⇒ 抽屉底部出现红字「无法连接到服务器」
> + 右侧 ✕(`app/build/e2e-shots/x2-drawer-error.png`);点 ✕ 后那条消失、
> 而「网络异常,显示 刚刚 的结果」**正确保留**(`x3-dismissed.png`)—— 两件事本来就是两个信号。
> - **`stream_chunk` 的端到端仍未验**(要服务端真走进回退路径;本端没法自己造),
> 收益侧是「与 `MessageDelta` 逐字同路」,风险侧只有契约那两句话作依据。
> - **W25 的打断帧未在真机上看过发出**(要「偏好切成打断 + 会话正忙 + 发一条」三条同时满足;
> 后端在另一个会话手里,我没有主动去占它的回合)。逻辑侧由 8 条纯函数测试钉住。
> - **登录页的输入框是否被 IME 遮挡:未验**。审计把它标成「存疑」(字面违反「输入框在 `bottomBar` +
> `imePadding()`」这条约定,但 `LoginScreen` 有 `verticalScroll` + Manifest 是 `adjustResize`,
> `TextField` 通常自己会把光标带进可视区)。**要验就得清空 App 数据回到登录页**——
> 风险是万一登不回去,今晚所有装机验证能力一起没了,所以我**没做**,如实留在这里。
> 判断:**风险低但不是零**;主人下次在干净设备上装一次就能一眼定论。
>
> ### 本批**没做**的(含一个我刻意不做的)
>
> - **连接所有权(共享 socket + 按会话路由)**:**没有动手**。它是架构级改动,
> 动到四条 VM 的帧路由,而今晚模拟器状态不稳、后端在另一个会话手里 ——
> 一旦引入回归,我**未必能当场发现**(这比不做更糟)。已写设计要点与验收清单(见本节末)。
> - **`DocumentDelivery` 里那组「正文转文件」的判决 API**(`plan` / `textAsFile` / `textNeedsRest`
> + `AsDocuments.textAsDocument` 字段)**没有删**,也**没有接线**:所有权归属有**口径冲突**——
> `05` 的三条定案写着「**不做**长正文自动转文件」(理由:`POST /chat` 没有正文长度上限,长正文
> 已经走 REST 了),而主人后来在 G 条里点名「**超限走文件也要做**」。两者多半指的不是同一件事
> (前者 = 用户**在 App 里打的字**自动变附件;后者 = 从别的 App **分享**进来的超长内容走文件),
> 但我**不替主人合并这两个口径**。已登记进 `02` 请他拍板。
> - 另有一条 W26 留下的边界记在这里备查:**回退路径没有 `message.start`** ⇒
> `MessageStart(index==0)` 里那句 `freshTurnIfFinished()` 在回退轮次不触发;日常看不见问题
> (落库/`stream_end` 各自会收尾),只有「上一轮被取消且没落库 + 紧接着来一轮回退流」才会让
> 上一轮的思维链/工具卡滞留。加它属于行为增强,当时按「只加消费分支」停手了。
>
> ### 连接所有权的设计要点(**下一批做它之前先读这段**)
>
> - 现状:`ChatViewModel`(**每个会话一个 VM**)、`SessionsViewModel`、`AdminViewModel` **各建一条**
> `ChatSocket`,而 chat 的 VM 活在 Activity 级 `ViewModelStore` 里 ⇒ **访问过的每个会话都永久留一条
> 连接**(各自带 `OkHttpClient` + 连接池 + 重连循环),删会话也不回收。
> - 自然解法:**共享一条 socket + 按 `sessionId` 路由帧**(`WsEvent.isForSession` 已经是现成的判据)。
> - 验收清单(缺一不可):① 主会话流式正常;② 切到次级会话时**不串台**(旧连接收到的别的会话帧必须被丢);
> ③ 管理页三帧(metrics/trace/log)仍到;④ 抽屉未读角标仍对;⑤ 断线重连后 `after` 补洞仍跑;
> ⑥ 登出时要断开且重登要接上(现有 `authManager.state` 那条 `connect()/close()` 逻辑要跟着搬);
> ⑦ 删会话后那条连接不再留着。
> **★ 锚点补记四(2026-10-05 上午 · 主人给了明确指示后的一批)**
>
> ### 状态
> - **`android` = `origin/android` = `7b97440`**,工作区干净,**891 测试 / 0 失败 / 0 错误**。
> - APK:`app/build/outputs/apk/debug/app-debug.apk`(就是 `7b97440` 这棵树)。
>
> ### 本批落地
>
> | # | 内容 | 提交 |
> |---|---|---|
> | W28 | **进程内共享一条 `ChatSocket`**:连接数 **N+2 → 1**,`SessionsViewModel` 为唯一 owner | `7b97440`(`276b011`) |
> | FB | **可编程假后端测试台**(`devtools/fake-backend/`,纯 Python 标准库、零依赖) | `27d5680` |
> | — | `02` 三处登记:回应回执十三 / 置顶「微信文件看不到」P0 / IM 语音消息的新需求 | `691b073` `ae49853` `a008580` |
>
> ### W28 的关键取舍(**别退回**)
> - **共享的 socket 谁都不许 close**:`onCleared` 里的 `close()` 全删了(否则切个会话就把全 App 的连接掐断)。
> 现在全仓 `connect()` / `close()` **各只剩一处**,都在 `SessionsViewModel`(它由 `ChatHomeScreen` 在进登录态时创建、活在 Activity 级 store 里,存活期正好覆盖「登录→登出→重登」)。
> - **`_events` 的 `replay` 必须恒为 0**:共享后新建的 `ChatViewModel` 会立刻订阅同一个 SharedFlow,replay > 0 就会把历史帧重放给它。
> - **两个已知的固有代价**(都在 W28 的 commit 里写明):① 晚于连接建立才创建的 VM **不再收到 `history` 帧** —— 实测无影响,新 VM 的正文由 REST `loadLatest` 给(设备上验证过:次级会话首次访问 `GET …/messages?limit=50 → 200`);② **Activity 结束不再断开连接**(进程级共享的必然结果),只在登出 / 令牌失效时断。
>
> ### W28 装机验收(**有设备证据**)
> - **连接数不随访问会话增长**:`awk` 数 `/proc/net/tcp{,6}` 里 App uid 的已建立连接 —— 只访问主会话时 **4**,**切到第二个会话后仍然 4**(改前这里会 +1)。这是 W28 全部意义所在。
> - 切会话 → 切回主会话,渲染**完全正常**(消息、来源角标、旁白都在)。
> - 管理页三个端点全到(`admin/observability/{logs,metrics,trace}`)并渲染出真实数据(总请求 205 / 平均延迟 20ms)。
> - **未验**:重连补洞(`after`)在共享 socket 下的表现、登出/重登的 owner 逻辑、viewer 分支 —— 都还没有设备证据。
>
> ### 假后端(FB)—— 它是「验不了」这个借口的解药
> `devtools/fake-backend/`(`python fake_backend.py --port 18080` + `ws_probe.py`)。它解锁三件**真后端做不了**的验证:
> 1. **`after` 补洞**:`POST /__control/messages?count=120` 免费灌 120 条,握手只回放 50 条 ⇒ 精确造出那个洞。
> (**这解释了为什么之前说「验不了」是错的**:我当时只想着用真后端灌 51 条 = 51 次模型回合。**该用假后端。**)
> 2. **打断帧**:`out/upstream.jsonl` 记录**所有上行帧** —— 这是「W25 那帧到底发没发」的**唯一直接证据**。已实跑抓到 `{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}`。
> 3. **`stream_chunk` 回退路径**:能指定走旧形状,正好用来验 W29 要修的那条分支。
> - App 指过来:`adb reverse tcp:18080 tcp:18080` + 登录页服务器地址填 `http://127.0.0.1:18080`(账号随便填)。
> - ⚠️ **它的事实来源是客户端代码与协作文档,不是真后端** —— 别拿它当后端行为的依据。
>
> ### 本批的一条硬教训:**问一句,比自己猜一整天都值**
> `stream_chunk` 是我昨夜「按增量」接的(把它当 `MessageDelta` 追加)。今早问后端,他们回复:**`delta` 是整段 `res.Text`**(而且生产里已无触发源)。
> ⇒ **真到达会追加成重影**。**我昨夜那版是错的,而且如果没有问,它会以「已修好」的样子留在仓库里。**
> 同类:`viewer` 凭据我一直挂着当红验收项,问了才知**全仓没有任何铸造路径**,应当直接摘掉。
>
> ### 待办(更新)
> 1. **W29(在跑)**:`stream_chunk` 改成「整段替换 + 有结构化帧则忽略」/ 空壳消息如实说一句 /
> 分享超长内容走文件 / `F6①` 图片 URL 兜 host。
> 2. **等后端**:把 IM 入站的文件挂到消息的 `documents` 上(P0,`02` 已置顶);IM 渠道语音消息的入站 ASR。
> 3. **用假后端补三条设备验收**:`after` 补洞 / 打断帧 / `stream_chunk`(等 W29 合完)。
> 4. 子会话流程(主人说搁置);图片/工具卡实机验证(后端未配工具)。
> 5. **已结案(别再返工)**:登录页输入框被 IME 遮挡 —— **不成立**,2026-10-05 装机实测(密码框聚焦、键盘弹出时完整可见,截图 `app/build/e2e-shots/y3-ime.png`)。
> **★ 锚点补记五(2026-10-05 上午 · 忙时并行批次 + 思考流排查)**
>
> ### 状态
> - **`android` = `origin/android` = `c3cfef7`,工作区干净,1033 测试 / 0 失败 / 0 错误**。
>
> ### 本批落地(一行一个)
> | # | 内容 | 提交 |
> |---|---|---|
> | W28 | **进程内共享一条 `ChatSocket`**(连接数 N+2 → 1;`SessionsViewModel` 是唯一 owner);装机验过「连第二个会话连接数不涨」 | `7b97440` |
> | FB | **可编程假后端**(`devtools/fake-backend/`,纯 Python 标准库) | `27d5680` |
> | W29 | `stream_chunk` 改**整段**语义 / 空壳消息如实说一句 / 分享超长走文件 / 图片 URL 兜 host | `ac3f63f` |
> | W30 | **忙时并行 UI 半边**:六态 + 灰态 + `(sessionId, turnId)` 两级分桶,**帧侧全部可降级** | `0057205` |
> | W31 | 设置栏「她忙时发消息」**追加第三档「并行」**(忙时上行 `chat.parallel=true`,绝不发 interrupt) | `4847d74` |
> | W32 | 忙时被拒那条横幅**调柔和**(只那一档,真错误仍红)+ 并行额度落回排队如实说一句 | `79fcd80` |
> | W33 | `parallel_limit` 提示加「**本端确在并行档**」的闸 + 查证横幅(结论:代码无缺) | `1f6c16c` |
> | FB2 | 假后端加 **`--hold-replies`**(收到消息不自动落库)⇒「待发气泡还挂着」可稳定复现 | `c3cfef7` |
>
> ### 装机验过的(有设备证据)
> - **`after` 正向增量补洞**:重连后她自己发出 `messages?limit=50&after=…`(这是之前两天判定「验不了」的那条 —— **错在我想窄了,该用假后端**)
> - **打断帧**:上行帧原文 `{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}`,**先于** `chat` 帧
> - **`stream_chunk`**:假后端发**一帧整段** → 渲染 `CHUNKONE` **一次**,无重影
> - **W28 连接数**、**错误横幅关闭叉**、**登录页 IME(不成立)**
>
> ### ⚠️ 思考流(主人 2026-10-05 报的)—— **根因在后端/模型侧,已查到一半**
> 1. **证据**:真后端最近 5 条 assistant 消息**全都没有 `thinking` 键**(`GET /messages` 是 owner 可见的唯一历史思维链来源)⇒ **后端手里根本没有思考内容**,不是客户端不显示。
> 2. **配置**(`Cyrene_v2/data/models.json`):`provider: "anthropic"`、`baseUrl: https://api.deepseek.com/anthropic`、`model: deepseek-flash`、**没有 `reasoning` 键**。
> 3. **`reasoning` 键加不了**(**别再试**):`models.go:180-191` 的 `validateReasoning` 明确规定 —— **anthropic 组上除 `auto`/空之外任何 kind 都非法**,写了会让后端**下次启动校验失败**。
> 4. **原因在代码里写着**:`anthropic.go:195-201`「**anthropic 协议自带思考形状**,因此这里声明 openai 形状是无效的」⇒ `reasoning.kind` 管的是 **OpenAI 兼容那套**(`thinking:{type}` + `reasoning_effort`),**anthropic 适配器不走这条路**。
> 5. **所以开关在别处**:在 **anthropic 适配器自己的请求构造**里(协议原生的 `thinking` 块)。
> **⚠️ 待查(我读到 `anthropic.go:195` 就停了)**:它到底发不发 `thinking`、什么条件下发;
> 以及 **DeepSeek 的 anthropic 兼容端点支不支持 anthropic 的 `thinking` 形状**。
> ⇒ 最省的办法:**直接问后端会话**(他们写的,一句话就够)。
>
> ### 待办(按优先级)
> 1. **用假后端把「忙时被拒」那条横幅端到端验一次**(上次没验成:当时待发气泡是 0 条 ⇒ 落 `Ignore` ⇒ **刻意什么都不写**,那是设计不是 bug)。
> 做法已在 `devtools/fake-backend/README.md` **第 6 节**(`--hold-replies` → 发 chat 挂住 → `persist` 补落库 → 注入 `pending_full`),**逐条实跑过**。
> 2. **思考流那条的后续**(见上):问后端 anthropic 适配器的 thinking 下发条件。
> 3. **等后端 v0.65**:`chat.parallel` / `turn_begin` / `turn_id` 的**精确位置**登记后接帧(我们形状已备好,全可降级)。
> 4. 旧的:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。
> 5. **作废的说法**(别再引用):曾记「假后端 `--stream-chunk` 建模与契约不符」——**错**,它的 `chunks=1` 就是契约的「一帧整段」。
>
> ### 环境与纪律(这次踩到的)
> - **模拟器今天很脆**:连 launcher 都 ANR 过,App 进程被系统重建。**新会话开工前先重启模拟器**。
> - **假后端进程还占着 18080 / 18081 / 18083**(`taskkill` 对它们不太好使,重启机器最干净)。
> - **我这次又犯了「先怀疑代码、而不是先怀疑自己的测量」**:截图上没看到横幅时,我先去查代码,其实当时只是**待发气泡为 0**(设计如此)。这条纪律写在上一节,**要一直记着**。
> ### ⚠️ 纪律补记(2026-10-05 上午 · 主人问「为什么项目文件夹里那么多本项目的文件夹」)
>
> **那些是 git worktree** —— 给并行子代理各开一个独立工作区用(避免两个代理同时改同一棵树)。
> **问题不是它们存在,而是我合并完忘了清**:这一批(W28–W33 + FB/FB2)**8 个全都残留下来了**,
> 主人先在文件管理器里发现的。上一次(W4–W24)我做过一轮「家务清理」,之后就再没做。
>
> **⇒ 从今往后写成硬规则:**
> 1. **每合并一个 worktree,就 `git worktree remove` / `rm -rf` 它**(别攒着)——**同一次操作里做完**,
> 不要留到「以后一起清」。
> 2. 清理用 **Git Bash 的 `rm -rf`**(Windows 直接用 git 自带的删会撞 `MAX_PATH`),然后 `git worktree prune`。
> 3. **删之前先验**:`git merge-base --is-ancestor <branch> android` —— 只有已并入的才删。
> 4. **分支引用保留**(方便回退),只删目录。
> 5. 收尾自检:`git worktree list` 应当**只剩主树**、`ls -d /d/Project/Code/Uni/Cyrene-Android-*` 应当**为空**。
>
> 本次已清:**8 个全删,`git worktree list` 只剩主树**;`feat/W4-*` … `feat/W33-*` 分支全部保留。
>
> **另一条同源的提醒**:我给子代理开的「假后端」进程(python,占 18080/18081/18083)**也是我留下的** ——
> `taskkill` 对它们不太好使,**重启机器最干净**。以后用完就该关。
> ## ★★ 锚点补记六(2026-10-05 上午 · **文档已拆分** + `<p>` 规整上线 + 三件待办)
>
> ### ⚠️ 先看这条:`devdocs/` 的结构变了(新会话别找错文件)
>
> | 想找什么 | 读哪一份 |
> |---|---|
> | **当前状态 / 待办 / 锚点 / 纪律** | `05-开发进度与交接.md`(本文,624 行) |
> | **历史批次的详细记录**(W4–W7 等) | **`05-archive-历史批次.md`**(418 行,只读追溯) |
> | **当前开放的登记 + 状态一览 + 置顶 P0 + 最近往来** | `02-前后端协同事项.md`(472 行) |
> | **历史回执(一~十七)与已闭环的登记** | **`02-archive-历史回执.md`**(1681 行,只读追溯) |
>
> **规则**:新回执写在 `02` 的**末尾**;闭环的登记由我方**定期**挪进归档。`02` 是**与后端会话共用**的文件 ——
> 他们也会往里写,**改动前先看 `git status`**(这次就撞上过一次:他们未提交的追加把我的 merge 挡了,
> 处理方式见下)。
>
> ### 状态
> - **`android` = `origin/android` = `681ae96`**,工作区干净,**1033 测试 / 0 失败 / 0 错误**。
> - 本批**只动文档**,没有代码改动。
>
> ### 本批发生的事
> 1. **拆分过长文档**(W34,两轮):`02` 2143 → **472 行**主 + 1681 归档;`05` 1035 → **624 行**主 + 418 归档。
> **零丢失有两层证明**:代表性字符串 `grep -c` 前后相等 + **逐行多重集 `diff`**(差异只有新增说明行)。
> 第二轮按主人要求把 `A~E/G/H` 那批**已闭环的登记**(约 430 行)也归档,并修了状态表那处会断的指针。
> 2. **撞上一次协作竞态**(值得记):后端会话**在我拆的期间往 `02` 追加了内容**(未提交)⇒ merge `Aborting`。
> **处理**:先把他们的补丁 `git diff > /tmp`,`git checkout --` 清工作区,合并拆分,**再把他们的内容原样追加回新主文件末尾**。
> ⇒ 两边都没丢。**教训:动 `02` 之前先 `git status`。**
> 3. **`<p>` 规整已落地并上线**(后端 `5de708a`):照我们的裁定走 —— **写入侧**收口、**幂等守门断言**、
> 覆盖 **Web 与 IM 两条落库路径**(渲染口径不分叉)。**我方零改动。**
>
> ### ✅ 已拍板(主人 2026-10-05):历史回填 **做**
> **决定:做**(主人「按最优解」)——三条约束见 `02-前后端协同事项.md` 末尾「历史回填」一节:
> **① 先 dry-run 只统计不改(量大异常先对齐)② 留回滚点 ③ 一次性、不留常驻逻辑**。
> 范围严格限定为「只补 `<p>`」,不碰 role/时间戳/documents/thinking/记忆派生数据。
>
> > 原问题描述(保留备查):**历史消息要不要一并回填重写?** 后端的原话:本次是**写入侧**收口 ⇒ **已落库的旧「半包」消息不会自动重写**(按既有解析规则仍会塌)。
> 要重写就是**一次性数据回填**(涉及 `data/**`,**需主人点头**)。**不做的话**:新消息从此正常、老消息照旧塌。
>
> ### 待办(按优先级)
> 1. **真机验证「并行」两路**:后端已确认 `turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id`
> **都已进 HEAD 并已上线**(契约 v0.66 待升)⇒ 「等登记」这个阻塞**已解除**。我方形状早备好且**全部可降级**
> (W30),入口也在(W31:设置页「她忙时发消息」第三档)。**装机步骤**:先重启模拟器(见下)→ 装 APK →
> 设置切「并行」→ 在**她正忙**时发一条 → 看是否真开第二路 + 灰态 + 两级分桶。
> 2. **用假后端验「忙时被拒」横幅**(上次没验成的那笔):`devtools/fake-backend/README.md` **第 6 节**有**逐条跑过**的命令序列
> (`--hold-replies` → 发 chat 挂住 → `persist` 补落库 → 注入 `pending_full`)。**关键**:必须让待发气泡 **≥1 条**
> (0 条会落 `Ignore`,**刻意什么都不显示**,那是设计不是 bug——我上次就栽在这)。
> 3. **等后端补「思考开关在哪开」**:已确认**端点支持**(`anthropic_reasoning.go:76` 的 `anthropicDeepSeekReasoning`,
> 走 `output_config.effort`,`thinking.budget_tokens` 会被忽略并 WARN),且**开关是请求级的、来自角色/组的思考设置**
> ⇒ **不是**改 `models.json`。**⚠️ 绝对不要再试 `reasoning.kind`**:`validateReasoning`(`models.go:180-191`)
> 在 anthropic 组上会**拒绝**它,加了会让**后端下次启动校验失败**。
> 4. 旧的:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。
>
> ### 环境与纪律
> - **模拟器今天极不稳**(连 launcher 都 ANR 过、App 进程被系统重建)⇒ **开工前先重启模拟器**。
> - **假后端进程还占着 18080 / 18081 / 18083**(`taskkill` 不太好使,**重启机器最干净**)。
> - **worktree 纪律(上一轮主人要求写死的)**:每合并一个**当场 `rm -rf` + `git worktree prune`**,
> 删前先 `merge-base --is-ancestor` 验已并入,只删目录保留分支,收尾自检 `git worktree list` 只剩主树。
> **本批已按此执行**(W34 合并后立刻清掉)。
> - **我又犯了一次「先怀疑代码、而不是先怀疑自己的测量」**(截图看不到横幅时)。这条已经写过两次了,
> 是第三次 —— **下次第一反应必须是"我的验证条件对不对"**。
> ## ★★ 锚点补记七(2026-10-05 下午 · 并行两路**实机验通** + 一个误标 bug 已修 + 假后端横幅两档已验 + 模拟器调参)
>
> ### 状态
> - `android` = `origin/android` = **`39a63f8`**,主树全量 `assembleDebug` + `testDebugUnitTest` 跑过:
> **1055 测试 / 0 失败 / 0 错误 / 0 跳过**。
> - 本批代码两个都**已合并、已推送、worktree 已清**:**W35** `6e00697`(分享来源角标)、**W36** `39a63f8`(并行路角标改以 `kind` 为准)。
> - ⚠️ **工作区有 `02` 的在途改动 —— 那是后端会话的追加(回执十八等),不是我们的,别提交它。** 处理手法见本文末。
>
> ### ✅ ①「并行」两路:实机验通(这次是真证据,不是看角标猜的)
> 三条互相独立的判据:
> 1. **后端日志**(`%USERPROFILE%\cy.log`):`msg="parallel leg started" session=web turn=turn-4743035881e410af leg=2 active_turns=2`
> —— 这行**只在并行启动路径**打(`session_parallel.go:175`),`active_turns=2` 按定义就是「起第二路时第一路仍在飞」。
> 2. **库内 `turnId`**(`GET /sessions/web/messages`):`write 2000 words about the ocean` = `turn-b618432ddb21ed42`(11:56:27.8),
> `para2` = `turn-4743035881e410af`(11:56:39.4)⇒ **两条 user 消息不同 turnId**;leg1 的 assistant 行 **11:56:41.8** 才落库 ⇒ 与 leg2 真重叠。
> 3. **客户端画面**:底栏显示「正在思考… **并行 2 路**」(= `activeTurns=2`),两路回复各自成块、没互糊。
>
> ⚠️ **差点被自己骗过去的坑(写死它)**:第一轮我把「角标显示并行路 2」当通过 —— **是假阳性**。
> 后端 `leg` 来自 `st.legSeq++`(`session_parallel.go:202`,**每条新路都自增,串行回合也算**)⇒ `leg=2` 只说明「本会话第 2 条路」。
> **判并行只能看 `turn_begin.kind` / `active_turns`,或后端那行 `parallel leg started` 日志。**
>
> ### 🐞 副产物 bug(已修 W36):`leg >= 2` 就挂「并行路 N」
> 复现:会话里发**第二条普通消息**,气泡下就出现「并行路 2」。根因是客户端**把 `turn_begin.kind` 丢掉了**,角标只认 `leg`。
> W36 把 `kind` 带进 `TurnBinding.parallel`,判据改成 `parallel && leg >= 2`;**`kind` 缺席(老服务端)⇒ 一枚都不挂**(宁可少挂,不说假话)。
>
> ### ✅ ②「忙时被拒」横幅:假后端两档都验过
> 手法(比改 App 服务器地址干净):**不动 App 配置**,把 `adb reverse` 的 10942 转到假后端端口 ——
> `adb reverse --remove-all; adb reverse tcp:10942 tcp:18099`,App 照旧访问 `http://127.0.0.1:10942`;验完还原 `adb reverse tcp:10942 tcp:10942`。
> - 待发 **1** 条 ⇒ 横幅「**她正忙且队列已满,这条没发出去,请稍后手动重试**」+ 气泡摘掉、**正文退回输入栏**(`e2e-shots/fb3-banner.png`)
> - 待发 **≥2** 条 ⇒ 横幅「**她忙不过来,这条可能没发出去**」+ **一个气泡都不动**(`e2e-shots/fb5-unknown.png`)
> - 关键前置:**待发气泡必须 ≥1 条**(用 `--hold-replies` 挂住,别让它落库)
>
> ### 🔧 模拟器调参(今天那串 ANR 的真因不是代码)
> 宿主 Ryzen 5 5600G(6C/12T)/ 28G;AVD 原本 **4 vCPU + 6G**(`config.ini` 里 `hw.ramSize=2048`,靠 `-memory 6144` 顶)。
> 客户机 `top` 显示 **iowait 196% + swap 已用 457MB** ⇒ 瓶颈是 **IO + 内存**,不是核数。
> 已改 `C:\Users\Aska\.android\avd\Pixel_10_Pro_XL.avd\config.ini`:`hw.ramSize=8192` / `hw.cpu.ncore=6` / `vm.heapSize=512`。
> 效果:**iowait 196% → 14%,swap 457MB → 0**。
> 另外**别再每次 `-no-snapshot-save`** —— 冷启动那一遍 IO 最贵;现在让它存快照,下次热启动。
>
> ### ⚠️ 新踩的两个坑
> 1. **`settings put global hide_error_dialogs 1` 会让 ANR 直接杀掉 App**(日志 `reason=6 (ANR)` + `Killing … user request after error`)。
> 它对自动化友好,但代价是丢状态 —— 配合「不在负载高峰驱动 UI」一起用。
> 2. **别一边跑 Gradle 一边驱动模拟器 UI**(今天就这么把 App 饿到 ANR)。
> 判断「是不是 App 的 bug」的正确做法:**拉 ANR trace 看主线程栈** —— 在 `/data/system/procexitstore/`,`adb root` 后可 pull。
> 那次主线程是 `__epoll_pwait`(**空闲**),且 **launcher 同时段以同样理由 ANR** ⇒ **模拟器被饿死,不是 App 的问题**。
> 3. `uiautomator dump` 失败会**留着上一次的快照**:每次用**唯一文件名**并检查输出行 `UI hierchary dumped to:`,否则会对着旧快照做判断(本批栽过一次)。
>
> ### 📦 分享接入:**其实早就做完了**
> 子代理查实:Manifest 两个 intent-filter、`app/ShareIntake.kt`、`domain/share/{SharePayload,PendingShareHolder}.kt`、`ChatViewModel.sendShared` 全在,
> 且 `05-archive-历史批次.md:124-139` 有 2026-10-04 的装机验收(文本 / 单图 / 未登录暂存 / 登录后落地四条)。
> 本次补的是**「可识别」那一半**(W35):分享进来的消息 `clientMsgId` 用 `share-` 前缀,渲染层挂 **`[用户分享]`** 角标,**正文零改动**(守 `02` 的 F4 红线)。
> ⚠️ **一次观察(未结案)**:用 `am start -a SEND --es …TEXT` 投分享时,客户端出现「**发送失败,请重试**」并把正文退回输入栏,库里**没有**多出那条消息;
> 当时 WS 刚在附近重连过一次。**未定位**,先记着。
>
> ### 待办(按优先级)
> 1. **分享路径剩下三条实机验证**(超长正文走文件 4000 rune / `SEND_MULTIPLE` 多图 / 冷启动)—— 命令见 README 与子代理报告。
> 2. **W36 的真机目视**:单测钉住了,但「第二条普通消息不再冒角标」没在设备上目视过。
> 3. 旧账:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。
>
> ### 环境与纪律(更新)
> - 假后端**用完即关**(本次已关,18099 已释放);别长期占 18080/18081/18083/18099。
> - worktree 纪律照旧(合并当场 `rm -rf` + `prune`);本批 W35/W36 都已执行,`git worktree list` 只剩主树。
> - **动 `02` 之前先 `git status`**:本批 `02` 一直带着后端会话的在途追加,处理手法 = 先存他们的补丁 → `git checkout --` 还原 → 只提交我的 → 再把他们的贴回末尾。两边都没丢。
> ## ★★ 锚点补记八(2026-10-05 傍晚 · 分享路径设备验证的成绩单 + W37 + 三条新环境坑)
>
> ### 状态
> - `android` = `origin/android` = **`a2350a6`**(含 W37 的合并提交),**1084 测试 / 0 失败 / 0 错误 / 0 跳过**(主树实测)。
> - W37 `6cdf111`(分享:认下字符串形态的 `EXTRA_STREAM` + 消灭静默失败)已合并、已推送、**worktree 已清**。
> - `02` 里已回后端**回执十九**(`0764bc2`):给出那次分享失败的**精确时间锚点**与「无 `error` 帧 ⇒ 帧根本没发出」的证据链。
>
> ### ✅ 分享路径:三条通过
> | 场景 | 结果 | 证据 |
> |---|---|---|
> | 纯文本・**热启动**(连接正常) | ✅ | 库内 `clientMsgId=share-5f65cb44…` + 正文 `share one`;气泡挂 **`[用户分享]`** 角标 |
> | 纯文本・**冷启动** | ✅ | App 被 ANR 杀掉后由分享冷启动,`share two` 照样落库(`share-ea81310c…`) |
> | **超长正文(4200 rune)→ 文件** | ✅ | 后端 `POST /api/v1/documents 201`;库内 `content_len=0`、`documents=[{…txt, size:4200, chars:4200, status:"extracted"}]`;她回「人家打开你发来的那个文件了」 |
>
> ### ⛔ 分享路径:**图片(单图/多图)没拿到设备证据** —— 原因是工具,不是产品判定
> **`adb shell am` 造不出「带授权的 URI 分享」**,两条都试过:
> - `--esa`(`String[]`):类型不对 ⇒ `Cannot cast [Ljava.lang.String; to android.net.Uri`(**W37 已让它不再静默**);
> - `--eu`(单 `Uri`)+ `-f 0x00000001`:**没有 ClipData ⇒ 授权传不到目标应用** ⇒ App 打不开那个 `content://` ⇒ 静默无反应(同一处缺陷);
> - **`am` 帮助里确认没有 ClipData 选项**(只有 `--eu/--esa/--grant-*`),所以这条路在 adb 上封死。
> **真路径**是:相册(或任何真实发送方)→ 分享面板 → Cyrene。我试过,但今天 UI 驱动不可靠(见下面三条坑 + 模拟器 `system_server` 崩过一次)。
> ⇒ **要么主人在真机上装最新 debug APK 试一次「相册多选 → 分享」,要么等下次环境稳定再做**。**不要再花时间用 adb 造 URI intent。**
>
> ### 🔧 W37 修了什么(都是今天实机暴露的)
> 1. **字符串形态的 `EXTRA_STREAM` 现在认了**(`String[]` / `ArrayList<String>`,历史遗留与跨壳应用常见),**只接受 `content://`**(`file://`/`http(s)://` 一律拒,理由写在 `stringStreamUri` 的 KDoc);非法项逐条跳过,不整体失败。
> 2. **消灭静默失败**:`EXTRA_STREAM`/`ClipData` 里**确实有内容迹象**却一条都读不进来 ⇒ 给一条**柔和告知**「这次分享的内容没能读进来,没有发送任何内容…」(刻意不写「发送失败」)。
> **边界**:`EXTRA_TEXT` 与 `EXTRA_STREAM` 都为空 ⇒ **仍然静默**(两条判据同源)。
>
> ### ⚠️ 今天新踩的三条环境坑(比代码更耽误时间)
> 1. **模拟器会自己黑屏**,而 `svc power stayon true` **不一定挡得住**。后果极坏:黑屏下的 `input tap` 全部落空 ⇒ 我会误判成「点了没反应 / 长按不生效 / 焦点乱跑」。**开测前先 `input keyevent 224` + `82` 唤醒并截图确认屏幕是亮的**,另加 `settings put system screen_off_timeout 1800000`。
> 2. **`adb root` 会重启 adbd,把 `adb reverse` 全部冲掉**。之后 App 就「无法连接到服务器」。**`adb root` 用完必须补回 `adb reverse tcp:10942 tcp:10942`。**
> 3. **模拟器的 `system_server` 会崩**(症状:`Can't find service: window`、`am` 报 `Failure calling service activity: Broken pipe`、负载飙到 11)。**此时除了重启模拟器没有别的办法**;别把这类现象当 App 的问题。
>
> ### 📌 主人问的「开了并行之后连串行消息都成了并行处理?」—— 结论:**不会**,今天的数据里也没有发生过
> 三条证据:
> 1. **服务端有硬门**:`session_orchestrator.go:300` 的 `if st.mode != ModeIdle` —— **会话空闲时根本不进并行分支**,无论客户端声明什么都不可能开第二路(空闲只有一条串行路)。
> 2. **客户端只在忙时声明**:`parallelForSend(mode, busy) = busy && mode.wireParallel`(`InterruptScope.kt:65`),空闲时三档**逐字节相同**。
> 3. **今天全部日志(含轮转备份)里 `parallel leg started` 只有 1 条**:`turn-4743035881e410af leg=2 active_turns=2`(`11:56:39`)—— 正是我做并行实测时**故意**制造的。
> ⇒ **最可能的真相**:主人手机上装的是 **W36 之前**的 APK,于是**任何会话的第 2 条及以后的消息**都会被误挂「并行路 N」(`leg` 是「本会话第几条路」,不是并行与否)——**已修为 `39a63f8`,请装最新 debug APK 再看**。
> ⇒ 若看到的其实是**底栏那句**「正在思考… 并行 N 路」:那句来自 `ChatUiState.activeTurns > 1`(`ChatScreen.kt:863`),是**另一个信号**;服务端 `session_idle` 用 `busyPayload(…, ModeIdle, 0)` 发 0(`session_orchestrator.go:455`)⇒ 正常情况下会清掉。**若仍能看到,请告诉我,我按 activeTurns 陈旧这条线单独查。**
>
> ### 最新 debug APK
> `app/build/outputs/apk/debug/app-debug.apk`(**由 `d8228fb` 构建**;含 W35 分享角标 / W36 角标闸 / W37 字符串形态与告知 / **W38 分享等连接**)。
> ## ★★ 锚点补记九(2026-10-05 夜 · 分享「等连接」缺陷**已修**(W38)+ W37 设备验证通过 + 「并行」误标的设备证据)
>
> ### 状态
> - `android` = `origin/android` = **`d8228fb`**(W38 合并提交;分支提交 `aeb3745`);主树 **1091 测试 / 0 失败 / 0 错误**。
> - W38 worktree 已清,`git worktree list` 只剩主树。`02` 工作区仍是**后端会话的在途追加**(本次没动它)。
>
> ### ✅ W38:分享「等连接」缺陷已修 —— 这处是今天唯一确认的真缺陷
> **缺陷**(实机证据链见 `02` 回执十九与补记八):分享消费点把 `withTimeoutOrNull` 的**返回值丢掉** ⇒「等 3 秒没等到」与「等到了」走**同一条下游**:
> 照样 `take()` + `sendShared` → WS 未就绪 ⇒ `sendChat` 回 false ⇒ `rollback(localId, "发送失败,请重试")`。
> 三个后果:**① 硬错误横幅把「一次都没发出去」说成「发送失败」;② `take()` 原子出队且无归还路径 ⇒ 内容既不在 holder 也不在库里,重试资格丢了;③ effect 键不含连接状态 ⇒ 连接后来恢复也不会补发。**
>
> **修法**(`aeb3745`,4 files / +277 −20):
> - 新增 `ui/chat/ShareLanding.kt`:纯函数 `shareLanding(hasPending, connection, waitedLong, alreadyTold)` → `Nothing / KeepWaiting / TellWaiting / Send`(**返回单值动作 ⇒ 发送与告知在类型上不可能同时发生**)。
> - 落地 effect 的键加了**连接状态**(连接一变就重跑判据,**无轮询**);**只在 `Send` 分支才 `take()`**。
> - 等太久(3 秒)⇒ 走既有柔和通道 `showShareNotice` 说**一次**「还没连上、会等连上后自动发出」,**不是** error 横幅、**不含**「失败/重试」字样,**抖动不刷屏**。
> - `SHARE_CONNECT_TIMEOUT_MILLIS` **改名**为 `SHARE_WAIT_NOTICE_MILLIS`:含义从「到点就照发的发送时限」变成「等多久才值得说一句」,KDoc 明写**不是发送时限**。
> - 新增 `ShareLandingTest` 7 例(穷举 `WsState.entries` × 两个标志);**未动**普通发送路径(输入框/图片/附件)、未动 `PendingShareHolder.take()` 的原子语义。
>
> ✅ **真机回归已做(2026-10-05 夜,设备:模拟器 Pixel_10_Pro_XL / 包 `f15f0bf` 构建)**:
> 1. **断线时分享 ⇒ 不再报「发送失败」**:`adb kill-server` 真断线(reverse 清空)后投分享 —— 顶栏「重连中…」,
> 横幅是**「还没连上,分享的内容会留着,连上后自动发出」**(柔和),**没有** error 横幅;**库里查不到那条消息** ⇒ 内容没丢、也没假装发出去。
> 2. **连接恢复 ⇒ 自动补发,且只发一次**:`adb reverse tcp:10942 tcp:10942` 恢复后(等 ~50 秒,含退避)库里出现**恰好一条** user 消息
> `cli=share-914435c5…`、`turn-8cbf94e7`(同 turn 的另一条是她的回复,不是重复发送)。
> ⇒ 三条回归里 **① 断网分享后恢复 ② 横幅不是 error** 都拿到了;**冷启动分享**未单独重跑(补记八里冷启动路径已验过一次,且那条路径的差异只在入口)。
> 截图留档:`app/build/e2e-shots/w38-offline.png`(柔和告知)/ `w38-recovered.png`。
>
> ### ✅ W37 设备验证通过(补记八里说它「只有单测背书」)
> 装 `a2350a6` 的包,用 `am start -a SEND_MULTIPLE -t image/png -f 0x00000001 --esa android.intent.extra.STREAM <uri1>,<uri2>`:
> - 界面出现告知 **「有 2 个文件读不到,未发送」** ⇒ **App 真的认下了这 2 个字符串形态的流**(W37 生效),只是 adb 给不出读授权所以读不到;
> - **App 侧不再有 `Cannot cast [Ljava.lang.String; to android.net.Uri`**(logcat 里剩下的那几条来自 **system_server 的 `ActivityCallerState`**,pid 1315,不是我们的进程)。
>
> 🐞 **顺带发现一处文案瑕疵(未修,登记)**:这次告知显示成**两句叠在一起** ——「有 2 个文件读不到,未发送**;**分享的内容是空的,没有可发送的内容」。
> 前者是「认下了但读不到」,后者是「一条都没认下」,**同时出现读起来像两个不同的问题**(`ChatScreen.kt:905-911` 的拼接顺序)。
> 建议:`failedImages/failedDocuments > 0` 时**不再追加**「内容是空的」那句。
>
> ### ✅ 主人问的「并行档下串行消息也变并行」——**设备证据到手,不会**
> 步骤(可复现):设置页切**并行** → 她**空闲** → 用分享投一条普通消息 `serial gate check`,然后看两处:
> - **后端日志新增 `parallel leg started` = 0 条**(那条消息只有一个 `turnId`:`turn-780c3aa2`);
> - **界面助手气泡 footer 只有「发送至 安卓」,没有「并行路 N」**(含 W36 的包)。
> ⇒ 并行档只在**她真的在忙**时才另起一路;空闲时就是串行。**主人若仍看到角标,几乎确定是旧包**(W36 之前 `leg>=2` 就挂标)。
>
> ### ⚠️ 一条今天的测量教训(差点又误判)
> **`adb reverse --remove-all` 不会杀掉已经建立的连接** —— 我用它模拟「断线」,结果那条探测消息**照样落库**(`share-f7cd2374` 的 `offline share probe`)。
> ⇒ 想真的把 App 与后端切断,要 **`adb kill-server`**(再 `start-server` + 补回 `adb reverse`)。**别拿「移除转发」当断线。**
>
> ### 待办(更新)
> 1. **W38 的三条真机回归**(冷启动分享 / 断网分享后恢复 / 横幅不是 error)—— 真机或下次环境稳定时做。
> 2. **文案瑕疵**(`failed* > 0` 时不再追加「内容是空的」)—— 小改动,与下面两条同区,建议合成一批。
> 3. W38 子代理提的三条后续(**未做,待主人定**):① `deliver` 在 WS 未就绪时对**输入栏那条路**仍一律 `rollback(…, "发送失败,请重试")`(「未就绪」与「发失败」仍是两件事);② `WsState.Offline`(不可恢复)目前也走「等 + 连上后自动发出」,会一直等一句永不到来的「连上」,可改成「需要重新登录 / 检查服务器地址」;③ 待发内容只在进程内存里,一直连不上且进程被杀就丢(既有语义)。
> 4. **`02` 的 §四/回执十九 仍以「修法方向」口吻描述这处缺陷** —— 下次动 `02` 时补一句「已修 `aeb3745`」(本次没动它,避免与后端会话的在途追加打架)。
> 5. 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、**相册多选→分享**(图片路径仍需真机)。
> ## ★★ 锚点补记十(2026-10-05 夜 · W39 三处「措辞不诚实」已修)
>
> ### 状态
> - `android` = `origin/android` = **`b55d220`**(W39 合并提交;分支提交 `a763592`);主树 **1101 测试 / 0 失败 / 0 错误**。W39 worktree 已清。
> - ⚠️ **本批只有单测与编译证据,没有真机回归**(真机那三条留给下次或主人)。
>
> ### ① 分享告知「两句叠加」已消
> **改前实机原文**:「有 2 个文件读不到,未发送**;**分享的内容是空的,没有可发送的内容」——读起来像两个不同的问题。
> **改法**:`shareNoticeFor` 里新增 `hasFailedPieces = failedImages > 0 || failedDocuments > 0`,`isEmpty` 那档改成 `isEmpty && !hasFailedPieces`(`ChatScreen.kt:997` / `1002`)。丢件那句**逐字未改**。
> ⚠️ **子代理发现的事实(值得记)**:加了这个前提之后,「分享的内容是空的」这句**一次都不会再出现**——因为 `worthDelivering` 要求 `isEmpty` 的分享必须带丢件计数或 `contentUnreadable`,而后者已被「读不进来」那档接走。分支**保留并在 KDoc 里写明它已无出口**(没删,避免猜错路径时反而丢告知)。
>
> ### ② `deliver`:未连接与发送失败分开说(**只改文案,行为不变**)
> - 新常量:`SEND_FAILED_MESSAGE = "发送失败,请重试"`(逐字不变)、`SEND_NOT_CONNECTED_MESSAGE = "还没连上,这条没发出去,请稍后手动重试"`。
> - 新纯函数 `sendFailureMessage(connection: WsState)`:`Connected` 一句、其余四档另一句;`SendFailureMessageTest` 覆盖 `WsState.entries` 全部。
> - 接线处取的是 **socket 自己的 `state`**(正是 `sendChat` 判的那个值),**不用界面镜像那格**,避免晚一帧读成 `Connected`。回滚、幂等键、**无自动重发**全部照旧。
> - ⚠️ **没做**(单列,等主人定案):让「未就绪」像 W38 的分享那样**不取走内容、等连上再发**。那会改变普通发送的重试语义(未连接时是留在输入栏,还是自动排队重发),属产品定案。
>
> ### ③ `WsState.Offline` 不再承诺「连上后自动发出」
> - `ShareLanding` 加 `TellOffline`(**时机一字未改**:仍是等够久才说、同一轮只说一次,只换措辞);新纯函数 `shareWaitingNotice(landing)` 决定哪档说哪句,Compose effect 只剩一行接线。
> - 文案:`SHARE_OFFLINE_NOTICE = "现在处于离线,需要重新登录或检查服务器地址;分享的内容一个字节都没发出去,不会丢"` —— **不含**「连上」「自动发」「失败」。
> - 仍**不 `take()`、不发送**;恢复 `Connected` 后照旧自动补发(W38 的硬要求未动)。
> - ⚠️ **没做**:`Offline` 时**立刻**告知(现在仍等 `SHARE_WAIT_NOTICE_MILLIS`)。属独立改动。
>
> ### 待办(更新)
> 1. **主人定案项**:普通发送在「未连接」时该不该也自动排队重发(现在是留在输入栏 + 诚实文案)。
> 2. **未纳入新判据的一处字面量**:`ChatViewModel.kt:1552`(REST 回执被拒那条路)仍是 `rejected.message.ifBlank { "发送失败,请重试" }`,刻意未动。
> 3. `Offline` 立刻告知(不等 3 秒)。
> 4. **W38/W39 的真机回归**:W38 已验(补记九);W39 这三条只在真机上扫一眼即可(触发条件都少见)。
> 5. 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、**相册多选→分享**(图片路径仍需真机;App 一个权限都没声明 ⇒ adb 造不出可读 URI,这条**只能**走真机或驱动相册 UI)。
> ## ★★ 锚点补记十一(2026-10-05 · W40:普通发送「未连接 ⇒ 进队列」,不再回滚)
>
> ### 状态
> - worktree 分支 `feat/W40-send-queue-offline`(基线 `e60ae7a`),分支提交 **`df9fc85`**;**未合并、未推送**(主树与 `origin/android` 都没动)。
> - 本批证据:`assembleDebug` 成功;`testDebugUnitTest` **1118 测试 / 0 失败 / 0 错误 / 0 跳过**(83 套件)。**没有真机回归**(这一批只有单测与编译证据)。
> - 净变化:W39 加的 `SendFailureMessageTest`(4 条)**删掉**,其断言改成新事实后并入新的 `SendQueueTest`(21 条)⇒ 1101 − 4 + 21 = 1118。
>
> ### ① 主人的裁决落地:未连接 ⇒ 进队列(上一批的「待办 1」已定案并做完)
> - 新纯函数 `wsSendOutcome(sent, connection)`(`ui/chat/SendQueue.kt`)三档:`Dispatched`(帧交给 socket;**成功路径一个字没动**)/ `Queued`(未就绪四档:Idle / Connecting / Reconnecting / Offline)/ `Failed`(连接就绪却仍发不出去 ⇒ 今天那条 `rollback` + `SEND_FAILED_MESSAGE`)。
> - `deliver` 的 WS 分支**只改「未就绪」这一支**:不回滚、不进 `error` 横幅,改由 `queueForConnection` 留在待发态(`ChatUiState.queuedForConnection`:条目只有键与并行声明,正文仍从气泡上现取)。
> - **触发点**:`onConnectionChanged` 里 `connection == WsState.Connected` 那一格(由 `socket.state` 驱动,**无轮询**)+入队后的竞态兜底。
> - **顺序**:FIFO = 用户发送的先后顺序;补发只看队头。**只发一次**:队头 + 出队恰好一次(`dropQueuedSend`);第二道是补发复用同一个 `clientMsgId`(服务端 `chat_duplicate` 判重)。`flushQueuedSends` 是同步函数(无挂起点),两个触发点不会交错。
> - ⚠️ **已知边界**:打断声明(`interrupt{scope:"current"}`)不随补发重放(补发时在跑的那一轮早已不是用户按发送时看到的那一个);内容仍只在**进程内存**里,一直连不上又杀进程就丢(既有语义)。
>
> ### ② 状态表达:③ 排队中 + 小标「还没连上」,**不新增第七态**
> - `phaseOf(待发气泡)` 的 ③ 判据扩为「收到过入队回执 **或** 本端因未连接留住」;气泡小标由新纯函数 `pendingBubbleFooter` 给:`排队中 · 还没连上`(服务端忙时排队仍是 `排队中`)——挂在气泡上而不是只靠告知,因为告知 8 秒就收走,而这是会持续几十秒的状态。
> - 柔和告知走**新通道** `ChatUiState.sendQueueNotice`(与 `shareNotice` 分开:两条说不同的动作):`SEND_QUEUED_NOTICE = "还没连上,这条排队等着,连上后自动发出"`;`Offline` 那一档换 `SEND_QUEUED_OFFLINE_NOTICE = "现在处于离线,需要重新登录或检查服务器地址;这条还没发出去,已排队不会丢"`(**不提**「连上」「自动发」)。**不刷屏**:一轮「队列从空到空」只说一次。
>
> ### ③ REST 通道(长文 / 图片 / 附件)**未覆盖**,仍是回滚 + 网络错误文案
> 理由:它给不出「未连接」这个信号(只有一次网络错误,请求可能已被服务端收到),且补发要重跑图片重编码与附件上传(本地 URI 授权可能已失效)。影响面仅这一条通道。
>
> ### 待办(更新)
> 1. **W40 的真机回归**:断网发普通消息 ⇒ 应看到「排队中 · 还没连上」+ 柔和告知(**没有**红色横幅、输入栏不退回)⇒ 恢复连接 ⇒ 自动补发且只落库一条。
> 2. REST 通道(长文 / 图片 / 附件)未连接时要不要也排队——需主人定案(代价见 ③)。
> 3. `Offline` 立刻告知(不等 3 秒)——W39 遗留,未动。
> 4. **未纳入新判据的一处字面量**:REST 回执被拒那条路仍是 `rejected.message.ifBlank { "发送失败,请重试" }`,刻意未动。
> 5. 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、**相册多选→分享**(只能走真机或驱动相册 UI)。
> ## ★★ 锚点补记十二(2026-10-05 深夜 · AVD 调参第二轮:尺寸/核数/无窗口)
>
> ### 状态
> `android` = `origin/android` = **`31e8e31`**(W40 合并提交);主树 **1118 测试 / 0 失败 / 0 错误**;worktree 只剩主树。
>
> ### 先纠正一条容易走错的路:**硬件加速本来就是开着的**
> 模拟器日志:`WHPX on Windows 10.0.26100 detected` + `Windows Hypervisor Platform accelerator is operational`;CPU `VirtualizationFirmwareEnabled=True`。
> ⇒ **慢不是「没吃到加速」**,别往那个方向修。
>
> ### 本轮改了什么(`Pixel_10_Pro_XL.avd/config.ini`)
> | 项 | 改前 | 改后 | 理由 |
> |---|---|---|---|
> | `hw.lcd.width/height/density` | 1344×2992 @480 | **1080×2340 @440** | 像素少 ~37%,Compose 每帧重绘直接变轻 |
> | `hw.cpu.ncore` | 6 | **5** | 宿主只有 6 个物理核;**给满会与宿主机互相抢**(WHPX 下超额分配会互相等) |
> | `skin.name` / `skin.path` / `skin.dynamic` | 指向 `pixel_10_pro_xl` 皮肤 | **整三行删除** | **皮肤会把屏幕尺寸钉死**,`hw.lcd.*` 完全不生效(实测 `wm size` 仍是 1344×2992);删掉后 `wm size` 才是 1080×2340 |
> | `showDeviceFrame` | yes | **no** | 无皮肤时没必要画外框 |
> 备份留在 `app/build/e2e-shots/config.ini.bak`(改坏了可原样还回去)。
> `hw.ramSize=8192` / `vm.heapSize=512` 沿用上一轮(那一轮把 **iowait 196%→14%、swap 457MB→0**,是本项目至今最大的一次性能收益)。
>
> ### ❌ `-no-window` **不要用**(这条与直觉相反,实测证据)
> 无窗口启动时模拟器把渲染降级成软件 GL:日志 `emuglConfig_init: vulkan_mode_selected:lapvaipe gles_mode_selected:swiftshader`
> ⇒ **省了宿主窗口开销,却丢了宿主 GPU**(原先是有窗口时的 `vulkan:host / gles:host`,用的是 AMD RX 6600 XT)。
> 对以 Compose 重绘为主的负载,这是**倒退**。⇒ **保留窗口跑**。
>
> ### 结论:**真机做验证、模拟器做冒烟与看日志**
> 全系统模拟本来就比真机慢 5~10 倍,宿主是 6 核 Zen3,再压也压不出真机的手感。其余可做的:测试期间别同时跑 Gradle / 别开 Docker 与浏览器(今天就有一次构建把 App 饿到 ANR);保持快照启动(已开)。
> 「换别的虚拟机」这条路**对这个项目基本走不通**:WSA 微软 2025 已停服;MuMu/LDPlayer/BlueStacks 多是 Android 9~12,而我们 `minSdk 33` **至少要 Android 13 才装得上**;Genymotion 个人版付费且镜像滞后;Waydroid 只有 Linux。**官方模拟器 + 真机仍是最优组合。**
>
> ### 待办(本节的)
> - 新尺寸下**没重跑**任何设备验证(App 已装、登录态在;下次做设备测试时留意**旧的点击坐标全部作废**,要按 1080×2340 重新取样)。
> ### ✅ 补:W40 的真机回归已做(2026-10-05 深夜,1080×2340 新 AVD,包 `31e8e31` 构建)
> 手法(可复现):`adb kill-server` + `start-server` 真断线(`adb reverse` 被清空)→ 在**输入栏**打 `q1` 并点发送 → 再 `adb reverse tcp:10942 tcp:10942` 恢复。
> | 阶段 | 结果 |
> |---|---|
> | 断线时 | 顶栏「重连中…」;柔和横幅 **「还没连上,这条排队等着,连上后自动发出」**;**没有红横幅**、**输入栏没有退回**;库里 `q1` **0 条** |
> | 恢复后(等 ~55 秒,含退避) | 库里 `q1` **恰好 1 条**(`cli=local-bccbb362-7…`、`turn-d2b6457a`)⇒ **自动补发、顺序 FIFO、只发一次** |
> ⇒ W40 主人定案的「未连接进队列」在设备上成立。
>
> ### 📍 新 AVD(1080×2340 @440dpi)的实测点击坐标(省得下次重新取样)
> | 目标 | 坐标 |
> |---|---|
> | 输入框 | **(560, 2170)** |
> | 发送按钮 | **(950, 2170)** |
> | 抽屉(左上菜单)| 按 1080 宽重算,约为 **(86, 188)**(未实测,下次用前先截图确认) |
> 注意:**软键盘仍是禁用状态**(`ime disable` 写在 userdata 里,跨重启有效),所以布局不会因 IME 位移;这是刻意的(见补记八第 1 条坑)。
> ## ★★ 锚点补记十三(2026-10-06 凌晨 · W41 三级阶梯:未连接时**先探 health,通就发出去**)
>
> ### 状态
> `android` = `origin/android` = **`eb22521`**(W41 合并提交;分支提交 `9f77fb4`);主树 **1135 测试 / 0 失败 / 0 错误**。W41 worktree 已清。
> ⚠️ **本批只有编译 + 纯判据单测证据,没有真机回归**(子代理按任务书禁碰 adb)。
>
> ### 主人给的思路(比原来那版好)
> 我原先只做了「未连接 ⇒ 进队列」。主人指出:**「未连接状态你就不能拿 httpAPI 尝试吗?」→「可以走 health 端点验证连通性」**。
> 关键理由(我原来那份评估的漏洞):我曾担心「REST 分不清有没有送达,所以不敢重发」——
> 而这条**已被 `clientMsgId` 幂等解掉**(服务端按 `(session_id, client_msg_id)` 唯一索引仲裁,重复到达只回 `chat_duplicate`)。**既然重发安全,就没有理由不试。**
>
> ### 三级阶梯(`ui/chat/SendFallback.kt` 的 `sendRouteFor`)
> 1. **WS 已发出** ⇒ `Ws`(连接状态与探测都不再参与;**成功路径零改动**)
> 2. **`WsState.Offline`** ⇒ `Queue`,**连探测都不做**。理由:该档语义是「握手 401 且续期也失败」,会话已知是死的;而 `/api/v1/health` **不鉴权**(`routes.go:51`、`rest.go:219-221` 只回 `{"status":"ok"}`)⇒ 探它必然 200 ⇒ 会发一次注定 401 的 REST,把「已排队不会丢」换成一次回滚 + 一句误导人的鉴权错误。
> 3. **未就绪(Idle/Connecting/Reconnecting)+ 探测通** ⇒ **`Rest`**:回落 `POST /api/v1/chat` 把消息**当场发出去**,不进队列。
> 4. **其余一切**(不通 / 超时 / 抛异常 / **还没探过**)⇒ `Queue`(**fail-closed**,`null` 与「不通」同路)。
>
> **超时**:探测 **2500 ms**(`callTimeout` 覆盖 DNS+连接+写+读);回落 REST 另加 **10000 ms** 上限。
> **无网时的上界**:WS 就绪 = 0 次探测;`Offline` = 0(不探);完全无网 ⇒ **≤ ~2.5 秒**进队列且**一次 REST 都不会发**;探测通但 REST 卡住 ⇒ **≤ ~12.5 秒**后进队列(气泡全程「发送中…」,不是无响应)。
>
> ### ✅ 它顺手补掉了一个真洞(值得记)
> 「回落 REST 之后回复怎么回来」要求核实 —— 结论是**能回来**(`catchUp` → `loadAfter` → `absorb`,`ChatViewModel.kt:923/924-932/2091/2106` + WS 握手 `history` 兜底),
> 但发现 **`gapCursor` 只在「离开 Connected」那一刻记**:若本 VM **从没连上过**(进会话时就是断的)它为 `null`,重连那一步**什么都不会拉**,而回落那一轮的回复恰好只能靠它。
> ⇒ 新增 `armGapCursorForFallback`(`:1614-1615`),只在回落被受理后、且游标还是 `null` 时补记一次(**绝不改写掉线时记下的起点**)。
> ⚠️ **仍缺的一段(它如实写了,没假装补上)**:本端一条消息都没有时(`newestKnownAt == null`)游标补不上 —— `after=0` 会从**最旧**方向拉,20 轮上限停在最旧 1000 条上,正好错过要看的那条。主会话由 `history` 的 50 条兜住;**次级会话 + 首屏为空 + WS 从未连上过**这个组合下要用户自己刷新。
>
> ### 边界(它核实后写的,别记错)
> - **有图 / 有附件的消息走不到这条阶梯**:`ChatSendRouting.channelFor` 对「有图或有附件」恒回 `Rest`,而只有 WS 那一支才进阶梯 ⇒ 进阶梯的**必然是纯文本**。
> ⇒ 不存在「本地 URI 授权失效」「图片重编码」被这条新路踩到的可能;有图/有附件的行为与 W40 之前**逐字相同**。
> - **REST 失败分档**:`null`(超时/映射不出)与网络族(NoNetwork/Dns/Timeout/ConnectFailed)⇒ **进队列**;Server/Unauthorized 与配置族 ⇒ 仍回滚 + 文案(服务端给过裁决、等下去不会变好)。
> - **队列仍不持久化**(进程被杀就丢)——W40/W41 一致,未改。
> - ⚠️ **「任何 REST 失败都进队列」没做**(主人若指这个,需先设计「补发时怎么安全重放图片/附件」):本次只覆盖**阶梯第 2 步那次回落 REST**。
>
> ### 顺带改了两处(任务书外,有 KDoc/commit 说明)
> `devtools/fake-backend` 补上 `GET /api/v1/health`(否则本机跑假后端时探测吃 404、阶梯退化成直接排队);补发由 W40 的「同步无挂起点」改成**单飞协程 + 串行出队**(阶梯要挂起,必然)。
>
> ### 待办(本节的)
> 1. **W41 的真机回归**:用假后端 `--hold-replies` + **断 WS 但留着 HTTP**,看是否真走 REST 出去、重连后回复是否补齐。
> 2. 「任何 REST 失败都进队列」若要做,先设计图片/附件的安全重放。
> 3. `gapCursor == null` 且本端为空那条残留洞:可加「重连时 `loadLatest` 一次」兜底。
> 4. **W42(图片按钮改多选)已派单**(`feat/W42-image-multipick`,基线已追到 `eb22521`)。
> ## ★★ 锚点补记十四(2026-10-06 凌晨 · W42 图片入口改多选)
>
> ### 状态
> `android` = `origin/android` = **`4355ebd`**(W42 合并提交;分支提交 `5a9f52a`);主树 **1143 测试 / 0 失败 / 0 错误**。W42 worktree 已清。
> ⚠️ **只有编译 + JVM 单测证据,没有真机**(多选器交互与缩略图观感未经仪器测试)。
>
> ### 改了什么
> - **`PickVisualMedia()`(单选)→ `PickMultipleVisualMedia(ShareRules.MAX_IMAGES)`**(= 4),**仍然零权限**(没退回 SAF/存储授权)。回调变 `onImagesPicked(List)`;发送键可用性判据改看列表。
> - `ChatUiState.draftImage: String?` → **`draftImages: List<String>`**;输入栏上方的预览从一枚键变成**每张各自一枚移除键**(顺序即发送顺序);图片入口在**攒满 4 张后隐藏**(先移除才能重选)。
> - **切分抽成唯一出处**:新增纯函数 `ui/chat/ImageSendPlan.kt` 的 `imageSendPlan(text, images, documents)`。
> 判据:N=0 ⇒ 1 条(正文+附件);N=1 ⇒ 1 条(正文+图+附件);N=2…4 ⇒ N 条(**第一条**带正文+第一张图+全部附件,其余每条空正文+一张图);**N>4 ⇒ 只切 4 条**,超限张数如实报出。
> - **复用而非另起**:分享路径原本那段切分是 `sendShared` 里内联的 `forEach`(**不是**纯函数),本次把它抽成 `imageSendPlan` + 私有 `emitPlannedMessages`,**输入栏那条路与分享落地共用同一段**。每张图的解码/缩放/重编码与「内联 or `/upload`」仍逐张走既有 `emitAndDeliver → deliver → deliverImage`。
> - 超限文案抽成 `ShareRules.truncatedImagesNotice(kept, dropped)`(**逐字沿用**既有那句),分享路径与图片入口共用。
>
> ### ⚠️ 两处要主人过目(子代理主动交代的)
> 1. **N≥2 时「她忙时发消息」偏好只套用在第一条**(`applySendPreference && index == 0`)。理由:第 2 条起声明打断=打断自己刚起的上一轮。
> 替代方案是像分享路径那样**整批不声明**(改一行)。**N=1 时与今天逐字节相同**,所以这是个只影响新形态的选择。
> 2. **文案措辞**:那句超限告知原文以「**分享的**图片」开头,现在也会出现在**图片入口**(那里并没有"分享")。任务书要求逐字沿用,所以照做;要改成入口中性的措辞属新文案,需主人拍板。
>
> ### 待办(本节的)
> 1. **W42 真机**:多选器交互、输入栏那排缩略图观感、N 张落成 N 条的落库形态(模拟器或真机都行)。
> 2. 上一条那两个决定等主人一句话。
> ## ★★ 锚点补记十五(2026-10-06 · W43 + W41/W42 的拍板结果 + 一次**没跑成**的回归)
>
> ### 状态
> `android` = `origin/android` = **`3c1d3fd`**(W43 合并提交;分支提交 `5556573`);主树 **1150 测试 / 0 失败 / 0 错误**。worktree 只剩主树。
>
> ### 主人 2026-10-06 的四条裁决
> | 指令 | 落点 |
> |---|---|
> | N≥2 时「忙时发消息」偏好只套第一条 ⇒ **按我推荐** | **保留现状**(唯一不会「打断自己刚起的上一轮」的选项) |
> | 超限文案改中性 ⇒ **按我推荐** | W43 已改(见下) |
> | 跑 W41 回归 | **没跑成**(见下,是操作问题) |
> | 长文/图片/附件的 REST 失败**放着不做**;但加一条:**多次重试失败就静默** | W43 已实现 |
>
> ### ① 超限告知新文案(**逐字**,两处共用同一句)
> ```
> 一次最多 4 张,超出的没发,只发送前 3 张
> ```
> (`$MAX_IMAGES` / `$kept` 插值;`dropped == 0` 时仍回 `null`。)说清三件事:上限 4 张、超出的没发、发出去的是前 N 张;**无**「失败」「错误」,**无**「分享」。
>
> ### ②「多次重试失败就静默」
> - **阈值 3**(`QUEUED_RESEND_SILENCE_THRESHOLD`,`ui/chat/SendQueue.kt`):第 1 次失败必说,2、3 次仍允许说,之后不再说(取 1~2 等于没告知,故禁止)。
> - **记账口径**:本会话的一个**补发批次**(队列从空到非空那一段),落在 VM 实例字段 ⇒ 天然按会话,**无全局单例**。只计「补发没能上路、内容仍留在队列里」那种失败。
> - **清零**:补发**真的成功一次**就归零(`dropQueuedSend(sent = true)`);回滚/被收编那两种出队**不**清零。
> - **静默 ≠ 丢弃**:这个数只进告知判据,`queuedForConnection` / `nextQueuedSend` / 出队逻辑一行未动;连接一好照旧按序补发(同一个 `clientMsgId`)。测试 `silence never touches the queue` 钉住。
> - ⚠️ **一处要主人过目的边界**(子代理主动交代):**静默一旦生效会持续到某次补发真正成功**,不是「每轮结束就恢复」;期间气泡的「排队中 · 还没连上」小标照常可见。要改成「每轮结束恢复告知」只改一行(清零点从 `sent=true` 移到 `forgetQueuedNoticeIfDrained`)。
>
> ### ⛔ W41 的真机回归:**没跑成**(如实记,结论是「未验证」)
> **场景造出来了,而且判据很干净**:我写了个只掐 `/ws/chat` 的窄代理 `app/build/e2e-shots/ws_block_proxy.py`,`adb reverse tcp:10942 tcp:18098` 把 App 引过去。代理日志证明:
> - `BLOCKED GET /ws/chat?token=…` ×9(**WS 确实断了**,App 在退避重试)
> - `PASS GET /api/v1/sessions/web/messages?limit=50 / meta/enums / sessions / personas -> 200`(**HTTP 确实通**)
>
> **但代理日志里一条 `/api/v1/health` 都没有** ⇒ **App 从来没探过健康端点** ⇒ **消息压根没发出去,三级阶梯一次都没被触发**。
> 原因**在我**:第一次软键盘是弹着的(布局上移,坐标落到键盘上);第二次禁用 IME 后重试仍未命中(大概率输入框没拿到焦点,而我没先确认就复用了坐标)。
> ⇒ **不能写成「通过」,也不能写成「失败」——它是「未验证」。**
>
> **可复用资产与教训**:
> - **`ws_block_proxy.py` 值得留着**:因为 **Web 前端直连 127.0.0.1:10942、不经过 `adb reverse`**,所以**代理日志里的每一行都只可能来自 App** —— 这是分辨「App 到底发了什么」的最干净判据。
> - **打字前必须先截图确认输入框已聚焦**,别复用坐标(这条我今天栽了两次;补记八第 1 条的黑屏坑是同一类)。
> ### 🔴 补记十五·补(2026-10-06 · W41 回归**重跑成功**,并且**找到一个真缺口**)
>
> **场景复现成功**(`ws_block_proxy.py` + `adb reverse tcp:10942 tcp:18098`,表头显示「重连中…」而不是「离线」)。这次消息**真的发出去了**——但它**进了队列**,不是回落 REST。
>
> **判据(`uiautomator dump` 原文,决定性)**:
> ```
> text="" class="android.view.View" clickable="true" enabled="false" bounds="[867,2130][1047,2268]"
> └ text="发送" ← 按钮 disabled(因为草稿已清空)
> text="w41c" … bounds="[891,1916][1002,1970]" ← "w41c" 不在输入框,而是一条待发气泡
> text="排队中 · 还没连上" … bounds="[792,2015][1024,2059]" ← 正是 W41 新增的角标 ⇒ 它进了 queuedForConnection
> EditText … 占位文案「和昔涟说点什么…」 ← 输入框是空的
> ```
> **代理日志(只可能来自 App)**:`BLOCKED /ws/chat` ×N、`sessions / messages / enums / personas` 全 200,**但一条 `/api/v1/health` 都没有**。
>
> ### 结论
> 1. **路由选了 `Queue` 而不是 `Rest`** —— 在「WS 断、HTTP 通……」这个**正是它为它而写**的场景里,三级阶梯**没有生效**。
> 2. **探测请求根本没发出去**(代理日志一条都没有)。而表头是「重连中…」(`Reconnecting`)不是「离线」,按 `sendRouteFor` 判据**应该**去探测。
> ⇒ **最可能:探测在「发出请求之前」就失败了**,被 `probeReachability` 外面的 `catch(Exception) → Unreachable` 兜住 ⇒ 落到 fail-closed 的 `Queue`。
> ⇒ **下一步该查**:`data/api/ApiClientFactory.kt` 的 `createProbe`(它是否真能建出可用的 client?baseUrl / 拦截器 / `callTimeout` 是否与主链路一致?)与 `data/repository/ConnectivityRepository.probe` 的错误映射。
> ⚠️ **HTTP 侧本身是通的**(`sessions`/`messages`/`enums`/`personas` 都 200 走过同一个代理)⇒ 不是「代理挡住了探测」。
> 3. **这不是本轮新增的 bug**:W41 只在编译 + 纯判据单测层面验过(子代理明确说了没做真机)。**这正是「真机回归」存在的意义** —— 纯函数判据全绿,运行时接线却断了。
> 4. 顺带坐实一条既有边界:那条被排进队列的消息在**我 `force-stop` 之后跟着没了**(队列只在进程内存里)——与 W40/W41 文档一致,不是新问题。
>
> ### 另外两轮的失败原因(免得下次又猜)
> 第 1、2 轮之所以「什么都没发生」:**前台根本不是我们的 App,而是 `com.android.settings/.SubSettings`**(`dumpsys window` 看出来的)。所以我的点击全落在设置页上。
> ⇒ 教训升级:**不只要确认输入框聚焦,还要先确认前台就是我们的 App**(`dumpsys window | grep mCurrentFocus`)。
> ## W44(2026-10-06 · W41 那个「缺口」的判定:A 已否,B 只到形态,根因要一次设备取证)
>
> 分支 `feat/W44-probe-diagnosis`(基线 `866a3c3`)。本轮**没动任何语义**:`sendRouteFor` 的 fail-closed、探测的 2.5 秒上限、三档路由一个字都没改——判定的结论是「代码这条路是对的,失败生在连接阶段」,而那一档在本机看不出成因。
>
> ### 1. 假设 A(503 被判成 Offline、界面镜像滞后)——**否**
> `ChatSocket.isAuthFailure`(`app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt:320-323`)**只认 `response.code == 401`**(拿不到 response 时才退化成消息文本含 401);非 101 的握手响应(代理回 503)落到 `SessionEnd.Closed` ⇒ `Reconnecting` + 退避重连(同文件 `:301-304`、`:230-235`)。而顶栏副标题「重连中…」正是 `WsState.Reconnecting` 的文案(`ui/chat/ChatScreen.kt:918`,`Offline` 那档是「离线」)。
> ⇒ 那一刻 socket 的真值就是 `Reconnecting`,`sendRouteFor` 在这一档**会**看探测结论,界面镜像也没有滞后。A 不成立。
>
> ### 2. 假设 B ——**形态成立,但「这条链根本发不出请求」不成立**
> - 调用点是**无条件求值的实参**:`sendRouteFor(wsDispatched = false, connection = state, reachability = probeReachability())`(`ui/chat/ChatViewModel.kt:1614`)⇒ 只要走到 `WsSendOutcome.Queued`,探测**一定被调用**(连 `Offline` 那一档也会,`sendRouteFor` 只是把结论丢掉——这一点与本轮前的 KDoc 说法相反,已改)。
> - 新增 `app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt`(6 例,真 OkHttp + 真 Retrofit + JDK `HttpServer`,与 `ChatRepositoryTest` 同一手法):证实这条链**能**在真 socket 上留下 `GET /api/v1/health`、**不带 `Authorization`**、200 ⇒ `Reachable`、503 ⇒ `Unreachable`、2xx 但回执解析不出来 ⇒ `Unreachable`、黑洞服务端在 2.5 秒上限处失败。⇒ 「探测压根发不出去」不是代码事实。
> - 但真机代理日志里一条 `/api/v1/health` 都没有 ⇒ 那个 `Unreachable` 是在**连接之前**产生的。而它在外面**完全同形**(三种成因写在 `data/repository/ConnectivityRepository.kt` 类注释新增的那一节):
> - **(甲) 建 client 就抛**:baseUrl 为空或格式不合法时 `Retrofit.baseUrl` 直接抛,被 `probeReachability` 的 `catch (Exception)` 折成不通。socket 上一个字节都没有。
> - **(乙) 连上了、一个字都没写**:`createProbe` 的 `callTimeout` 是**整通上限(含连接)**,而探测**从不复用连接池** ⇒ 每次都要现做一次冷握手,预算只有 2.5 秒,比共用客户端的 10 秒**连接**预算紧 4 倍。连接比它慢时 OkHttp 在写请求之前就放弃了 ⇒ 对端只看到「连上又不说话」,**日志里一条请求行都不会有**。
> - **(丙) 请求出去了、回执非 2xx**(或回执解析不出来):**只有这一档在对端留痕**。
> ⇒ 所以「代理日志里没有 health 那一行」**推不出**「App 没探过」;(甲)(乙) 都不留痕。上一轮结论里那句「App 从来没探过健康端点」需要收回一半:**没探过是可能的,(甲)(乙) 也同样符合全部现有证据**。
>
> ### 3. 本轮改动(除一处 DEBUG 日志外,全部是文档/测试;无行为变化)
> - 新增测试 `app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt`(6 例)。
> - `data/api/ApiClientFactory.kt` 的 `createProbe`:**DEBUG 下挂 BASIC 日志**(与通用 `create` 同一约定)。这是本轮要的**诊断仪**:`-->` 打在连接**之前**,所以「到底有没有探」与「死在连接还是死在回执」当场可分。探测无令牌、无请求体,日志不涉凭据。
> - KDoc 失真修正:`ui/chat/SendFallback.kt` 的 `sendRouteFor`(「连探测都不做」只在判据层面成立)、`ChatViewModel` 的调用点与 `probeReachability`、`ConnectivityRepository` 补「三种成因 + logcat 判据」、`data/ws/WsState.kt` 的 `Offline` 补「非 401 的握手失败不属于这一档」。
>
> ### 4. 下一步(要设备,一次就能定论)
> 1. 同一套场景重跑(`ws_block_proxy.py` + `adb reverse tcp:10942 tcp:18098`),在输入栏发一条纯文本。
> 2. 看**发消息那一刻**的 `adb logcat -s OkHttp`(探针客户端走 OkHttp 的 Platform 日志):
> - **没有** `--> GET http://127.0.0.1:10942/api/v1/health` ⇒ **(甲)**:死在建 client,去查 App 里配置的服务器地址(空 / 缺 scheme / 有非法字符)。
> - 有 `-->` 后跟 `HTTP FAILED: java.io.InterruptedIOException: timeout` ⇒ **(乙)**:2.5 秒整通上限被连接吃掉。(**不许**为此放宽 fail-closed;要不要让探测的连接预算与链路对齐,是主人裁决。)
> - 有 `-->` 后跟 `HTTP FAILED: java.net.ConnectException` ⇒ 对端根本没在听(`adb reverse` 被冲掉 / 代理没起)⇒ **这不是 App 的 bug**。
> 3. 同时确认**发完消息之后**代理日志里还有没有新的 `BLOCKED /ws/chat`:没有就说明路已经断了(⇒ 第 2 条的第三种),那一轮阶梯其实从没在「HTTP 通」的条件下被考过 —— W40 那一轮就是用 `adb kill-server`/`adb root` 造「真断线」的,这两件都会把 `adb reverse` 一起冲掉(见本文件 W39/W40 两处踩坑记录)。
> ## ★★ 锚点补记十六(2026-10-06 · W45 把「先探 health」换成「直接试 REST」)
>
> ### 状态
> `android` = `origin/android` = **`1d548dd`**(W45 合并提交;分支提交 `ec8d54f`);主树 **1153 测试 / 0 失败 / 0 错误**。W45 worktree 已清。
>
> ### ⚠️ 先说作废:本文件里关于「连通性探测」的描述**全部不再成立**
> 下面这些句子是**历史记录**,保留但**已作废**,别再照着做:
> - **补记十三(W41)**:三档路由里「`Offline` 连探测都不做」「未就绪 + 探测通 ⇒ Rest」、`探测 2500 ms(callTimeout)+ 回落 REST 10000 ms`、无网上界那段「0 次探测 / 一次 REST 都不会发」——**探测这套机制已被删除**。
> - **补记十三**里「fake-backend 补上 `GET /api/v1/health`(否则探测吃 404)」——理由随探测消失(那条路由仍在假后端里,属无害的开发夹具,未清)。
> - **补记十五·补 + W44 那一整段**:`probeReachability` / `createProbe` / `ProbeClientTest` / `sendRouteFor(..., reachability = …)` 以及「logcat 看 `--> GET /api/v1/health`」的判据**全部作废**。
> **保留的结论只有一条**:那个 `Unreachable` 生在**连接之前**——正是它逼出了这次替换。
>
> ### W45 做了什么(设计替换,不是修 bug)
> **判据**:`sendRouteFor(wsDispatched, connection)`(`ui/chat/SendFallback.kt:112`)——
> 1. `wsDispatched` ⇒ `Ws`(成功路径逐字不变)
> 2. `connection == Offline` ⇒ `Queue`,**一个请求都不发**(保留 W41 的理由:该档是「握手 401 且续期也失败」,试了只会拿 401 并落到回滚,比「已排队不会丢」更差)
> 3. 其余三档 ⇒ **`Rest`(直接试)**
>
> **结果分档**:`restAttemptLanding(attempt)`(`:206`)四档 —— `null`(整通上限到点)⇒ `Unsent` 进队列(fail-closed);`success` 且 `isAccepted()` ⇒ `Accepted`(**不进队列**);`success` 但被拒 ⇒ `Refused`(回滚 + 既有文案);`failure` ⇒ 交给 `restFailureLanding`(网络族/映射不出 ⇒ `Unsent`;Server/Unauthorized/配置族 ⇒ `Errored` 回滚)。
> **判据里已没有任何 `Reachability` 输入**(签名本身就是这条事实)。
>
> ### 两个超时(**与任务书的措辞有出入,取舍已写进 KDoc**)
> - **连接超时 2.5 秒**挂在**那个共用 client** 上(`data/api/ApiClientFactory.kt:39`):短到「无网快速进队列」,又不会连累长文本/图片/文档下载。
> - **整通上限 10 秒仍在调用点**(`REST_FALLBACK_TIMEOUT_MILLIS` + `withTimeoutOrNull`,`ChatViewModel.kt:1662`)。
> **为什么没做成 client 级 `callTimeout`**:这个 client 是共用的,client 级 `callTimeout` 会把长文本 / 内联图片那两条(本轮不碰的路)一并框住,还连累 `DocumentsApi` 的字节下载 ⇒ 等于给它们引入新失败面。而「无网快速进队列」**只靠连接超时就保住了**。
> - **无网上界:完全无网 ≤ ~2.5 秒进队列**(与 W41 持平,没有变长)。
>
> ### 一条被证伪的担心(记下来免得再犯)
> 我曾担心「`stream:true` 的回执是内容流,所以要先关掉 `stream` 才能判『收下了』」。W45 核实后**不成立**:
> `POST /api/v1/chat` 的 `stream:true` 回执是**小 JSON**(`chat_stream_dispatch.go:201` 的 `writeChatStreamAck`:`200 {stream:true}` / `202 {queued:true}` / `200 {duplicate:true}` / `409` / `503`),回合跑在自己派生的 goroutine 上,回执写完即返回 ⇒ **读完这段 body 就有 `ChatAck`**,不等整轮生成、不用改契约。
>
> ### 删了什么 / 测试怎么承接
> **删除**(删前 grep 全仓确认无其它调用方):`data/repository/ConnectivityRepository.kt`、`data/api/HealthApi.kt`、`data/api/dto/HealthDto.kt`、`ProbeClientTest.kt`、`ApiClientFactory.createProbe` 与 `PROBE_TIMEOUT_MILLIS`、`AppContainer` 的 `healthApi`/`connectivityRepository` 接线。
> **新增** `RestAttemptClientTest`(6 例,真 OkHttp + 真 Retrofit + JDK HttpServer)**承接仍然成立的事实**:请求真的写出去(现在是 `POST /api/v1/chat`)、请求体带 `clientMsgId`/`stream:true`、**这次带令牌**(`Authorization` + `X-API-Version`,与探针相反)、三种「收下了」的回执 ⇒ `Accepted`、`409` ⇒ `Refused`、连不上 ⇒ 快速失败且 `Unsent`。
> **丢掉的四条**(钉的都是已不存在的机制):`GET /api/v1/health` 落到线上、探针不带令牌、`200 但 data 空 ⇒ Unreachable`、探针 `callTimeout` 处失败。**fail-closed 这条事实没有丢**(由「整通上限到点」与「异常映射不出」两条承接)。
> 唯一略降级:连接超时的**数值**现在由纯断言钉(2s ≤ x ≤ 3s),运行时那侧只用「没人听的端口 ⇒ 很快失败 ⇒ 队列」兜住。
>
> ### 可观测性(下一轮设备验证就靠它)
> **主 REST client 的 OkHttp `BASIC` 日志本来就开着**(`ApiClientFactory.kt:88-94`,DEBUG 下所有 `create` 都挂),**不需要改动**:
> ```
> --> POST http://<host>/api/v1/chat (NNN-byte body)
> <-- 200 OK http://<host>/api/v1/chat (12ms, 41-byte body)
> ```
> 失败时第二行是 `<-- HTTP FAILED: java.net.ConnectException…`(或被短连接超时掐断的 `SocketTimeoutException: connect timed out`)。
> `-->` 打在 `chain.proceed()` **之前**(连接都还没开始建)⇒ **「这条请求有没有发出去」与「死在连接还是死在回执」当场可分**。
> `BASIC` **不打印任何请求头**(`HEADERS`/`BODY` 才会;`redactHeader("Authorization")` 是双保险),级别没有上抬。
> 注:**WS 那条连接(`ChatSocket`)没挂日志**,所以 `-s OkHttp` 里出现的都是 REST 调用——正好是下一轮要的判据。
>
> ### ⚠️ 一条事故记录(已核实无害)
> W45 子代理的**文件工具把相对路径解析到了主仓库**(不是它的 worktree),最初三次编辑落在主仓的 `AppContainer.kt` / `ApiClientFactory.kt` / `SendFallback.kt` 上。它自己发现后用 `git checkout --` 还原,并用绝对路径重做。
> **我亲自核过**:主仓 `git status` 只有 `devdocs/02`(后端会话的在途追加)、`HEAD` 仍是当时的 `2bee0b5`、那三个文件 `git diff HEAD` **零差异** ⇒ **确实还原干净**。
> **教训**:给子代理的路径一律写绝对路径,且收工后**必查主仓 `git status`**。
>
> ### 待办(更新)
> 1. 🔴 **设备验证(下一轮第一件)**:`ws_block_proxy.py` + `adb reverse tcp:10942 tcp:18098`(WS 断、HTTP 通),发一条纯文本 ⇒ 用 **`adb logcat -s OkHttp`** 确认 `--> POST /api/v1/chat` 与 `<-- 200`,并确认**库里落库、没进队列**。**先 `dumpsys window | grep mCurrentFocus` 确认前台是我们的 App**(补记十五·补的教训)。
> 2. `devtools/fake-backend` 里的 `GET /api/v1/health` 路由与 README 的说法现在是死的(无害夹具,未清)。
> 3. 待主人过目:静默持续到某次补发成功 vs 每轮结束恢复告知(改一行)。
> 4. 待办:子会话流程跑一遍出清单;W42 多选器真机观感。
> ## ★★ 锚点补记十七(2026-10-06 · **W45 那条阶梯验通了**,以及我连续三次的误判)
>
> ### 状态
> `android` = `origin/android` = **`16699c4`**(W46 合并提交;分支提交 `d546f31`);主树 **1155 测试 / 0 失败 / 0 错误**。W46 worktree 已清。`02` 里另有一笔**已推**的登记(`b8399b5`,工具卡缺数据面,见下)。
>
> ### ✅ 设备验证:**通过**(判据三条独立)
> 场景:`ws_block_proxy.py` 只掐 `GET /ws/chat`(回 503)+ `adb reverse tcp:10942 tcp:18098`;新包(含 W46 的 `CyreneRoute` 日志);输入栏发一条纯文本。
> ```
> CyreneRoute: wsDispatched=false socket=Reconnecting mirror=Reconnecting wsObject=absent route=Rest reason=ConnectionNotReady outcome=Queued fromQueue=false
> 代理日志: PASS POST /api/v1/chat -> 200
> 库里: 恰好 1 条
> ```
> ⇒ **`route=Rest` ⇒ 直接试 REST ⇒ 请求真的发出去(代理看到 200)⇒ 消息落库。** W45「把探测换成直接尝试」**成立**。
> 顺带把 W41 那个「探测不可用」的缺口**绕过去并消掉了**(那套机制已被删除)。
>
> ### ⚠️ 我在这一串里连着错了三次(必须记下来)
> 1. 「App 从来没探过 `/health`」——**推不出来**(W44 证明:`Unreachable` 生在连接之前,三种成因在对端同形)。
> 2. 「零请求 + 进队列 ⇒ 只能是 `Offline` 那一档」——**错**。这次日志给出的真实答案是 `route=Rest`,**REST 确实发出去了**。
> 3. 前几轮「发送没发生」的结论——**错**,有两次是我点在了 `com.android.settings` 上(前台不是我们的 App),有一次消息其实**进了队列**(我当时只查了网络侧、没看界面)。
>
> **共同点:我拿「对端日志缺席」当「客户端没做」的证据,而中间还隔着几层我看不见的东西。**
> **纪律(写死)**:① 设备验证**第一步永远是 `dumpsys window | grep mCurrentFocus`** 确认前台是自己的 App;② 要判「客户端做了什么」,**优先看客户端自己的输出**(`CyreneRoute` / `logcat -s OkHttp`),**不要**从代理/后端日志反推;③ 一次只改一个变量。
>
> ### 一条仍未解释的旧现象(如实留着)
> W45 那次(同一场景)气泡停在「排队中 · 还没连上」、代理没有 POST、库里没有那条。**与本次唯一可见的差别是时机**:那次我在 App 启动后 ~55 秒就发了(正撞上启动期的请求洪峰),这次 App 已稳定运行一分多钟。
> **最可能的解释**:`route=Rest` 但那次 REST 死在**整通上限 10 秒**(被启动期拥挤的连接/续期吃掉)⇒ `Unsent` ⇒ 进队列。**未经证实**,W46 的日志下一次复现时可以直接读到(看 `route` 与 `reason`,再看 OkHttp 有没有 `--> POST`)。
>
> ### W46 留下的可观测性(以后常备)
> `ChatViewModel.sendThroughLadder` 一进入就一行 **`Log.d("CyreneRoute", …)`(仅 DEBUG)**:
> `wsDispatched=<bool> socket=<WsState> mirror=<WsState> wsObject=<present|absent> route=<SendRoute> reason=<SendRouteReason> outcome=<WsSendOutcome> fromQueue=<bool>`
> - `socket` 是**权威值**(`ChatSocket.state.value`),`mirror` 是**顶栏读的那个镜像** —— 一行就能分辨「镜像滞后」。
> - `reason` 三档:`FrameDispatched`(走 WS)/ `ConnectionOffline`(进队列,一个请求都不发)/ `ConnectionNotReady`(直接试 REST)。
> - 这是全仓**唯一**一处 `android.util.Log`,引入理由:必须是稳定可 grep 的固定 tag,而 OkHttp 的 BASIC 日志只照得到 REST 侧、照不到这条判据。
> - `SendFallbackTest` 有「结果 + 原因」的全表测试。
> - 复核结论:`WsState.Offline` 的写点**只有两处**(`ChatSocket.kt:233` ConfigMissing、`:284` 鉴权失败),**503 到不了 Offline** —— 所以「零请求 + 进队列」在纯函数层面**只有** `ConnectionOffline` 一条,本次实测也确认了不是它。
>
> ### 另外:主人报的「工具调用记录回合结束后消失」——根因已定,**已登记给后端**
> **数据面缺失**:工具卡的数据只活在**流式路的本地状态**里;助手行落库时客户端把整条路丢掉,而**落库消息 DTO 里没有工具字段**可渲染。
> **证据是同一段代码里的对比**:思维链有「打捞 + 滞留 10 分钟」的补救(因为 DTO 里有 `thinking`),**工具一个字段都没有**。
> **不是我方本地状态的 bug**(我方与 `devdocs/03:141`「thinking / tool_call:仅 WS 实时有」逐字一致)。后端**其实已经把工具调用落进 `flow_graphs`**,缺的是「取出来给聊天端」。
> 已登记进 `02`(`b8399b5`):要可选字段(`omitempty`,缺席=老行为逐字不变)、覆盖面对齐 `turnId`、**归属要明确**、允许开独立端点。
> 我方接线点:目前 `ToolCard` 在「已落库消息」那条渲染路径上是**不可达代码**(渲染器签名里没有 tools 参数)——字段一到即可接。
> ## ★★ 锚点补记十八(2026-10-06 · W47 队列静默改为「按事故复位」)
>
> ### 状态
> `android` = **合并提交见下**;分支提交 `ce50bd2`;主树 **1158 测试 / 0 失败 / 0 错误**。W47 worktree 已清。
>
> ### 主人裁决
> 「静默持续到某次补发成功(A)vs 每批结束复位(B)——**哪个从体验上更好就用哪个**」。**判定:B**,但把 B 改准成「**按事故复位**」。
> **为什么不选 A(原实现)**:A 只在「某次补发**成功**」时清零 ⇒ 若一个坏网络时段的队列最后**以回滚/被收编结束**(而不是以成功结束),静默会**一直挂着**;之后连接恢复、再断一次、用户又发消息时**永远得不到解释**。那不是减少噪音,是**把解释永久关掉**。
>
> ### 改了什么
> - `ui/chat/SendQueue.kt` 新增纯函数 **`queuedResendFailuresAfterDelivery(failures, sent, drained) = if (sent || drained) 0 else failures`** —— 清零点判据的唯一出处(与 `queuedSendNoticeAllowed` 同一写法)。
> - `ChatViewModel.forgetQueuedNoticeIfDrained(sent)` 从「只清 `queuedNoticeTold`」改成**同时按新判据复位 `queuedResendFailures`**;`dropQueuedSend` 里原来那行 `if (sent) queuedResendFailures = 0` 删掉,改为出队后调它 ⇒ **两个清零点收在同一个函数里**。
> - **阈值仍是 3**、「一批从空到非空只说一次」仍由 `queuedNoticeTold` 负责、`withoutQueuedSend` 等队列操作**一行未动**。
>
> ### 「事故」的界定与验收体验(已写进 KDoc)
> **事故 = 队列从空到非空、再回到空的那一段**(与 `queuedNoticeTold` 的「一轮」同口径)。结束就复位,**不管它是怎么结束的**。
> - **一次事故内**连发几条 ⇒ 只有**一次**横幅(重试本身不产生横幅);
> - **事故结束后**再遇新事故 ⇒ 计数已复位 ⇒ **会重新解释**,不会永久闭嘴;
> - **长期断网**不停发 ⇒ 消息**堆在队列里、队列不清空** ⇒ 不算新事故 ⇒ **不会每条都弹横幅**;
> - **静默 ≠ 丢弃**:计数只进告知判据,`silence never touches the queue` 那条用例继续通过。
>
> ### 未做
> - **设备验证**(任务书禁碰 adb):「一次事故一次横幅」的体验只由纯函数单测 + KDoc 钉住。若要设备验收,用 `ws_block_proxy.py` 造场景即可。
> - 项目没有 `ChatViewModel` 单测装置,所以复位判据提成纯函数来钉(与 W43 同一做法);清零点被调用这件事靠既有架构保证。
> ## ★★ 锚点补记十九(2026-10-06 · **「子会话流程」从模糊待办变成清单**)
>
> 依据:HEAD=`ff699f5` 的**只读静态核对**(含后端定点交叉验证)。⚠️ **除 S3/S6 之外均未做设备实测**(完成判据里「需真机」已逐条标出)。
>
> ### ⚠️ 先纠正两条我自己写错的前提(免得沿用错的方向)
> 1. **「每个 `ChatScreen` 各建一条 `ChatSocket`」已经不成立** —— W28(`7b97440`)已收成**进程级唯一**:`AppContainer.kt:229`(`by lazy` 唯一实例)、`ChatViewModel.kt:964-967`(只订阅)、`SessionsViewModel.kt:135-153`(唯一 connect/close 主人)。剩下的是**VM 不回收**那一半(S4)。
> 2. **`sendInterrupt` 不再是死 API**(`devdocs/02:144` 已闭环):唯一调用方 `ChatViewModel.kt:2006-2009`(「设置=打断 且 忙」时才发)。但**「切会话时打断」这个原始出口确实从未接线**(S5),且全 App 没有停止按钮。
>
> ### 清单(按严重度降序)
>
> **S1 · 客户端问题 · 体验瑕疵(换服务器 = 看到别的后端的内容)**:登出/重登或**换服务器地址**后,聊天页仍显示**登出前那份消息列表**(抽屉则空着,直到打开一次才回来)。
> 证据:`ChatScreen.kt:150-153`(`viewModel(key = sessionId)`,owner 是 Activity 级 store)+ `ChatViewModel.kt:932-968`(只在 init 拉一次)/`:2912-2914`(`onCleared` 空)+ `CyreneApp.kt:23-31`(登出只把它移出组合,实例仍在);**会话列表有 `baseUrl+username` 指纹闸**(`SessionPreferences.kt:34-36`、`SessionsViewModel.kt:397-398`),**ChatViewModel 没有对应指纹**。
> 修法方向:给 VM key 加账号指纹(一行);**真正的回收**要自定义 `ViewModelStoreOwner`,而它与「切回来状态还在」强绑定 ⇒ **属要主人拍板的设计取舍**。
>
> **S2 · 客户端问题 · 体验瑕疵**:**次级会话没有「握手 history」这第二条安全网**。进次级会话时 WS 未连(冷启动离线)→ 此后连接恢复**不补拉**;主会话由 `history`(最近 50 条)兜住。
> 证据:`ChatViewModel.kt:982-997`(`gapCursor` 只在「离开 Connected」时记、重连才补)、`:1787-1805`(`armGapCursorForFallback` 的既有边界);后端 `ws_hub_conn.go:57` 固定 `replayHistory(MainSessionID)`、`ws_history.go:36`。
>
> **S3 · 客户端问题 · 需真机确认**:冷启动进次级会话可能**闪一下「还没有消息」**。
> 证据:`ChatViewModel.kt:2408-2409`(history 帧过滤后为空就无条件 `loading=false`)而初值 `true`(`:610`),正文要等 `repo.loadLatest`;渲染分支 `ChatScreen.kt:486-487`。
>
> **S4 · 客户端问题 · 仅技术债**:**每个访问过的会话各留一个常驻 VM**(仍订阅共享流、**每次重连各自发一轮 after 补拉**、删会话不回收),各留消息列表(含内联 base64 图片)。
> 证据:`ChatScreen.kt:150-153`/`177-182`、`AppContainer.kt:215-221`(KDoc 自认)、`ChatViewModel.kt:966`/`982-997`/`2303-2310`/`2960`(20 页上限)。
> ⚠️ **顺带一处文档硬伤**:`ChatHomeScreen.kt:26-33` 与 `ChatScreen.kt:161-164` 写「`onSessionGone` 一被调用,那个会话的 VM 连同状态**立刻被销毁**」——**这是错的**(key 变了只是组合被销毁,VM 仍在 store 里继续收帧)。行为结论没错、成因写错了,**建议顺手改对(零成本)**。
>
> **S5 · 客户端问题 · 体验瑕疵**:**切会话不打断**——在次级会话发一条后立刻切回主会话,那一轮继续在服务端跑完(算力照花),抽屉也看不出谁在生成。
> 证据:`sendInterrupt` 唯一调用点 `ChatViewModel.kt:2008`(仅发送时刻按 `InterruptScope.kt:39-40` 发)、会话切换路径 `ChatHomeScreen.kt:119-122` 里没有任何打断动作。
> 注:**不接线今天未必更差**(切走不打断 + VM 不回收,恰好让「切回来那一轮还在、流式完整」成立)。真正缺的是**能看见 + 能停**的新入口(可复用 S9 的广播帧)。
>
> **S6 · 契约缺口(要登记后端)+ 客户端 · 体验瑕疵**:**抽屉未读对「非激活会话」不实时增长**——停在主会话时次级落了新消息,红点不出现,必须开一次抽屉才补上。
> 证据:`SessionsViewModel.kt:359-369`(`message_persisted` 只在 `sessionId == activeSessionId` 时推进)、列表更新唯一触发点 `ChatHomeScreen.kt:96-102`;**后端设计里已登记为遗留**:`Cyrene_v2/DevDocs/design/16:103`。归类理由:`message_persisted` **不带 `unreadCount`** ⇒ 客户端算不准(契约面缺增量);客户端可自愈(节流重拉 `GET /sessions`)。
>
> **S7 · 客户端问题 · 仅技术债**:**已删除会话的僵尸 VM** 下次重连会发一次注定 404 的补拉,404 被吞(`sessionGone` 位没有消费者)。证据:`SessionsViewModel.kt:265-278`、`ChatViewModel.kt:2303-2310`/`:2333-2336`/`:2912-2914`。
> ⚠️ **本行有一处我写错了(W51 更正)**:`sessionGone` 的**消费者一直都在** —— `ChatScreen.kt:165-166` 的 `LaunchedEffect(state.sessionGone)` → `ChatHomeScreen.kt:109` 的 `onSessionGone`(一句如实告知 + 带回主会话),由 **`b5c0b13`(2026-10-04,本行所依据提交 `ff699f5` 的祖先)** 引入,`git merge-base --is-ancestor` 已验证。**真正缺的那一环**是「出路不能依赖网络」(原来只靠 `refresh` 成功后的回落),见锚点补记二十九。
>
> **S8 · 结论:以下几处主/次「没有分叉」,别再返工**:发送/流式/灰态/**并行分桶**、分页与补拉、主会话禁删(三处一致)、改名/置顶、已读游标、分享落地/D2 搜索/图片与附件 —— 全部按 `sessionId` 等值收敛,**同一份代码**。
> 另:**`sessionId → N` 序号映射已在 `c09a211` 整块退役**(`devdocs/02:146`)⇒「次级会话序号」**不再是待办**。
>
> **S9 · 客户端问题 · 仅技术债**:「这个会话正在生成」**既看不见、也没有冷启动可查的状态面**:`session_busy`/`session_idle`/`turn_begin` 都带 sessionId 且在广播,但 `SessionsViewModel.onEvent`(`:335-344`)只认 `session_read`/`message_persisted`;握手也不回放状态。
>
> ### 优先级建议(排查方给的)
> **先修**:**S1**(唯一会把「别的环境的对话内容」摆上屏幕的东西;便宜的一步是给 VM key 加账号指纹)、**S6**(日常最容易撞到;客户端可自愈 + 给后端登记)。**可继续搁置**:S4/S5/S3/S7/S2(各有理由,且 S4/S5 的正解与产品取舍绑定)。**零成本顺手**:把 S4 里那处 `ChatHomeScreen.kt:30` / `ChatScreen.kt:163` 的错误说法改对。
>
> ### 设备验收步骤(12 步,已设计好;**除 S3/S6 外都还没跑**)
> 步骤骨架(每步的判据在排查报告里,逐条都很具体):0 纪律(先 `dumpsys window | grep mCurrentFocus` 确认前台是本 App + `adb reverse tcp:10942 tcp:10942`)→ 1 建次级会话(判 `GET /sessions` 里有 `type:"sub"`)→ 2 次级发送+流式(判 `logcat -s CyreneRoute` 恰好一行 `route=Ws`、库里两条)→ 3 冷启动进次级(**盯 S3 那一下闪**;判无主会话内容混入)→ 4 切会话不打断取证(假后端 `out/upstream.jsonl` 无 `interrupt`)→ 5 次级没有 history 兜底(`--history-replay 2`,判 history 帧只含 `web`)→ 6 「次级+首屏空+WS 从未连上」的已知洞(**需 `ws_block_proxy.py`**)→ 7 未读/已读(**S6 现场**)→ 8 置顶/改名/禁删 → 9 删除(判 `GET .../messages` 404)→ 10 别处删除的兜底横幅 → 11 重连补拉在次级跑不跑(S2 闭环)→ 12 登出/重登陈旧状态(**S1 现场**)。
>
> ### ⚠️ 工具位置(排查方找不到它)
> **`ws_block_proxy.py` 不在 git 里** —— 它在 `app/build/e2e-shots/`(**被 gitignore**)。它很好用(只掐 `/ws/chat`,且 **Web 前端不经过 adb reverse ⇒ 代理日志只来自 App**),但**一个 `clean` 就可能把它扫掉**。**建议下一步把它挪进 `devtools/`**(连同一小段用法说明),否则下次又得重写。
>
> ### 排查方自己标注的不确定(照抄,别当成全量结论)
> 结论都是**静态核对**;S1/S2/S3 需真机确认。**后端只做了定点核对**(握手回放、广播路由、会话 404 契约、上行帧路由、回合播种)——**没有通读编排器**,所以「次级会话的回合在服务端与主会话 100% 同形」**不能背书**;记忆写回、主动消息、后台思考是否只走主会话**未逐条核对**。
> ## ★★ 锚点补记二十(2026-10-06 · **W42 图片多选设备验收:通过**)
>
> ### 结论:功能通过;一处观感待主人定
> 环境:模拟器 `Pixel_10_Pro_XL`(1080×2340),包 = 当前 `android` 的 `assembleDebug` 产物,服务器 `127.0.0.1:10942`。
>
> **判据(逐步,都是实机)**:
> 1. 点输入栏左起第一个图标(图片)⇒ **打开了系统 Photo Picker**(`mCurrentFocus = com.google.android.providers.media.photopicker.PhotoPickerActivity`,文案「此应用只能访问您选择的照片」)⇒ **多选器入口成立**。
> 2. 点两张图 ⇒ **两张同时被选中**(各是**蓝底对勾**,不是单选圆点)⇒ **确实是多选,不是单选**。
> 3. 确认按钮文案 = **「添加(2 项)」**(`bounds=[734,2081][1036,2225]`)⇒ **计数正确**。
> 4. 点它 ⇒ 回到聊天页,**输入栏上方出现两枚缩略图,各带一枚移除键(X)** ⇒ **「N 张图 = N 条消息」的前置(草稿态)成立**。
>
> **⚠️ 一处观感要主人拍板**:那枚**移除键(X)叠在缩略图的中间**(不是常见的角标位置)。是刻意(整张点击即移除)还是布局问题,**属观感取舍**,我不替他下结论。截图:`app/build/e2e-shots/p4-selected.png`(选择器)/ `p5-composer.png`(输入栏)。
>
> **未验的邻近一格**(本次没做,留个尾巴以免误以为都验了):**点那枚 X 是否真的移除那一张**、以及**真发出去后 N 张是否落成 N 条消息**(后者需真的发送一条带两图的消息,会真的花钱并往会话里写消息)。
>
> **过程中撞到的环境坑(已记过,但仍复现了)**:`svc power stayon true` 与 `screen_off_timeout` **都没挡住黑屏** ⇒ 第一次点击全落空、`screencap` 全黑。**开测前先 `input keyevent 224` + `82` 唤醒,并立刻截图确认屏幕是亮的**。
> ## ★★ 锚点补记二十一(2026-10-06 · 移除键验到了;③ 的第一次尝试**没跑成**)
>
> ### ✅ ② 的「未验邻近格」补验一格:**移除键有效**
> 输入栏两枚缩略图,点第一枚的 X ⇒ 变成**一枚**(第二枚按布局回流**左移**)。
> ⚠️ **我的测试失误(记下来)**:我第二次点击用了**旧坐标**,结果点空(第一枚移除后第二枚已左移)——这是**布局回流的正常行为,不是 bug**。教训:**每次点击前重新取样坐标**,别在同一排会回流的东西上复用。
> ⇒ **② 的两个未验格里,「移除键」这一格补上了;「N 张真发出去是否落成 N 条」仍未验**(要真发一条带图消息)。
>
> ### ⛔ ③ 第一次尝试:**没跑成**(判据三条一致:发送没触发)
> 做法:`ws_block_proxy.py` + `adb reverse tcp:10942 tcp:18098` + **force-stop 重启 App**(必须重启:否则旧连接还活着 ⇒ 会走 `Ws` 档,压根考不到阶梯);`logcat -c` 清过;启动后 **45 秒**发一条纯文本。
> 结果:**`CyreneRoute` 日志为空、代理没有 POST、库里没有那条** ⇒ **`sendThroughLadder` 一次都没被调用**,即**发送没发生**。
> 最可能的原因与前几次同源:**输入注入没命中**(45 秒时 App 可能仍在加载;我没有在打字后先确认「文字真的进了输入框」就点了发送——**这条纪律我今天写了两次,这回又没执行**)。
> **结论:③ 仍未定论。** 下一轮的做法必须改成三步都确认:① `dumpsys` 确认前台是本 App → ② 打字后 `dump` 确认输入框里**确实有那段文字** → ③ 再点发送;然后读 `CyreneRoute` + 代理 + 库。
> ## ★★ 锚点补记二十二(2026-10-06 · **③ 有结论了**:机制正常,那一次异常未复现)
>
> ### 结论
> **「WS 断、HTTP 通 ⇒ 回落 REST」这条阶梯是工作的**(**两次有效观测**都走通了);**那一次异常(W45 那轮停在队列)未能复现**,且当时 `CyreneRoute` 日志还不存在 ⇒ **无法确定它当时选了哪条路**。按完成判据,这一条以「说明为何不可复现」结案。
>
> ### 两次有效观测(判据一致)
> 场景:`ws_block_proxy.py` 只掐 `/ws/chat`(503)+ `adb reverse tcp:10942 tcp:18098` + **force-stop 重启 App**(**必须重启**:否则旧连接还活着 ⇒ 会走 `Ws` 档,考不到阶梯)+ `logcat -c` 清过。
>
> | 次 | CyreneRoute | 网络侧 | 库 |
> |---|---|---|---|
> | W46 那轮 | `route=Rest reason=ConnectionNotReady` | 代理 `PASS POST /api/v1/chat -> 200` | **1 条** |
> | 本轮(`w49`) | `route=Rest reason=ConnectionNotReady` | (未取代理日志,落库即证明 POST 成功) | **1 条**(`cli=local-32f2c53d`) |
>
> ### 那次异常(未复现)
> 现象(W45 那轮):气泡停在「**排队中 · 还没连上**」、代理**没有 POST**、库里**没有那条**。
> 最可能的解释(**仍未证实**):`route=Rest`,但那次 REST 死在**整通上限 10 秒**(被启动期的请求洪峰吃掉连接预算)⇒ `Unsent` ⇒ 进队列。
> **为什么不能定论**:那轮发生在 `CyreneRoute` 日志加入之前(W46 才加),所以「它当时选了 Rest 还是 Queue」**没有证据**;之后两次复现都正常。
>
> ### ★ 本轮最关键的收获:**一套可靠的设备驱动协议**(写死它)
> 这一轮之所以终于拿到数据点,是因为严格执行了三步,而且**第三步是从 dump 里取真实坐标**:
> 1. `dumpsys window | grep mCurrentFocus` 确认**前台是本 App**;
> 2. 打字后 **dump 并检查那段文字的 `bounds`**——判断它**在输入框里**(x 落在 275..845、y 被软键盘顶到 ~1293..1435)还是**已经成了列表里的气泡**(y < 2000)。这一步就是「发送到底触发了没有」的判据;
> 3. **不要复用发送键坐标**:软键盘会把整条输入栏顶高约 820px。本轮 `发送` 的真实 bounds 是 `[911,1333][1003,1399]`(不是无键盘时的 `[911,2166][1003,2232]`)⇒ **从 dump 里取 bounds 再点**。
> ⇒ 我今天在这个坑上栽了 4 次(黑屏、前台不是本 App、复用坐标、不确认文字落点)。**这四步之后,设备验证才算「可靠」。**
> ## ★★ 锚点补记二十三(2026-10-06 夜 · 两个子代理在飞 + S6 的契约缺口已登记)
>
> ### 在飞(都已派单,未合并)
> - **W48 `feat/W48-account-scoped-vm`**:① 修 **S1**(换账号/换服务器后聊天页仍显示上一环境的消息 —— 给 `ChatScreen` 的 VM key 加**账号指纹**,沿用 `SessionPreferences` 的 `baseUrl+username` 口径,**不回收旧 VM**);② 改掉 `ChatHomeScreen.kt:26-33` / `ChatScreen.kt:161-164` 那两处**写错的注释**(「VM 立刻被销毁」是错的,行为不动);③ 把 `ws_block_proxy.py` 从 gitignored 的 `app/build/e2e-shots/` **挪进 `devtools/ws-block-proxy/`** 并配 README(否则一次 `clean` 就没了)。
> - **W49 `feat/W49-inactive-unread`**:修 **S6** 的客户端一半 —— 收到**非激活会话**的 `message_persisted` 时**节流地静默重拉**列表,让红点在不开抽屉时也能出现;要求**节流 + trailing 语义**(最后一条之后必须还能再拉一次),且**不许**新写取数路径、不许 loading 闪烁。
>
> ### 已登记给后端(`02`,本轮)
> **S6 的契约缺口**:`message_persisted` **不带 `unreadCount`** ⇒ 客户端算不准未读、只能整表重拉。请他们在新消息广播上带**未读增量或 `unreadCount`**(`omitempty`,缺席=老行为),并说清是绝对值还是增量、以谁的已读游标为基准;或给一个**批量查询未读**的面。**我方自愈不能替代它**(每次仍是一次完整 `GET /sessions`,会话多了会变贵)。
> 顺带提了:这条他们自己的设计文档里也记着(`Cyrene_v2/DevDocs/design/16:103`)。
>
> ### 合并时的纪律(提醒下一轮的自己)
> 两个 worktree 的基线都是 `df8df67`;合并要**分别** `--no-ff` + 全量 `assembleDebug`/`testDebugUnitTest` + 当场 `rm -rf` + `git worktree prune`,并**核对主仓 `git status` 只有 `02` 那笔私有改动**(曾有子代理误写主仓)。
> ## ★★ 锚点补记二十四(2026-10-06 深夜 · **一条流程教训**:写 `02` 之前必须先读它的最新追加)
>
> ### 我差点犯的错
> 我给后端写了一整节(`7d1c56e`「我方依赖的线形状」)**之前只看了 `git status` 知道 `02` 是脏的,就假定那还是我上次看到的那批追加**。
> **主人一句「你确定不先读一下?」把我拦住了** —— 一读发现后端**早已回了** `回执二十二 · 补`,里面有 **🔴 一条点名问我们的 P1** 和另外三问。
> ⇒ **纪律(写死):动 `02` 之前,除了 `git status`,还要 `git diff` 看它最新那几十行**(`git diff -- devdocs/02-… | grep '^+' | tail -45`)。**「脏」不等于「还是那批内容」。**
>
> ### 后端正在等我们回的四件事(`回执二十二 · 补`,原文节选)
> 1. **🔴 P1:主动消息会不会显示两次?** —— v0.72 起她主动说的话**既是实时帧、也是主会话历史里的一条真消息**,且帧里新增了 **`data.id`**(= 落库行权威 id)。若我们「用帧画一个气泡 + 拉历史再画一个」⇒ **同一条显示两次**;他们说 Web 侧已按 id 去重,问**我们做了没有**(**未做就是 P1 客户端项**)。好处:做了之后**刷新/重开 App,她主动说的话仍在**。
> 2. **`tool_execution` 取哪个口径**:(a) 我们**只认 `.start`/`.end` + 按 `id` 关联` ⇒ 他们不再补 `.update`**;或 (b) 我们确实要不要 `.update`(中间进度)⇒ **他们排一片后端工作**。**要我们选一个。**
> 3. **我们那份待办清单的现状**(哪些要他们配合)—— 注意:他们列举里的「**`ChatScreen` 每屏一条 WS**」**已经是过时前提**(W28 早就收成**进程级唯一连接**了;剩下的是 VM 不回收),回信时要纠正。
> 4. **移动端要不要系统推送**(形状层面)—— 主人说过**等他自建推送**再定。
> 另两条无需回复的 FYI:**v0.70 起实时 `backthink` 帧带 `urgency`**(routine/warning/critical);**v0.69~v0.75 除 v0.72/v0.74 外全是 admin 面**(对我们零改动)。
> ⚠️ 我读到的只是他们追加的**尾部 45 行**,第 4 条的句子在 190 字处被截断 ⇒ **回信前要读全**。
>
> ### 已派单核实
> **只读排查**(`agent-8qtm01gd`)就为这两条取证并给出建议:问题一的「帧画的 vs 历史拉的会不会被按 id 合并成一条」(以及最小修法),问题二的「`.start`/`.update`/`.end` 三档各自是否解析 / 是否消费」以及**只发 start/end 时工具卡够不够用**。
> ### 附:本轮回后端的两个**决定**与一个新 P1(补记二十四处)
> **① P1(我方接):主动消息实时看不到。** 核实结论:`proactive_message` 帧我们**只解了错的 id**(`messageId`,而落库行是另一个 `id`;`ignoreUnknownKeys` 把新 `id` 静默丢弃),且**整帧零消费**(`ChatViewModel.onEvent` 无该分支)⇒ **「显示两次」不会发生,但「实时看不到她主动说的话」是真缺口**(只能等重连/重开/切会话拉历史)。修法位置已定(`WsEvent.ProactiveData` 补 `id`/`createdAt` + `ChatViewModel` 加分支,**按已落库 assistant 行并入 `mergeMessages`**,**必须自己钉死主会话**——该帧壳**不带 `sessionId`**,否则会窜进每个次级会话)。
> **② `tool_execution` 口径:选 (a)**(只认 `.start`/`.end` 按 `id` 关联;**不要他们补 `.update`**)。取舍已明确:**没有 `.update` ⇒ 工具卡的结果区是空的**(`ToolCallState.output` 只由 `.update` 的 delta 追加)⇒ **我们真正要的是他们已认领的「持久化 `toolCalls[]` 回看面」**,并请求把输出/摘要一并带上。
> **③ 移动端推送:暂不接**(等主人自建)。并已告知他们:他们建议的「应用内提示」`message_persisted{proactive:true}` 在我们这边**零实现**(`PersistedData` 只读 `message`;客户端无任何 Notification 代码)⇒ **那条建议目前不产生任何效果**,别当兜底。
> ## ★★ 锚点补记二十五(2026-10-06 深夜 · W48 + W49 落地:S1 与 S6 的客户端半边)
>
> ### 状态
> `android` = `origin/android` = **`5dc6408`**(两个合并提交);主树 **1177 测试 / 0 失败 / 0 错误**;**worktree 全清**(只剩主树),主仓只剩 `02` 那笔后端会话的在途追加。
>
> ### W48(分支 `4f860aa`,合并 `e589ab3`):修 **S1** + 注释纠错 + 工具归位
> - **账号指纹进 VM key**:新增顶层纯函数 **`accountFingerprint(baseUrl, username)`**(`core/prefs/SessionPreferences.kt`,**口径就是既有的 `baseUrl|username`,不另造第二套** —— `SessionsViewModel` 的私有实现也改成调它),VM key = `指纹 + "|" + sessionId`(`ui/chat/ChatScreen.kt` 的 `chatViewModelKey`)。
> - **顺带修了同类缺陷**:**会话内搜索小 VM** 的 key 也加了指纹(它持有的 `hits` 就是消息正文,换环境后在主会话(id 恒为 `web`)重开搜索会把**别的服务器**的消息摆进面板)。
> - **5 处错注释纠正**(`ChatHomeScreen` ×3、`ChatViewModel` ×2,加上点名的 `ChatScreen:161-164`):原写「key 一变 VM 立刻被销毁」是错的(key 变只销毁**组合**,VM 仍在 Activity store 里收帧)。**行为一字未动**(`onCleared` 仍空)。
> - **`ws_block_proxy.py` 挪进 `devtools/ws-block-proxy/`** + `README.md`(用途 / 用法 / **「Web 前端不经过 `adb reverse` ⇒ 代理日志每一行都只可能来自 App」**这条关键性质 / `adb reverse tcp:10942 tcp:18098` 的典型用法 / 提醒抹掉日志里的 token 查询串),并**冒烟测过**(503 BLOCKED + 200 PASS)。**一次 `clean` 再也不会把它扫掉了。**
> - ⚠️ **未做设备验收**(禁碰 adb):换账号后屏幕上是否真的不再有旧消息,只由纯函数单测钉住。
>
> ### W49(分支 `93cf3e3`,合并 `5dc6408`):修 **S6** 的客户端半边
> - 新增判据层 **`ui/sessions/InactiveSessionPull.kt`**:`silentRefreshStep` 等纯函数 + **3 秒节流窗口**。
> - `SessionsViewModel`:非激活会话落 `message_persisted` ⇒ **节流地走既有 `refresh(silent = true)`**(**与打开抽屉那次逐字相同**,含 `withReadCursors` 遮罩),让红点在**不开抽屉**时也能亮;登录态变化时**清账本 + 取消补拉任务**。
> - **3 秒的理由**(写进 KDoc):够快(3 秒还在「就是刚发生」的感知里);够粗(一次静默重拉是**两个**请求,而播出方**按每条消息**推帧,3 秒能把人手连打 5 条收进同一窗口);与 `SHARE_WAIT_NOTICE_MILLIS` 同量级。
> - **trailing 是结构性保证**:同一时刻**最多一个**补拉任务;窗口内的后续成因只更新账本并返回 `Ignore`;到点**直接拉**并把窗口重开(不回头问判据,避免自锁);边界取 `>=`。
> - **不拉的三档**:当前打开的会话(那条路「看到即已读」,多拉是白花两个请求)、同一窗口内的后续消息、窗口内且补拉已排上。
> - **它不是「造数」**:一个数都不编 —— 帧本身只说「某会话有新消息落库」,客户端据此**去问一次服务端**。同理这是「增量到位之前红点唯一能亮的办法」;**后端补上 `unreadCount` 后这条路可退成不必走**。
> - ⚠️ **一个刻意的偏差**:**`select`(切会话)不接触发** —— 它紧接着发 `POST /read`,而游标还没落地时重拉会**按旧游标算出未读、把刚清掉的角标闪回来**。判据对「切会话」够用,KDoc 写明了理由。**若将来发现「切回来后红点不对」,这里就是第一嫌疑处。**
> - ⚠️ **未做设备验收**:「红点确实在没开抽屉时自己出现」没有实机证据。
>
> ### 顺带要改的旧引用(我)
> `devdocs/05` 里那条「建议把 `ws_block_proxy.py` 挪进 `devtools/`」的建议**已执行**(`devtools/ws-block-proxy/`);文中提到旧路径(`app/build/e2e-shots/ws_block_proxy.py`)的地方**现在过时**,下次一并更正。
> ## ★★★ 锚点补记二十六(2026-10-06 深夜 · **续接锚点:从这里开始**)
>
> ### 0. 一句话现状
> 客户端 **W38~W49 已全部落地并推送**;**W50(接上主动消息消费)正在跑**;主树 **1177 测试 / 0 失败 / 0 错误**;**worktree 只剩主树**。
>
> ### 1. 仓库与环境
> - 仓库 `D:\Project\Code\Uni\Cyrene-For-Android`,分支 **`android`**。
> - **主仓只有一笔脏改动:`devdocs/02-前后端协同事项.md`** —— 那是**后端会话的在途追加**,**不是我们的**。**永远别 `git checkout --` 它、也别把它提交进来。**
> - **动 `02` 的纪律(今天写死的)**:① `git status`;② **再 `git diff` 看它最新那几十行**(「脏」≠「还是那批内容」——我今天差点因此给后端写信时漏掉他们的回执);③ 提交走「另存他们的补丁 → `git checkout --` → 只提交我这一节 → 再把他们那份贴回末尾」,两边都不丢。
> - 模拟器 AVD `Pixel_10_Pro_XL`:**1080×2340 @440dpi / 5 vCPU / 8G**(**删掉 `skin.*` 三行**才让 `hw.lcd.*` 生效);`adb reverse tcp:10942 tcp:10942`;软键盘默认禁用(布局不因 IME 位移)。
> - ⚠️ **环境坑**:屏幕会自己黑屏(`svc power stayon true` 也挡不住)⇒ 开测前 `input keyevent 224` + `82`;**`adb root` 会冲掉 `adb reverse`**;模拟器 `system_server` 会崩(`Can't find service: window`、`am` 报 `Broken pipe`)⇒ 只能重启模拟器。
>
> ### 2. ⭐ 设备驱动协议(今天用四次栽跟头换来的,**务必照做**)
> 1. `dumpsys window | grep mCurrentFocus` **确认前台是我们的 App**(我今天两次点在 `com.android.settings` 上,白跑两轮);
> 2. 打字后 **dump 并读那段文字的 `bounds`**:x 落在输入框范围、y 被软键盘顶高(约 1293~1435)⇒ **在输入框里**;y 在列表区(<2000)⇒ **已成气泡**。这就是「发送到底触发没触发」的判据;
> 3. **绝不复用发送键坐标**(软键盘把输入栏顶高约 820px)⇒ **从 dump 取 `bounds` 再点**;
> 4. 要判「客户端做了什么」⇒ **优先看客户端自己的输出**(`logcat -s CyreneRoute`、`-s OkHttp`),**不要**从代理/后端日志反推 —— **对端看不到「没发出去的请求」**,我因此误判三次。
>
> ### 3. 在飞:**W50**(`feat/W50-proactive-consume`,基线 `d5d2fee`)
> **缺口(真实产品缺口)**:她主动说的话**实时到不了安卓**。原因两条:① `proactive_message` 帧**零消费**(`ChatViewModel.onEvent` 无该分支,全仓 `app/src/main` 无消费点);② 我们**只解了错的 id**(只解 `messageId`,而落库行是另一个 `id`;`ignoreUnknownKeys` 把新 `id` 静默丢弃)。
> **任务书硬要求(合并前逐条核对)**:
> ① 按**已落库的 assistant 行**经 `mergeMessages` 并入(**不造 UI 气泡**)⇒ 实时可见 + 刷新后仍在 + **按 id 不会重复**;
> ② **必须自己钉死主会话**(帧壳**不带 `sessionId`**,`isForSession` 会放行给**每一个**会话 ⇒ 不钉就在**每个次级会话**里冒一条);
> ③ **无 `id` 时退化为本地气泡**(后端落库失败那一档),**不许静默丢弃**;
> ④ 可选项:顺带触发一次 W49 的节流静默重拉(因为后端**不为这条路补 `message_persisted`** ⇒ 主会话红点不会自己亮);
> ⑤ 红线:不动 `mergeMessages` 语义、不动 W49 节流判据、不动 W48 的 VM key、**不许**把这条帧接成「收到就整表重拉」。
> **合并后要做**:把「她主动说的话现在能实时到达安卓」写进文档 + **做一次设备验收**(用上面的协议 —— 这是**新的实时路径**)。
>
> ### 4. 今天已落地(W38~W49;细节看各自锚点)
> | 批次 | 内容 | 锚点 |
> |---|---|---|
> | W38 | 分享落地**等连接就绪**(超时不再等于发送失败、内容不丢、连上自动补发) | 补记九(+ 真机回归通过) |
> | W39 | 三处措辞不诚实(告知不再两句叠加 / 未连接与发失败分开 / `Offline` 不再承诺自动发出) | 补记十 |
> | W40 | 普通发送**未连接进队列**(+ 真机回归通过) | 补记十一/十二 |
> | W41→**W45** | 先探 `/health` **改成直接试 REST**(**探测整套已删除**)⇒ 已真机验证 `route=Rest` + 落库 | 补记十三/十六/十七 |
> | W42 | 图片入口**改多选**(N 张 = N 条消息)+ 设备验收通过 | 补记十四/二十 |
> | W43 | 超限告知**入口中性** + 重试**静默**(阈值 3) | 补记十五 |
> | W46 | 一行 **`CyreneRoute`** 诊断日志(DEBUG)+ 判据带原因 | 补记十七 |
> | W47 | 静默改为**按事故复位**(队列排空即清零) | 补记十八 |
> | W48 | **账号指纹进 VM key**(修 S1 换环境串味)+ 5 处错注释 + 代理挪进 `devtools/` | 补记二十五 |
> | W49 | 非激活会话**节流静默重拉**(修 S6 红点滞后,3 秒窗口 + 结构性 trailing) | 补记二十五 |
>
> ### 5. 待办(按优先级)
> 1. **W50 合并 + 设备验收**(在飞)
> 2. ⚠️ **`d5d2fee` 之后还没跑过全量**?—— 不,`d5d2fee` 是纯文档提交;**代码最后一次全量是合并 W48/W49 后的 1177 绿**。**W50 合并后必须再跑一次。**
> 3. **W48/W49 的设备验收**(换账号后屏幕是否干净 / 红点是否自己亮):**都只有单测**。
> 4. **老现象 ③**:**已结案**(机制正常、那次异常未复现)——见补记二十二,**别再花时间重跑**。
> 5. **S1 的第二步(回收旧 VM)与 S5(切会话不打断 / 一个能看见能停的入口)**:**都属要主人拍板的设计取舍**。
> 6. **S2/S3/S7/S9**(次级会话的窄窗口问题):清单在补记十九,按需。
> 7. **等后端**:`toolCalls[]` **回看面**(他们说**比未读更重要**,且要**带输出/摘要**)、`unreadCount` 增量(都已认领,**契约先行**)。
> 8. **等主人**:移除键位置那处**观感**、相册多选分享**真机**、**推送**(等自建)。
> 9. ✅ **文档陈旧 —— 已更正(`4c938c7`)**:`05` **历史条目**里提到的 `app/build/e2e-shots/ws_block_proxy.py`,一律指**现在**的 `devtools/ws-block-proxy/ws_block_proxy.py`(W48 已把它搬进 git,`clean` 再也扫不掉)。**历史条目不改写**,以本行为准。
>
> ### 6. 与后端的状态(不用重新问)
> - 他们 `回执二十三`:**未读增量接**(`message_persisted.data.unreadCount`,**绝对未读 + 主人单一已读游标 ⇒ 多端共享**;`GET /sessions` 列表项也带)、**工具记录认且说比未读更重要**、**我们六条依赖逐条「保持现状 + 改前必登记」**、`/health` 保留不删、**采纳了我们那条取证约定**(不从对端日志反推)。
> - 我们已答(`766843f`):P1 的真实含义是「**接上消费**」不是「去重」、`tool_execution` 选 **(a)** 并要 `toolCalls[]` **带输出**、纠正「每屏一条 WS」这个**过时前提**(W28 早已收成进程级唯一连接)、**推送暂不接**且告知「应用内提示」在我们这边**零实现**。
> - **我们没在等他们的答复**;**在等**两个已认领的面落地。
>
> ### 7. 一条方法论(今天最大的教训,写给下一个我)
> 我今天**四次**栽在自己的测量上(黑屏、前台不是本 App、复用坐标、不确认文字落点),**三次**拿「对端日志缺席」当「客户端没做」的证据。
> ⇒ **先怀疑自己的测量,再怀疑代码**;**要判客户端行为,只看客户端自己的输出**;**一次只改一个变量**。
---
## ★★ 锚点补记二十七 · W50 落地并**设备验收通过**(她主动说的话,现在实时到安卓)
> 这一段是**已验证**的状态,直接当事实用。操作细节与全部证据见 `07-W50-主动消息-验收与避坑.md`。
### 1. 结论
**`proactive_message` 帧现在有消费点了**:她主动说的话**实时**出现在主会话,刷新/重启后仍在、按 `id` 不重复;
无 `id`(后端落库失败那一档)退化成**本地气泡**、绝不静默丢弃。
- 合并提交 **`afee5f6`**(`android`,已推);W50 分支与 worktree 已删、构建锁已放。
- 全量(合并后的树,`testDebugUnitTest --rerun` **真跑**):`BUILD SUCCESSFUL` / **91 suite · 1193 tests · 0 failed · 0 error**(基线 1177 + 新增 16)。
- APK:`app/build/outputs/apk/debug/app-debug.apk`(47,850,706 B)。
### 2. 四条硬要求逐条成立(合并前核过)
| 要求 | 实现处 |
|---|---|
| 按**已落库的 assistant 行**经 `mergeMessages` 并入,不造只活在 UI 的气泡 | `ChatViewModel.onProactiveMessage` ⇒ 与 `onPersisted` **同一条** `mergeMessages` |
| **自己钉死主会话**(帧壳不带 `sessionId`,`isForSession` 会放行给每个 VM)| `ui/chat/ProactiveIngest.kt` 的纯函数 `proactiveIngestFor`:`sessionId != MAIN_SESSION_ID ⇒ Skip`(**等值**比较,`web` ≠ `web:xxx`)|
| 无 `id` 退化成本地气泡,**不许静默丢弃** | 同上 `Bubble`(本地 id `proactive-local:` + 帧里已有的稳定键 ⇒ 重投也是 upsert);只有「无 id **且**正文空白」才 Skip |
| 顺带触发 W49 的节流静默重拉 | `SessionsViewModel.onProactiveMessage`:复用 `persistedRoute` + `requestSilentRefresh`(**没有**新取数路径);另补「正看着主会话 ⇒ 看到即已读」那一档 |
红线核对:`mergeMessages` 语义未动(只被调用)、W49 节流判据未动、W48 的 VM key 未动、**没有**接成「收到就整表重拉」。
### 3. 顺带修掉一个不修就等于没做 W50 的缺陷
`ProactiveData.timestamp` 线上是 RFC3339 **字符串**(Go `time.Time`),旧声明 `Long?` ⇒ 解码抛异常被
`WsEvent.parse` 的 `runCatching`(`WsEvent.kt:654`)吞成 `null` ⇒ **整帧在成形之前就没了**(只加 `onEvent` 分支碰不到它)。
现在按字面量收(字符串/数字都吃)。详见 `07` §4。
### 4. 设备证据(假后端,四条判据全绿)
①主会话**实时**出现(`delivered=1`,5s 后 dump 到正文 + 角标「发送至 网页」);②次级会话 **0 次**;
③重启后 **1 条**、**同 id 重投仍 1 条**;④`--without-id` 档本地气泡 **1 条**、重启后 **0 条**而落库那条仍 1 条。
### 5. 这一次的环境坑(一句话版;完整版在 `07` §2)
**`input text`/keyevent 的标点会被 Gboard 转成全角**(系统 locale 是 zh-CN)——
绕法是「地址栏只填**纯字母** `localhost`」(`normalizeBaseUrl` 会补 `http://`,`ApiClientFactory.kt:109`)
+ `adb reverse tcp:80 tcp:<后端端口>`(**需 root**,且 `adb root` 会清掉所有 reverse)。
另:点太快会 **ANR 被强杀**(冷启动等 25~35s 再动 + 三个动画开关置 0);黑屏挡不住(每次交互前 `keyevent 224` + 确认前台);
`adb shell /sdcard/...` 要 `MSYS_NO_PATHCONV=1`;Python 要 `PYTHONIOENCODING=utf-8`。
### 6. 副产品:真后端那条主动路径是活的
验收前 App 还连着真后端时,主会话历史里有一条 `发送至 cyrene-backthink` 的她的话(「早安呀,亲爱的♪ …」)——
**落地行、来源角标、主会话归属三者都对**,与本次修的消费口径一致(那条来自历史拉取,不是实时帧)。
### 7. 待办变化
- 上面第 3 项「W48/W49 设备验收」**环境已就绪**(模拟器已 root、reverse 指向真后端)⇒ 现在是**可做**状态。
- W50 留了一条**窄尾巴**(只在「落库失败」档可达:降级气泡长按可删 ⇒ 会用本地 id 打服务端报错);收口做法见 `07` §5。
- 已更正「代理旧路径」那条陈旧项(见 `5520d60`)。
---
## ★★ 锚点补记二十八 · W48/W49 **设备验收也通过**(换环境串味 / 抽屉未读角标)
> 环境:模拟器 `emulator-5554` + 假后端 `python devtools/fake-backend/fake_backend.py --port 18090`
> (App 地址栏填 `localhost`,配 `adb reverse tcp:80 tcp:18090`)。操作细节与全部坑见 `07-W50-主动消息-验收与避坑.md`。
### 1. W48(账号指纹进 VM key,修 S1)——**已验**
判别法是关键:**先把服务端主会话清空**(`POST /__control/clear?session=web`)再换账号,否则两个账号拿到同一份历史、看不出串味。
- 账号 A(`e2ea`)的主会话里造两条:一条**只活在 A 的 VM 内存里的本地气泡**(`--without-id` 注入)+ 一条持久行;
- 换到账号 B(`e2eb`;同 baseUrl、不同用户名 ⇒ 指纹不同);
- 结果:**「A账号的消息」0 次、「A账号的持久消息」0 次**,聊天页「还没有消息」,抽屉里 A 的残留 **0 次**
⇒ **VM 没被复用、快照也没串味**。反证很硬:那条本地气泡服务端**根本不存在**,它若还在屏幕上就只能是旧 VM。
- 假后端流水证明这是全新一轮(`08:22:35` 起一整串 `login / enums / sessions / messages`)。
### 2. W49(非激活会话落新消息 ⇒ 节流静默重拉,修 S6)——**已验**
- 场景:App 停在**主会话**、抽屉**关着**;往次级会话 `web:5eed…0001` 灌一条(`POST /__control/reply`)。
- **机制证据(客户端自己发的请求)**:`08:20:03.018` 服务端广播 `message_persisted` ⇒ **`08:20:03.079` 客户端自己发出 `GET /api/v1/sessions`**(60ms 后,抽屉全程关着)——这正是 W49 那条节流静默重拉。
- **可见结果**:打开抽屉,次级会话行上是 `未读 1`(`[232,573][276,617]`),而**正在看的主会话没有角标**。
- ⚠️ **更正一处认知**:这里的「红点」是**抽屉里会话行上的未读角标**(`SessionDrawerContent.kt:403`);
菜单图标本身**没有点**(`ChatScreen.kt:468` 是纯 `Icon`)——以后别再对着菜单图标找点。
### 3. 本次新踩的坑(已补进 `07` §2 第 9、10 条)
1. **登录页地址栏留空 ⇒ App 回落到上一次记住的地址**(我为此白追一小时「请求怎么打到真后端去了」);
2. **「登出」是设置页最底部的图标按钮**(`ui/settings/SettingsScreen.kt:160`,图标是 `MiuixIcons.Back`、
只有 `content-desc="登出"`)——截图上看着像「返回」,很难找。**这是观感问题,等主人拍板**要不要换成带文字的危险行条目。
### 4. 待办变化
- ✅「W48/W49 设备验收」这条**结清**(原来标注「都只有单测」)。
- 还在等/待决策的:**等后端** `toolCalls[]` 回看面(要带输出)+ `unreadCount` 增量;
**待主人**:登出条目观感、移除键位置观感、相册多选分享真机、推送(等自建);
**待主人拍板**:S1 第二步(回收旧 VM)、S5(切会话打断/停止入口)、W50 那条窄尾巴(降级气泡按 `proactive-local:` 前缀屏蔽删除入口)。
---
## ★★ 锚点补记二十九 · W51(S2 + S7 次级会话补拉安全网)合并、**S2 设备验收通过**
> 合并提交 **`620a4e7`**(`android` 已推);分支/worktree `Cyrene-W51` 已清。全量真跑:**93 suite · 1205 tests · 0 失败 · 0 error**(基线 1193,+12)。
### 1. 做了什么(每条都写清「为什么」在代码 KDoc 与提交信息里)
- **S2**:新增纯判据 `ui/chat/ReconnectPull.kt`(`Forward / FirstPage / Nothing`,四级,只看 会话 id + 首屏状态 + 掉线游标 + 已知它没了);`ChatViewModel` 把首屏 `loadLatest` 提成 `loadFirstPage()`,**init 与补拉共用一份**,并新增 `FirstPageState`。判据刻意用「**首屏从没成功过**」而不是「本端有没有数据」——服务端真的没消息时列表也是空的,拿空列表当判据 = 每个空次级会话每次重连整表重拉(S4/S7 的成因)。`Loading` 不算失败(S3 那条时序一个字节没动);**主会话排除在外**(它有 `history` 兜底,再补一条 REST 就是第二套口径)。
- **S7①**:新增 `core/session/GoneSessions.kt`(进程级、不持久化、登出清空)+ `AppContainer.goneSessions`;写入口是 `SessionsViewModel.deleteSession` 成功那一刻,读入口是每个 `ChatViewModel`。判据第 1 条拦掉注定 404 的补拉:本地删过的**零次**,已 404 过的从「每次重连撞一次」收敛成**恰好一次**。
- **S7②**:⚠️ **任务书的前提是错的** —— 消费者一直都在(见补记十九那行的更正)。真正缺的是「出路不能依赖网络」:原来只靠 `refresh` **成功**后的回落 ⇒ 网络一抖,「已切回主会话」就是一句做不到的话。现在 `ChatHomeScreen.onSessionGone` **先在本端** `select(主会话)`(与用户点抽屉那一行同一条路),`refresh` 退回本职。
### 2. 设备验收(`emulator-5554` + 假后端 18090,App 地址 `localhost`、`adb reverse tcp:80`)
- **S2 通过**:先在线进次级会话(判据:客户端发 `POST …/read`)→ 撤 reverse 冷启动(离线,屏幕 `还没有消息` + `无法连接到服务器` + `重连中…`,注入的那条服务端消息在屏幕上 **0 次**)→ 装回 reverse → **20 秒处客户端自己发出 `GET /ws/chat` 紧跟 `GET /api/v1/sessions/web:5eed…/messages`**(就是那条补拉),**屏幕上的消息自己出现(1 次)**,全程没碰一下。
- **S7① 通过(否定式证据)**:在抽屉里删掉那个次级会话(服务端列表随即只剩 `web`)→ 用假后端 `POST /__control/ws-close` **真踢**一次 WS(握手日志显示 App 在 `09:20:01` 重连回来)→ 重连窗口里**没有任何该已删会话的 `…/messages` 请求**(`web:5eed` 计数 0),而**握手本身证明同一窗口通路是活的**(可信的否定)。反事实(不加闸就会撞一次)由补记十九的登记证据 + `ReconnectPullTest` 的单元测试承担。
- **S3(冷启动闪「还没有消息」)**:**未验**。W51 刻意没动那条时序;dump 采样的粒度抓不到亚秒级闪烁,我没有证据,不写成「没问题」。
- **S7②(在别处被删 ⇒ 被带回主会话且不依赖网络)**:**未验**(要造「别处删除」的场景)。代码路径与副作用(给主会话发一次 `POST …/read`,与用户手点主会话逐字相同)已在提交信息里写明,等主人复看。
### 3. 这次我又一次踩在自己的测量上(第二次同类,写下来)
S2 我第一次测出「没生效」,差点去改代码——真因是**设备上跑的还是上一版 APK**(换图标那版,`lastUpdateTime` 00:29,而含 W51 的是 09:04)。**规矩:凡是「真机上没生效」,第一件事先核 `dumpsys package … | grep lastUpdateTime` 与本地 APK 的时间**,再怀疑代码。(同类教训还有:`ws-close` 那次我以为「48 秒没重连」,其实是假后端踢掉连接本身就延迟了 19 秒,实际重连发生在 09:20:01。)
另:为它白跑了好几轮还有一个原因——**撤 `adb reverse` 不会掐断已建立的连接**,要真抖 WS 就用假后端的 `POST /__control/ws-close`。
### 4. 待办变化
- ✅ S2 结清(已实机验);S7① 结清(否定式实测 + 单测);**S7② 与 S3 未验**(见上)。
- 「REST 首屏失败、但 WS 从头到尾没断」这一格**仍是已知缺口**(没有任何状态变化可挂,项目纪律不许按时间轮)。
- S4/S5 仍需主人拍板;等后端两个面不变。
### 5. S7② 这次为什么没验成(下次照这个来)
- **要造的场景**:App **停在那**个次级会话 → 服务端把它摘掉(假后端 `POST /__control/forget?session=<id>`,它的 `/messages` 随即 404)→ 真踢一次 WS(`POST /__control/ws-close`)逼出那条注定 404 的补拉/补洞 → 期望:如实告知 + **被带回主会话**。
- **判据(两条)**:① 客户端发出那条请求并拿到 404 ⇒ `sessionGone` 置位;② 界面上出现那句告知,且 `POST /api/v1/sessions/web/read`(`select(主会话)` 的副作用)**在 `GET /sessions` 之前**出现 —— 后者才是 W51 那句「出路不依赖网络」的证据。
- **我卡在哪**:登录表单被前几轮实验搞乱了(地址栏里还留着 W48 那次的坏值 `http://loclocalhostalhost/`,用户名/密码/展开态彼此错位),`input text` 落不进目标字段;且 `uiautomator dump` 会**返回陈旧缓存**(同一次交互前后两张 dump 的地址栏展开态互相矛盾)。这个会话里我为此烧了好几轮,按「别把预算烧在仪表上」停手。
- **下次的干净起手**:`adb shell pm clear top.yeij.cyrene` → 登录页只填**纯字母**的 `localhost`(`normalizeBaseUrl` 会补 `http://`,见 `07` §2 第 1 条)+ 任意 ASCII 账号密码 → 每填一个字段**都 dump 一次确认值真的进去了**(别信「刚才填过」),再点登录。
### 6. S7②/S3 第二轮再次受阻(2026-10-06 下午,写给下一个我)
- **模拟器跑了约 6 小时后劣化**:`screencap` 返 **0 字节**、`uiautomator dump` **大面积失败或返陈旧缓存**(同一次交互前后两张 dump 的地址栏展开态互相矛盾)。已**重启模拟器**(run10;`boot=1` 约 170 秒;重启后 `adb root` 与两条 `adb reverse` 都要重新加)。
- **重启后仍不稳**:设备侧 `system_server` 111% CPU、sensors HAL 73%、Google 全家桶在抢;而**主机上当时也有负载**(`Get-Process` 里能看到 `cs2`、`msedgewebview2` 等)⇒ **大概率是主机被占,不是 App 的问题**。**以后遇到「adb 很慢 / 截图 0 字节 / dump 失败」先看主机在跑什么**,别先怀疑代码。
- **这一轮走到哪**:已**成功登录**(假后端收到 `POST /auth/login` + WS + 一串后续)、会话也已重建;但**进次级会话那一步没成**(dump 拿不到抽屉行坐标,盲点 `(146,595)` 也没落到行上)⇒ **S7② 仍未验**;S3 同样未动。
- **一条不依赖 dump 的验证套路(下次直接用)**:**盲点 + 用假后端的请求流水判结果** —— 进次级会话的判据是 `POST /api/v1/sessions/<id>/read` 出现在流水里;进没进一看便知,比 dump 可靠得多(这也是 W49/W51 那两轮用过的路子)。
- 收工时状态:`adb reverse` 已指回真后端(`tcp:80`/`tcp:10942` → 10942);假后端已停;App 停在聊天页(拿的是假后端 token,会在真后端 401 上登出)。
### 7. S7② 与 S3 的真机验收(2026-10-06 下午 · 补齐,**两条都结清**)
**S7② —— 通过**
- 造场景:App 停在次级会话(**进门判据 = `POST /api/v1/sessions/<id>/read`**,比 dump 可靠)→ 灌一条让它本端有内容(否则断线游标为空、逼不出补洞)→ 服务端 `POST /__control/forget` → **`POST /__control/ws-close` 真踢**(⚠️ 撤 `adb reverse` **不算**踢:已建立的连接不会断)。
- **判据①(那条请求 404 ⇒ `sessionGone`)**:`14:22:28.957 GET /api/v1/sessions/…0002/messages?after=1791267700478` —— 补洞请求打在一个已被摘掉的会话上 ⇒ 服务端 404 `session_not_found`(该响应本轮单独核过)。
- **判据②的界面一半(告知)**:**抓到了** —— `[33,378][1047,480] | 这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话`。⚠️ 它只显示 **8 秒**(`SESSION_GONE_NOTICE_MILLIS = 8_000`):前两次我都是事件后 ~50 秒才 dump,白跑两轮;**要抓它必须卡在 8 秒窗口内**(用「流水里出现补洞请求」当触发器,立即 dump)。
- **判据②的「不依赖网络」一半**:`select(主会话)` 的副作用 `POST /api/v1/sessions/web/read` 两轮都观测到(`14:18:56.464` / `14:22:29.404`)。
⚠️ **但要修正我自己原来写的判据**:我要求「`read` 出现在 `GET /sessions` **之前**」——实测**顺序不确定**(第一轮 `read` 在前 `.464`→`.480`;第三轮在后 `.040`→`.404`,两者是各起一条协程)。**那条顺序判据不成立,别再拿它当判据**;真正该验的是「**refresh 失败时也能回去**」那一档,本轮**没做**。
- 顺带又验了 W51 判据第 1 条:同一个 VM 里 `sessionGone` 一旦为 true,**再重连也不再补拉**(第三轮 kick 后没有任何 `…0002/messages`)——「已知它没了就别再撞」生效。
**S3 —— 否证(这个场景下没有空态闪烁)**
- 手段(**不改生产代码**):`adb shell screenrecord --time-limit 20 --bit-rate 6M /sdcard/s3.mp4` + 冷启动 → `ffmpeg -vf fps=8,scale=540:1170 -f rawvideo -pix_fmt rgb24` → 用一段 Python 逐帧量两块区域的**深色墨迹**(阈值 <120):中央空态框(设备 x420-660 / y1180-1260)与第一条气泡框(x78-600 / y380-530)。
- 时间线(8 fps,覆盖 0–11.4 s;抽帧确认过每一段的真身):
| 时间 | 中央那块 | 气泡那块 | 抽帧确认是什么 |
|---|---|---|---|
| 0.1–4.6 s | 窄墨迹 `x540-588` | 0 | **启动画面**(就是**新版 App 图标**,顺带证明图标换上了) |
| 4.9–7.4 s | 宽墨迹 `x460-620` | 0 | **「加载中…」**(**不是**「还没有消息」) |
| 7.6 s 起 | 0 | 352 | 内容出现 |
- **结论**:在线冷启动进「有内容的次级会话」时,屏幕是 **启动画面 → 「加载中…」 → 内容**,**没有出现空态** ⇒ **S3 在这个场景下否证**。
- 口径与局限(别读成「S3 不存在」):8 fps 采样(**<125 ms 的闪烁会漏**)、只测了这一个场景、只测了这一个 build。
- 方法论一条:**别用「墨迹的 x 宽度」去猜那是哪一句话**——我一开始按宽度推断 4.9–7.4 s 是空态,抽帧一看是「加载中…」。**量到异常就抽那一帧看**,这才是收口。
### 8. ★ 纪律(主人 2026-10-06 当场纠正,写死)
- **不准用 `WaitFor` 等子代理**(哪怕动机是「本回合内把它收回来」):它会把整个回合锁住,**主人在这期间无法插话**——主人当场打断了我的 `WaitFor` 并明确说「纪律是不准用 WaitFor」。
- **正确做法**:**派完就结束回合**(子代理完成会自动唤醒我);等待期间**只做与它们不冲突的并行工作**,或者干脆停手。多支并行时,谁先回来就先收谁。
- 备注:这条在后端仓库 `Cyrene_v2/AGENTS.md §10` 里原本就有(「等待子代理:不许用长 WaitFor 阻塞回合」),**本项目文档此前没写、我因此重犯了一次**,现补上。
---
## ★★ 锚点补记三十 · W52 五项落地(主人拍板的那五条)——**已合并推送;真机验收部分通过、一项未通过**
### 1. 代码(五支全部合并 + 真跑 + 已推)
合并到 **`579eccd`**(五次 `--no-ff`,其中 W52A 与 W52B 在 `deleteSession` 的同一锚点冲突一次,两侧都留)。主树全量 `--rerun` **真跑**:**101 suite / 1270 tests / 0 失败 / 0 error**(基线 1205 ⇒ +65,全为新增)。五支的 worktree/分支已清、构建锁已放。
- **W52A(S4)**:`core/session/ChatSessionSlots.kt` + `ChatSessionEviction.kt` —— **一格会话一份 `ViewModelStore`**(这样「交出一格」就是那份 store 自己的 `clear()` ⇒ 那一格的 `onCleared` + `viewModelScope` 取消,订阅/补拉/在飞请求一起停);「最近」口径 = **最后一次成为当前会话**;判据四条:**主会话永不回收** / **屏幕上那一格永不回收**(它一死那一段就不再更新)/ **有草稿或未上路发送的格子永不回收**(回收会抹掉用户唯一一份内容)/ 其余按最旧先走、`keep=3`,**被豁免的那些占名额**。删会话定点回收、登出全清。槽位挂在 Application 级容器上 ⇒ 存活期改由「≤3 格 + 回收/登出」界定(行为变化,已记在 KDoc)。
- **W52B(S5+S9)**:`ui/sessions/SessionBusy.kt` 纯账本(**只认活帧、绝不外推**;`session_idle` 无条件收、`session_interrupted` 仅在 `activeTurns=0` 时收、`turn_end` 只收认得的那一路、`stream_end` 刻意不参与;**WS 掉线当场作废账本**)+ 抽屉行内第三行「正在思考…」+ **「停止」**(用**既有** `WsOutbound.Interrupt`,恒发 `scope=current`,从不发 `all`)。⚠️ 「停止」**顶掉**了该行的置顶按钮(宽度硬上限),灯灭后置顶回来 —— 主人如需并列请说。
- **W52C(登出条目)**:`BasicComponent(title="退出登录", error 色, startAction=Icon(MiuixIcons.Remove))` —— 图标是穷举 Miuix 图标集后选的(`Basic` 只有 7 个、`extended` 156 个里没有 exit/logout 字形)。
- **W52DE**:`ui/chat/MessageServerActions.kt` 纯判据(`proactive-local:` 前缀 ⇒ 不做服务端动作,选字/复制不受影响)+ 空态拆两种真相(「还没有消息」/「消息没能加载出来」)+ `retryFirstPage()`(**一行**转调 `loadFirstPage()`)。
- **W52K(保活)**:`core/keepalive/{KeepAliveService,KeepAlivePolicy}.kt` + 设置页 `KeepAliveRow.kt` —— **默认关**、低优先级静默常驻通知、`dataSync` 类型、`onTimeout` 自停(不自己停会 ANR);判据 `keepAliveAction(enabled, sessionKnown, loggedIn, notificationsAllowed) → Start/Stop/Leave`(`Leave` 是「登录态未定,此刻不该有主张」)。
### 2. 真机验收(`emulator-5554` + 假后端)
| 项 | 结果 | 证据 |
|---|---|---|
| 设置页两行(W52C/W52K) | ✅ | dump:`后台保持连接`(带**如实**代价文案)+ `退出登录`(带文字);截图确认开关渲染正常 |
| 抽屉「谁在跑」(S9) | ✅ | `POST /__control/busy?session=web` ⇒ 抽屉里 与昔涟 行出现 `正在思考…` |
| 「停止」入口(S5) | ✅ | 点它,客户端发出 `{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}`(**既有帧**;`out/upstream.jsonl` 为证) |
| 降级气泡不给删改入口(D) | ✅(弱对照) | 注入 `--without-id` 气泡后屏幕上该气泡**没有**「消息操作」入口;但同屏没有服务端行可对照(假后端主会话为空)⇒ 这条主要由那 6 条单测承担 |
| **首屏失败「重试」(E)** | ❌ **未通过** | 断网冷启动后:同屏有错误横幅「无法连接到服务器」+「重连中…」,但中央是「**还没有消息**」且**没有重试按钮**(dump + 截图双证)。⇒ 判据里的 `hasError` 在实际那一刻为假,而那条横幅确实来自 `state.error?.let` ⇒ **我的状态模型与现场不一致,根因未定位**。下一步:在 `ChatScreen` 那一支加一条一次性诊断日志(打出 `loading / hasError / items.size`)再定;另一种可能是判据该直接问「首屏有没有失败过」而不是 `error` 这个代理(W52DE 自己也登记过这个洞:横幅上的 X 会清掉 `error`,按钮跟着消失)。 |
| 保活开关 → 前台服务 | **未验** | 只验到「开关渲染正确」;服务是否真起、通知观感、13+ 权限流都没验 |
| S4 回收 | **未验** | 只有单测 + 走查(VM 生命周期在 JVM 上起不来) |
### 3. 这一轮踩到 / 学到的
1. **`mCurrentFocus` 之外还要防「别的 App 抢前台」**:Google Play 服务的账号核验页(`gms PreAddAccountActivity`)在我们操作期间抢了前台,我的 dump/截图**全打在它身上**,白跑好几轮。⇒ 判据第一步永远是 `dumpsys window | grep mCurrentFocus`;**看到不是本 App 就先清掉它**(`keyevent 4` × N + `keyevent 3`,再 `am start` 拉回来)。
2. **`INTERNET` 权限的更正**(我先前说「release 可能没网络权限、得单独验」):W52K 跑了 `:app:processReleaseMainManifest` —— release 合并清单里**有** `INTERNET`,来自传递依赖 **`com.squareup.okhttp3:okhttp-android:5.5.0`**(不是 AGP 只给 debug 注入)。**结论:release 现在能联网,但那是搭在传递依赖上的** —— 换掉/排除 okhttp-android 会静默失去网络权限。建议无条件显式写进源码清单(一行,**还没做**)。
3. **构建锁协议有洞**:这批有切片是**整文件删除**而不是只删自己那行 ⇒ 别人的占用行一起没了(W52B/W52K 都提到过)。下次按「一行一个占用者、只删自己那行、收工前先读一遍」写死。
### 4. 复验补记:W52/E 的「重试」已修(`0054a3a`,**真机端到端通过**)
- **根因不是「我的状态模型不一致」,是接线极性**:判据 `shouldOfferRetryOnEmpty(loading, hasError, hasItems)` 的本体是 `!loading && hasError && !hasItems`,参数语义是「列表里**有没有**东西」;而**调用点传的是 `state.items.isEmpty()`**(极性相反)⇒ 在「列表为空」这个**唯一会求值**的场景里结论永远为 `false`、按钮永远不出现。
- **怎么抓到的**:在 `ChatScreen` 那一支加一条**一次性诊断日志**,把 UI 实际看到的三个输入打出来 —— `loading=false hasError=true errorIsQueueFull=false items=0 retry=false`:**三个输入全真而结论为假** ⇒ 只剩「极性」一种可能。核对判据本体后确认。定位完日志已删(`grep CyreneRetry` = 0)。
- **修 + 复验**:调用点改成 `state.items.isNotEmpty()`。真机复验:断网冷启动 ⇒「消息没能加载出来」+「重试」;**恢复网络后点它** ⇒ 发出 `GET /api/v1/sessions/web/messages`,**离线期间落库的那条出现在屏幕上**;列表非空后按钮自己消失。(顺带一条自查:我第一遍用 `grep 重试` 数按钮,结果那条消息的正文里正好也有「重试」二字 ⇒ 计数 1 是误报;按 dump 的节点看,按钮确实没了。)
- **教训(值得单独记)**:**判据单测全绿 ≠ 接线正确**。极性 / 参数语义这类错在纯函数测试里永远看不见(测试调的是正确语义),**只有调用点会露** —— 而调用点在 Compose 里、项目又没有 UI 测试装置 ⇒ **这条只能靠真机验收**。这是「设备验收抓住单测抓不到的错」的第二例(第一例是 W50 的 `timestamp` 类型不符)。
### 5. 待办变化(S4 / S5 / S9 移出)
- ✅ **S4**(常驻 VM 不回收)→ **W52A**:LRU 保留 3 格 + 四条豁免(主会话 / 屏幕上那一格 / 有未发出内容的格 / 其余最旧先走)。**真机未验**(只有单测 + 走查),已在 §2 表里如实标注。
- ✅ **S5**(切会话不打断)与 **S9**(「正在生成」看不见)→ **W52B**:抽屉里看得见「正在思考…」+ 能停(走**既有** `interrupt` 帧)—— **真机验收通过**。
- ✅ W52 其余三项:登出条目(W52C)、降级气泡不给删改入口(W52DE/D)、首屏失败显式重试(W52DE/E,修完复验)—— **真机验收通过**。
- 仍在待办:**保活开关 → 前台服务/通知/13+ 权限流** 未验;**`INTERNET` 显式声明**未做;**构建锁协议**未写死;主人待拍板的「保活第 3 件(后台收到消息弹系统通知 + 接上被丢掉的 `proactive` 判据)」。
### 6. 两件小收尾(主人「不反对」后做的;同批的第 3 件事见锚点三十一)
- **`INTERNET` 显式声明**(`app/src/main/AndroidManifest.xml`):原来 release 能联网是**搭在 `okhttp-android:5.5.0` 的清单合并**上的;现在**显式写进源码**,并在注释里写清「为什么这里必须显式」(换掉那个依赖就会静默断网)。验收(按判据核过):`:app:processReleaseMainManifest` 的 merger 报告里它已是 `ADDED from app/src/main/AndroidManifest.xml:26` 且 `MERGED from [okhttp-android]` ⇒ **来源是源码**。
- **构建锁协议写死**(这批真踩到:有切片**整文件删除**,把别人的占用行一起删了)。三条约定:① **一行一个占用者**(`<分支名> <PID> <时间>`);② **只删自己那一行,绝不整文件删除**;③ **改/删之前先读一遍**当前有谁在占。
### 7. 新登记:分享进来的内容**不再直接发出**,改成确认面板(主人 2026-10-06 提)
**现状(已核实)**:分享一进来就在 `ui/chat/ChatScreen.kt` 的分享落地那一支被**立刻发出** ——
`ShareLanding.Send -> { ShareIntake.take()?.let { view: sendShared(share, shareNoticeFor(share)) } }`。
**主人要的**:像其他 IM 一样给一个**确认面板** —— ① **能预览**支持的文件(图片缩略图 / 文件名字与大小);② 下方一个**输入框**让用户附上文字描述;③ 由用户点「发送」才真的发出去(可取消)。
**指派**:`W53X`(与 W53N/W53S 并行;它只许在 `ChatScreen.kt` 的分享落地那一小段动刀,其余放新文件)。
**边界**:不动 `shareLanding` / `shareNoticeFor` / `shareWaitingNotice` 的语义,不动 `sendShared` 的「超长文本走文件」那条(主人当时的裁决),不发则不 take 之前的内容——**take 只许发生一次**(否则 effect 重跑会重复弹面板)。
---
## ★★ 锚点补记三十一 · W53 落地(主人拍板那批 + 分享确认面板)——**均已合并推送,真机验收全过**
### 1. 代码(`53c7494` / `6505512` / `62f552f` / `77fbedc`,均已推)
主树全量 `--rerun` **真跑**:**110 suite / 1330 tests / 0 失败 / 0 error**(基线 1270 ⇒ +60)。三支的 worktree/分支已清、锁已放。
- **W53N(后台通知,只做「她主动说的话」这一档)**:**读回**被丢掉的 `proactive` 判据(**核过后端**:它在帧的 **`data`** 里而不是帧壳顶层 —— `broadcast.go:44-49`;客户端两层都读,两种说法都吃得住);纯判据 `shouldNotifyProactive(inForeground, viewingTargetSession)` =「这条此刻会不会正好被看到」;去重账本(进程级、容量 64、键 = 落库行 id > IM 消息号 > 时间字面量 > 正文);点通知 ⇒ 打开 App 并切主会话;**渠道 `proactive`(DEFAULT)、通知 id=2**,与保活的 `keep_alive`(id=1) 分开。
- **W53S(设置页两件)**:退出登录加 **Miuix `OverlayDialog`** 确认框(⚠️ **更正我任务书里的错**:Miuix 0.9.3 **没有** `extra.*` 包,对话框只有 `overlay.*` / `window.*` 两族);保活那行补「**24 小时内累计最多 6 小时**」+ 权限被拒/被撤时给「去系统设置」(`ACTION_APP_NOTIFICATION_SETTINGS` + 兜底 `ACTION_APPLICATION_DETAILS_SETTINGS`)。
- **W53X(分享确认面板)**:分享进来**不再直接发**,弹面板(图片缩略图 / 文件 chip(复用 `DocumentChipRow`)/ 可附文字的输入框(预填分享自带的文本)/ 发送·取消);用 **`window.WindowDialog`**(⚠️ 硬理由已写进 KDoc:`OverlayDialog` 需要 Miuix `Scaffold` 提供的 composition local,而它的调用点在 `ChatScreen` 的 Scaffold **外面** ⇒ 会「不报错也不显示」);`take()` 只发生一次(effect 的键里加了 `shareConfirm == null`,面板关掉后自动接上下一条)。
- **我自己补的一处**(`77fbedc`):W53S 标出的「去系统设置授权后回来,UI 说已开而服务其实没起」——给 `AppPresence` 补一条与 `appInForeground` **同源**的 StateFlow,让 `CyreneApp` 那个起停 effect 收它当 key(「回到前台」正是从系统设置返回必经、且必然变化的那一位)。**没加任何依赖**。
### 2. 真机验收(`emulator-5554` + 假后端)
| 项 | 证据 |
|---|---|
| **后台通知** | App 切后台 ⇒ 注入 `proactive_message` ⇒ `NotificationRecord(pkg=top.yeij.cyrene, id=2, channel=proactive, category=msg, AUTO_CANCEL)`;通知栏显示「Cyrene / 昔涟 / 通知验收:她主动说了一句」 |
| **点通知** | 点它 ⇒ 回前台,且**聊天页正是主会话、那条主动消息就在屏幕上**(带「发送至 网页」) |
| **退出确认框** | 点条目 ⇒ `退出登录?` +「退出后本机会清空登录信息(服务器地址也要重填),需要重新登录」+ 取消/退出登录;取消后仍在设置页、**没登出** |
| **保活文案与入口** | 开着时含「24 小时内累计最多 6 小时,用满会被停下」;**撤掉权限后**文案变「通知权限现在是关的,服务起不来…」+ 出现**「去系统设置」**按钮 |
| **保活服务**(上批欠的) | 开 ⇒ `isForeground=true`、`types=dataSync`、`channel=keep_alive`、静音常驻;关 ⇒ 服务停;13+ 权限弹窗真实 |
| **分享确认面板** | 外部 `ACTION_SEND` 分享一段文字 ⇒ **对端上行流水里没有任何 chat 帧**(确实没被直接发出),而是弹出面板(标题「发送给昔涟」+「分享的文字」预览 + 预填的输入框 + 取消/发送) |
| **`INTERNET`** | `:app:processReleaseMainManifest` 的 merger 报告:`ADDED from app/src/main/AndroidManifest.xml` + `MERGED from [okhttp-android]` ⇒ **来源是源码** |
### 3. 仍未验 / 已知缺口(如实)
- **「两条路只弹一次」只有单测**:真后端要走 IM 桥那条路由才会同时来两帧,本机造不出来。
- **通知频率**:恒用 id=2 ⇒ 她连说几句时**后到的覆盖先到的**(KDoc 已写明;要「一事一条」得按 id 生成通知 id)。
- **通知挂在 `SessionsViewModel`(Activity 存活期内)**:Activity 被销毁而进程被前台服务保活那一档(例如从最近任务划掉)收不到帧 ⇒ 不弹(要覆盖得把订阅提到进程级)。
- **转屏会静默丢掉「已取走」的那份分享**(面板状态是 `remember(sessionId)`,`take()` 不可逆)——W53X 列了两条更糟的修法,我没动;要收口得把状态挪进 VM。
- **面板里正文出现两次**(预览 3 行 + 输入框预填):W53X 问要不要删掉预览那块,**我的建议是删**(输入框已经显示且可编辑),一处小改即可 —— **等主人一句话**。
- **S4 回收**:正向证据有了(超 3 格后回到最早的次级 ⇒ 重新拉一次 `messages`);**对照组没跑干净**(我几次点击没落到达标的抽屉行上),如实标注。
- 给后端的一条(不影响我们):他们的注释写 `proactive` 是「帧顶」,代码实际放在 `data` 里(`broadcast.go:49`)——**我们两层都读,所以都能用**,但他们的注释值得对一下。
---
## ★★ 锚点补记三十二 · 续接锚点(2026-10-06 深夜 · 主人两条新指令 + 环境状态)
### 1. 主人刚下的两条(接手第一件事)
1. **复验「分享面板正文出现两次」这个说法** —— 主人怀疑它不存在(原话「你确定真的在吗 好好看一看」)。**不许只信 dump 与子代理转述**:模拟器还在,用 adb 自己看,或派子代理去看。两条判据:① dump 里那段文字出现几次;②**截图上肉眼是不是真看着重复**。若确认重复,主人此前授意「按你推荐的来」,我建议删掉预览里那块(输入框已显示且可编辑)。
2. **「保活长时间后台」验证可以开跑** —— 开着「后台保持连接」把 App 扔后台,过一段时间回来看:进程还在不在、连接断没断、**后台还能不能收到她主动说的话并弹通知**。
### 2. 此刻的环境状态(动手前先核一遍)
- 假后端:`python devtools/fake-backend/fake_backend.py --port 18090`(后台任务,会到点被系统收);`adb reverse tcp:80 tcp:18090`。
- 真后端 `10942` 活着(**没碰**);模拟器 `emulator-5554` 在跑。
- App 登录:假后端不校验凭据,地址栏记着 `localhost`;**若已被真后端的 401 登出**,就在登录页填 `e2eb` / `e2e1234`(纯 ASCII 好打)。
### 3. 两条各自的跑法(照抄即可)
**分享面板复验**
```bash
adb shell am start -a android.intent.action.SEND -t text/plain \
--es android.intent.extra.TEXT "分享面板复验:这句话该出现几次" \
-n top.yeij.cyrene/.app.MainActivity
# 然后 dump 数出现次数 + **截图看肉眼观感**(截图是主判据)
```
(上一轮 dump 读到的是:`分享的文字` 标签 + 一段正文(预览)+ 输入框里同样那段 ⇒ 两次;但主人怀疑,**以自己看/截图为**。)
**保活长时间后台**
```bash
# 1) 设置页打开「后台保持连接」(权限不在就先 adb shell pm grant top.yeij.cyrene android.permission.POST_NOTIFICATIONS)
# 2) 记基线:date;pid;服务;对端连接
adb shell pidof top.yeij.cyrene
adb shell dumpsys activity services top.yeij.cyrene | grep -E "KeepAliveService|isForeground"
# 3) 扔后台(input keyevent 3)后放着
# 4) 至少 1~2 小时后回查同样三项 + 注入一条 proactive_message 看后台还弹不弹通知
```
⚠️ 期间**别**让别的操作把 App 长时间拉到前台(那就不是「后台存活」了);每次检查都记时间点。
### 4. 本轮进度(写这条时的实况)
铺完环境(假后端 18090 + `tcp:80 → 18090` + 拉起 App);**分享面板复验与保活长跑都还没开始** —— 接上后按 §3 逐条做,结果写进本锚点或下一个锚点。
### 5. 两件事的后续(同日深夜补记)
1. **「正文出现两次」的真相 —— 根因不是「预览多余」,而是「输入框被预填了」**:dump 与截图都确认同一句话排了两遍(预览的「分享的文字」块 + 那个输入框),**但主人 2026-10-06 深夜澄清了语义**:那个输入框应当是「**用户附加的描述**」输入框,**不是分享文本内容的复刻**。
⇒ **正确形态**:**预览留着那段分享正文**(它才是这次要发出去的内容);**输入框默认留空** + 占位符(例如「加一句描述…」);发送时把「**分享自带正文**(若有)+ **用户描述**(若有)」拼起来(两段都在时用**一个空行**分隔,只有一段就只用那一段),拼接规则要**穷举**钉住;「都空且无文件」这一档**按钮不可点**(与 `sendShared` 开头那条早退同一口径)。
⚠️ **我先前那条「删掉预览里的正文」的指令方向反了,已当场更正**(W53X2 完成即按更正执行;若它已删,要改回来)。
2. **保活长跑已开跑**:基线 **20:05:14**、App pid **24883**、`KeepAliveService` 在跑、假后端侧 **1** 条 WS 连接、App 已切后台(前台是桌面)。挂了一个**后台任务**:`sleep 7200` 后自动复查四项 —— ① 进程还在吗(PID 应与基线一致)② 前台服务还在吗 ③ 对端 WS 连接还在吗 ④ **注入一条 `proactive_message`,看后台还弹不弹通知**(这是「保活到底有没有用」的真正判据)。
### 6. 分享面板与保活长跑的结果(同日更晚补记)
1. **分享面板语义已改对并上机验过**(W53X3 `514901c` → merge **`30c5345`**,主树 **110 suite / 1332 tests** 真跑绿):
- **预览留着**分享正文(`分享的文字` + 那段);**输入框默认空** + 占位符「**加一句描述…**」(截图确认);
- **发送 = 分享正文 + 空行 + 描述**:实测上行帧原文 `"text":"要分享的正文:看这个\n\nMyCaption123。"`,落库同一条 —— **拼接规则端到端验过**;
- 「都空且无文件」⇒ 按钮不可点(与 `sendShared` 开头那条早退同源);那个「名字说 A、行为做 B」的 `shareConfirmDraft` **已删**。
- ⚠️ **一条待主人定的观感取舍**:预览正文仍只给 3 行(`SHARE_PREVIEW_TEXT_LINES = 3`),而输入框不再承载全文 ⇒ **发送前读不到整段正文**。两条路:让预览那块可滚动/展开,或接受只能看开头。
- 附一条**测量教训(我的锅)**:`adb shell am start … --es android.intent.extra.TEXT "带空格的值"` 里的值会被**空格拆开**(我那次 URL 就是这样丢的,不是 App 吞的);要传带空格/URL 的值得先转义,或换成 `file://` + `--eu`。
2. **保活长跑重新起跑**(第 1 次被「装新包」打断 —— `adb install -r` 必然杀掉 App,这是必然的、不是 bug):新基线 **20:13:35**、pid **25584**、对端 WS **1**、App 已在后台;新的 2 小时复查已挂上(到点自动叫醒我)。
---
## ★★ 锚点补记三十三 · 续接锚点(2026-10-06 深夜 · 上下文将满前落地)
> 压缩或换会话后**先读这一节**:现状、在跑的东西、待办、环境、教训都在这。
### 1. 仓库现状
- HEAD = **`827f0e6`**(= `origin/android`);主仓只有 `devdocs/02` 一处脏 —— **那是后端会话的在途改动:别 `checkout`、别提交**。
- 主树全量 `testDebugUnitTest --rerun` **真跑**:**110 suite / 1332 tests / 0 失败 / 0 error**。
- 本批的 worktree / 分支 / 构建锁**全清**(`git worktree list` 只剩主树)。
### 2. 本轮做完的(全部已合并推送 + 真机验收)
| 项 | 提交 | 证据 |
|---|---|---|
| ① 后台通知(只她主动说的那档) | `53c7494` | 切后台 ⇒ 注入 `proactive_message` ⇒ 通知栏「Cyrene / 昔涟 / 她的话」;**点它回到 App 并切主会话,那条消息在屏幕上** |
| ② 退出登录确认框 | `6505512` | `退出登录?` + 说明 + 取消/退出登录;**取消后没登出** |
| ③ 保活文案 + 「去系统设置」 | `6505512` | 开着含「24 小时内累计最多 6 小时」;**撤掉权限后**出现「去系统设置」 |
| ④ `INTERNET` 显式声明 | `f3d8d28` | merger 报告:`ADDED from app/src/main/AndroidManifest.xml` |
| ⑤ 构建锁协议三条 | `ed0824f` | 文档 |
| ★ 分享确认面板(含语义更正) | `62f552f` → **`30c5345`** | 分享进来**不再直接发**(对端无 chat 帧);面板:预览留正文 / 输入框空 + 「加一句描述…」/ 取消·发送;**拼接端到端验过**:`"text":"要分享的正文:看这个\n\nMyCaption123。"` |
| 保活服务本身 | `62f552f` | 开关开 ⇒ `isForeground=true`、`types=dataSync`、`channel=keep_alive` 静音常驻;关 ⇒ 停 |
| 我补的一处 | `77fbedc` | 「去系统设置授权后回来服务没起」→ 给 `AppPresence` 补同源 StateFlow 当 key |
### 3. 在跑 / 待续
- **保活长跑(第 2 次)**:基线 **20:13:35**、pid **25584**、对端 WS **1**、App 已后台。挂着后台任务(`sleep 7200` ⇒ 约 **22:13** 自动复查):① 进程还在吗(PID 应与 25584 一致)② 前台服务还在吗 ③ 对端 WS 还在吗 ④ **注入一条 `proactive_message`,看后台还弹不弹通知**。结果写进本节或下一节。
### 4. 待主人拍板 / 待办
1. **分享面板预览正文只有 3 行**(`SHARE_PREVIEW_TEXT_LINES = 3`):输入框不再承载全文后,**发送前读不到整段正文** —— 让预览可滚动/展开,还是接受只看开头?**等主人一句话**。
2. **通知恒用 id=2** ⇒ 她连说几句时后到的覆盖先到的(要「一事一条」得按 id 生成通知 id)。
3. **通知挂在 `SessionsViewModel`**(Activity 存活期内)⇒ 从最近任务划掉 Activity 后收不到帧、不弹(要覆盖得把订阅提到进程级)。
4. **转屏会静默丢掉「已取走」的那份分享**(面板状态是 `remember(sessionId)`,`take()` 不可逆)。
5. **S4 回收**:正向证据有了(超 3 格后回到最早的次级 ⇒ 重新拉一次 `messages`),**对照组没跑干净**。
6. **给后端的**:① 仍在等他们答「`tool_execution.end` 能不能带可选 `resultSummary`」;② 已去核对一条事实(`proactive` 在 `data` 里而非帧壳顶层,他们注释写「帧顶」);③ **新登记一条依赖**(实时帧的 `id` 与落库帧的 `message.id` 必须是同一条落库行 —— 我们据此去重);④ 新问一句 `channel_persisted` 会不会也带 `proactive`。**给对面的提示词我已交给主人**(贴过去即可)。
7. **推送**:主人继续研究,等他。
### 5. 环境(接着干之前先核)
- 模拟器 `emulator-5554` 在跑;App 已登录(**假后端**,地址栏 `localhost`);`adb reverse tcp:80 → tcp:18090`。
- ⚠️ **假后端 18090 那个后台任务会到点被系统收** —— 长跑复查前**先确认 18090 还在**,不在就重启:`python devtools/fake-backend/fake_backend.py --port 18090`(重启后它的 store 清空、App 会重连,对「保活」这件事无影响)。
- 真后端 `10942` 活着、**没碰**。
### 6. 本轮新增的两条教训
1. **`adb … am start --es android.intent.extra.TEXT "带空格的值"` 会被空格拆开** —— 我测试分享时那个 URL 就是这么丢的(**不是 App 吞的**)。要传带空格的值得转义,或改用 `file://` + `--eu`。
2. **用户说「这里有点奇怪」时,先想清那个控件的语义,别急着按「渲染重复」去改** —— 我把「输入框被预填了正文」误判成「预览多余」,白派了一支(已停)。**语义问题优先问清楚再动手。**
## ★★ 锚点补记三十四 · 压缩后续接:W54 三支在飞 + 主人的三条拍板(2026-10-06 21:05)
> 上一节(三十三)之后上下文压缩过一次。本节记「压缩后我做了什么」+ 主人刚拍的三条。
### 1. 压缩后排掉的三件不需要拍板的收尾活(三支并行,文件集互不重叠)
| 支 | 内容 | 落点(关键文件) | worktree / 分支 |
|---|---|---|---|
| **W54A** | 通知「一事一条」——通知 id 与 `PendingIntent` requestCode 改成按消息**稳定派生**,修「她连说几句时后到的覆盖先到的」 | `core/notify/ProactiveNotification.kt`(`PROACTIVE_NOTIFICATION_ID = 2` / `PROACTIVE_OPEN_REQUEST_CODE = 2` / `notify(...)`)、`core/notify/ProactiveNotifier.kt` 的 `post` 签名、`app/AppContainer.kt:71` 装配点 | `../Cyrene-For-Android-wt54a` / `wt54a/notify-per-message` |
| **W54C** | 转屏**静默丢掉**「已取走但未确认」的那份分享(`take()` 不可逆 + 面板状态是 `remember(sessionId)`) | `ui/chat/ChatScreen.kt` 分享落地那段、`ui/chat/ChatViewModel.kt`(候选落点) | `../Cyrene-For-Android-wt54c` / `wt54c/share-survives-rotation` |
| **W54D** | 分享确认面板预览区**可滚动**(3 行高度上限不变,用户发送前能读到整段正文) | `ui/chat/ShareConfirmPanel.kt`(`SHARE_PREVIEW_TEXT_LINES = 3`)+ 分享测试 | `../Cyrene-For-Android-wt54d` / `wt54d/share-preview-scroll` |
三支的公共约束:不引新依赖、禁 Material3、禁碰 `devdocs/**`、只本地 commit 不 push、Gradle 走 `D:/Project/Code/Uni/.cyrene-build.lock`(**只删自己那行**)。A 还禁碰 UI;C 禁碰 `ShareConfirmPanel.kt`(D 的地盘);D 禁碰 `ChatScreen.kt`/`ChatViewModel.kt`(C 的地盘)。
**第 2 批(未开)**:订阅提到**进程级**(现在通知挂在 `SessionsViewModel`,Activity 一被划掉就收不到帧)——它要动 `AppContainer.kt` 与订阅装配,跟 A 同一片区域,**等 A 合入后再开**,避免同批冲突。
### 2. 主人 2026-10-06 21:0x 的三条拍板(原文要点)
1. **分享面板预览:「可以滚动 可以展开」** —— 两个都要。所以 D 支做完滚动后,**还要追加「展开/收起」**:默认仍是 3 行 + 内部滚动,长正文时给一个展开控件,展开后能读全文,再点收起。(D 的 prompt 里只写了滚动,**追加那一轮要另发**,不要重做。)
2. **给后端的提示词「贴过去了 等他回复」** —— 等的是:① `tool_execution.end` 能否带可选 `resultSummary`;② `channel_persisted` 会不会也带 `proactive`;③ 实时帧 `payload.id` 与落库帧 `message.id` 是否**同一条落库行**(我们据此去重)。**收到回执前不要据此改工具卡 UI。**
3. **推送:「我还在研究」** —— 等他,不主动推进。
### 3. 此刻环境(动手前照核)
- HEAD 曾=`33c3202`(= `origin/android`),主仓唯一脏是 `devdocs/02` —— **后端会话的在途改动,别 checkout、别提交**。
- 模拟器 `emulator-5554` 在跑;App pid **25584**、`KeepAliveService` 在、App 在**后台**(前台是 launcher)。
- 假后端 `127.0.0.1:18090` 活着(**store 已清空**:`sessions[].messages = 0`,说明它重启过,App 会自愈重连,对保活判定无影响);真后端 `10942` 活着、**没碰**。
- ⚠️ **22:13 长跑复查之前不要操作 App 前台**(一操作就把「后台存活」的基线破坏了)。S4 对照组同理,**排到长跑之后**。
## ★★ 锚点补记三十五 · 切真后端 + 标签解析 bug(已修已验)+ W54/W55/W56 五支落地(2026-10-06 21:45)
> 本节是三十四之后的实况:**模拟器现在连的是真后端**,主人报的「标签解析小问题」已定位到根因并修完验完。
### 1. 五支全部合并推送(`a60e3ab` = `origin/android`)
| 支 | 内容 | 提交 |
|---|---|---|
| **W54A** | 通知「一事一条」:通知 id 与 `PendingIntent` requestCode 按消息**稳定派生**(`proactiveNotificationId(key)`,先展宽 Long 再 `%` 落到 `[1000, 2e9+999]`,避开保活的 1 / 0) | `53339b3` |
| **W54C** | 转屏不再静默丢掉「已取走的分享」:面板状态与描述移进 `ChatUiState.shareConfirm`(`ShareConfirmDraft` + 纯函数 `shareConfirmClaim`),**`take()` 仍只发生一次** | `1142bf5` |
| **W54D** | 分享面板正文预览:3 行高度上限 + **内部滚动**(`cap` 由 `TextLayoutResult` 量出,非写死 dp) | `ec89d82` |
| **W55** | **解析器剥掉落单标签**(本节的 bug①) | `464e916` |
| **W56** | 分享面板预览加**展开/收起**(主人拍板「可以滚动 可以展开」的后半) | `14955ef` |
- 合并后主树全量真跑:**112 suites / 1361 tests / 0 failures / 0 errors**。
- worktree / 分支 / 构建锁**全清**(`git worktree list` 只剩主树)。
- ⚠️ **worktree 目录删除在 Windows 上会因长路径失败**(`git worktree remove` 报 `Filename too long`,`cmd rmdir`、`robocopy /MIR`、`Remove-Item` 都不行)。**可用的招**:`find <dir> -depth -delete`。注册表用 `git worktree prune` 清。
### 2. 模拟器已切到真后端(主人 2026-10-06 晚要求)
**怎么切的**(App 设置页里**没有**「服务器地址」栏,改地址只能走登出):
1. 抽屉 →「App 设置」→ 滚到底 →「退出登录」→ 确认框点「退出登录」;
2. 登录页填地址 / 用户名 / 密码 → 登录。
**⚠️ 踩到的坑(下次照抄结论)**:`adb shell input text "localhost:10942"` 里的 `:` 会被**中文输入法转成全角 `:`(U+FF1A)**,于是地址成了 `localhost:10942` → URL 非法 → Retrofit 抛 `IllegalArgumentException` → `mapException` 的 `else` 分支 → 界面显示**「发生未知错误」**(`CyreneError.LocalKind.Unknown`)。
**排查凭证**:`uiautomator dump` 读出 EditText 的真实值 = `localhost\uff1a10942`(转义后一眼看到全角)。
**绕法(已采用)**:把 `tcp:80` 直接转发到真后端,地址只填 `localhost`(纯 ASCII,无冒号):
```
adb reverse --remove tcp:80 && adb reverse tcp:80 tcp:10942
```
**当前转发**:`tcp:80 → tcp:10942`(真后端)、`tcp:10942 → tcp:10942`。⚠️ **假后端 18090 因此失去了 `tcp:80` 映射**,要回去用假后端得把这条改回来。
**当前登录态**:真后端、账号 `yeij0942`(`role=owner`),主会话 `web`。宿主机直连验证:`POST http://127.0.0.1:10942/api/v1/auth/login` → **200** `{token,expiresAt,role:owner,refreshToken}`。
### 3. bug① 「标签解析」——根因、证据、修复、验收(**这是主人报的那条**)
**现象**:气泡正文里**字面显示** `<p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?`。
**根因(服务端产出的 markup 畸形)**:从真后端拉到的那条消息 `content` 逐字节是
```
<p><action>满意地点点头</action></p>\n<p>真乖♪ …放心的一句。</p>\n<p><p>那现在——…发会儿呆?</p>
```
第三段 = **`<p><p>` 两个开标签、一个 `</p>`**。客户端 `ChatContentParser.kt:70` 的 `P_BLOCK_RE` 是**非贪婪**的,必然把内层裸 `<p>` 收进本段正文,于是标签被当正文渲染。
**修(客户端容错,`464e916`)**:只剥**完整匹配标签正则**的落单 `<p>` / `<p 属性>` / `</p>`(有完整块时也剥块外未认领的旁白标签);`1 < 2`、`<3`、`a<b`、`<p2>`、`<P>`(大写,有既有反契约)、残缺 `<p` 一律**原样保留**。17 条新单测。
- **流式那条**:`stablePrefixEnd()` **一行未改**,理由写进了 KDoc(前缀里每个开标签都已配对 ⇒ 中间态不存在「还没定死的落单标签」;而 `<p><p>…` 这种扣留点是 0,中间态压根不产帧,谈不上跳动)。子代理**评估并否决**了「放宽扣留让正文更早流出」的方案(会引入字面标签闪现)。
**真机验收(`emulator-5554` + 真后端)**:装新 APK 后打开主会话,那条消息现在渲染成
`那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?`(干净无标签),旁边那条旁白/两段仍各就各位。
**已登记给后端**(`devdocs/02`,本节末尾:**「客户端登记(2026-10-06 晚 · 在途消息出现 markup 畸形…)」**,含逐字节凭证、为什么「任何按契约实现的消费方都会踩」、我们已容错不需要他们动作、以及问他们**两件事**:① 是拼接 bug 还是有意形状?② 若是 bug 能否修在产出侧?)。
⚠️ **这一节我没有提交**:`devdocs/02` 工作区里压着**后端会话的在途改动**(8 个 hunk,最后一支延伸到文件末尾),提交会跟它打架。内容已写入磁盘文件,提交留给掌握该文件的会话。
### 4. bug②(顺带诊断)模拟器为什么卡 —— 不是分配少了
**证据(宿主 `Get-Process` 按累计 CPU 排序)**:同时跑着 `cs2`(游戏,34837 s)、`llama-server`(本地模型,53186 s)、com.docker.backend、GameViewer、加速器,**并且当前是 RDP 会话**(`query session` → `rdp-tcp#1` 为当前;`emulator.exe` / `qemu-system-x86_64.exe` 都跑在 RDP 会话里)。
- AVD 配置本身不寒酸:`hw.ramSize=8192`、`hw.cpu.ncore=5`、`hw.lcd=1080x2340@440`;模拟器内 `top` 显示 idle 仍有 **398%**(CPU 不是瓶颈)。
- `emulator -accel-check` → **WHPX 可用**(硬件加速正常)。
- **主因判断**:RDP 会话下 GPU 加速路径退化 + 宿主的游戏/模型/Docker 抢资源;我这边并行跑的 Gradle 构建也在抢。
- **已做(无损)**:`settings put global {window,transition,animator}_animation_scale 0`(关动画,UI 立刻跟手,不改分辨率所以坐标不变)。
- **没做(要重启模拟器,得主人点头)**:换 `-gpu angle_indirect`、或降 `hw.lcd` 分辨率/密度。⚠️ 重启模拟器**必然杀 App**。
### 5. 环境现状(动手前照核)
- HEAD = **`a60e3ab`** = `origin/android`;主仓唯一脏 = `devdocs/02`(**后端在途 + 我这一节的登记**)。
- 模拟器 `emulator-5554` 前台是 App;App 已装**合并后**的 APK(`adb install -r` 会杀进程,**不是 bug**)。
- ⚠️ **保活长跑基线(20:13:35 / pid 25584)已被打断两次**:主人自己把 App 切到前台;以及我 `adb install`。那个 22:13 的复查任务结果**只能当「装包后重启的 App 能不能重新连上」看**,不能再当「后台存活」的证据。
- 假后端 18090 进程仍在,但**已不在 App 的路径上**。
### 6. 待办(更新后)
1. **等主人真机验收**(他自己看,我不截图代替):分享面板「3 行窗口 + 内部滚动 + 展开/收起」(W54D + W56);退出登录确认框;保活文案。
2. **等后端回执**:`.end` 的 `resultSummary`、`channel_persisted` 是否带 `proactive`、实时帧 id 与落库 id 同源、**以及本节 3 的两问(markup 畸形)**。
3. **W56 留下的已知取舍**:分享面板的**展开态不跨转屏**(状态在 `remember(preview.text)`;跨转屏要么进 `ChatUiState`(红线)要么 `rememberSaveable`(本仓零先例)→ **待主人定要不要做**);展开态上限 240dp **没在真机上量过**。
4. **批 2(未开)**:订阅提到进程级(Activity 被划掉后收不到帧)。W54A 已合入,`AppContainer.kt` 的冲突面解除,可以开了。
5. **S4 回收对照组**(设备侧,随时可做,长跑已无基线可言)。
6. **推送**:主人继续研究,等他。
### 7. 保活长跑第 2 次的复查结果(**作废,不构成回归证据**)
`22:13:39` 到点自动跑了那四条,原始输出:
```
1) 进程还在吗: (空)
2) 前台服务条数: 0
3) 对端 WS 连接: 0
4) 注入一条 proactive: delivered=0
5) channel=proactive 的通知条数: 0
```
**为什么作废(三条各自独立成立的原因,都不是功能回归)**:
1. **我在长跑期间 `adb install -r` 装了新 APK** —— 装包**必然**杀 App 进程,这是已知事实(锚点三十三已写)。
2. **`tcp:80` 现在指向真后端 `10942`**(切服务器时改的),App 已不在假后端 18090 的路径上 ⇒ 往 18090 注入 proactive 帧 `delivered=0`、`wsClients=0` 是**必然**,与客户端行为无关。
3. 复查那一刻(及现在)App 进程**根本不在**(`pidof` 空、前台是 launcher、`KeepAliveService` 0 条)——**最可能的解释是保活开关当时没开**(没开 ⇒ 没有前台服务 ⇒ App 在后台待了几十分钟被系统正常回收,这正是「不保活」的预期)。⚠️ **这一条我没有实证**(App 已不在,读不到开关状态),**不要当结论**。
**要重做才有效**:得先把 `tcp:80` 切回假后端 `18090`(`adb reverse --remove tcp:80 && adb reverse tcp:80 tcp:18090`),再在真机/模拟器上开保活开关、建立新基线。⚠️ **但切回假后端会打断主人此刻在模拟器上用真后端看真实会话**,所以**没做,等主人定**。
## ★★ 锚点补记三十六 · W57(通知消费点提到进程级)+ 保活长跑第 3 次基线(2026-10-06 22:25)
### 1. W57:Activity 被划掉后也能弹通知 —— **根因与修法**(`0e03eba` → 合并 `900d191` = `origin/android`)
**结论先行**:帧**到得了**连接,是**到了没人消费**。改动面因此很小(没搬连接所有权、没新起连接)。
根因链(子代理逐条给了行号证据,改前状态):
1. **连接是进程级的**:`AppContainer.chatSocket`(`AppContainer.kt:282`,`by lazy`)在 `CyreneApplication.onCreate` 建;`ChatSocket.connect()` 把连接循环跑在它自己的 scope 上(`ChatSocket.kt:141,358-359`,`CoroutineScope(SupervisorJob() + Dispatchers.IO)`),**只有 `close()` 会取消它**(`ChatSocket.kt:165-175`)。
2. **没有 ViewModel 会关它**:`SessionsViewModel.onCleared()` 是空的,KDoc 明写「刻意不关连接」。
3. ⇒ 划掉 Activity 后连接照旧活着、`onMessage` 照旧 `_events.tryEmit(...)`。
4. ⚠️ **但 `_events` 是 `MutableSharedFlow(replay = 0, extraBufferCapacity = 512, DROP_OLDEST)`**(`ChatSocket.kt:101-105`)——`replay = 0` 意味着**不为将来的订阅者留任何东西,没有订阅者时 emit 就是当场丢**。
5. 而 proactive 帧当时的订阅者是 `SessionsViewModel.kt:232` 的 `viewModelScope.launch { socket.events.collect(::onEvent) }`,`viewModelScope` 随 VM `onCleared` 取消,该 VM 由 Activity 的 `ViewModelStore` 持有 ⇒ **划掉 Activity = 订阅被取消 = 帧被丢**。
**修法**(`app/ProactiveFrameIntake.kt` 新增):
- 进程级帧消费点:订 `ChatSocket.events`,把两类帧(实时 `proactive_message` / 带 `proactive` 的 `message_persisted`)交给 `ProactiveNotifier`;纯函数 `proactiveFrameOf(event)` 决定「哪些帧算她主动说的话、四个入参各取哪个键」。
- **构造即订阅,刻意不提供 start/stop**(理由写进 KDoc:唯一会想调 stop 的调用方是 VM,那正好把通路又绑回 Activity;留一个能忘调的 `start()` 只是多一种「通知整个哑掉且不报错」的坏法)。
- `AppContainer` 新增 `proactiveFrameIntake`;`SessionsViewModel.onEvent` 里那两处转交**删掉**;`ProactiveNotifier` / `AppPresence` **只改 KDoc**(判据、去重、`post` 签名零改动)。
- 登出**不需要**取消订阅:帧只有连接活着才可能到,而连接在 `LoggedOut` 与令牌失效两处都会断;去重账本刻意不清(换账号后同一条再推也不该弹第二遍)。
**验收**:全量真跑 **113 suites / 1370 tests / 0 failures / 0 errors**(基线 112/1361,±0 其它形状变化)。新增 `ProactiveFrameIntakeTest` 9 条,其中 `her words still notify when no activity is alive` 就是本条的现场。
**⚠️ 一个需要主人知道的副作用**:`AppContainer` 现在会在 `Application.onCreate` 时**提前把 `chatSocket` 建出来**(改前 `by lazy`,第一次被 VM 取用才建)。代价是进程启动多造一个 `OkHttpClient` 对象(线程池仍懒建)。若不接受,改法是把 intake 的参数换成 `() -> SharedFlow<WsEvent>` 保留懒性——**没做**,因为那会让「订阅时刻」与「连接建出时刻」分开,多一处要解释。
**另一处必要项**:`deliver` 里接住了落地异常(改前异常会打死 VM 那条 collect,但 VM 重建会自愈;现在它是唯一消费点,异常逃出去就是**进程崩溃 + 通知永久哑掉**,所以必须接住,只 DEBUG 打日志)。
**设备验收步骤(等长跑结束再做;⚠️ 装包必然杀进程)**:
```
adb root && adb reverse tcp:80 tcp:18090 # 注意顺序:adb root 会冲掉 reverse,要放在前面
adb install -r app/build/outputs/apk/debug/app-debug.apk
# 打开 App → localhost 登录 → 授通知权限 → 设置页开「后台保持连接」
# 进主会话 → Home 退后台 → 最近任务里把卡片划掉(等 2~3s)
pidof top.yeij.cyrene # 记下 PID
dumpsys notification --noredact | grep keep_alive # 保活通知还在 = 进程确实被撑着
python devtools/fake-backend/proactive_inject.py --port 18090 --text "划掉之后也要弹"
dumpsys notification --noredact | grep 划掉之后 # 期望:有「昔涟 / 划掉之后也要弹」
pidof top.yeij.cyrene # 期望:与前面同一个 PID(证明是同一进程,不是重启后补弹)
```
**判据前提**(不满足则本次无效):注入**之前** PID 未变 **且** 保活通知还在;通知权限已授予。
### 2. 保活长跑第 3 次(假后端,基线 `22:16:08`)
主人 2026-10-06 晚授权「允许你切」,于是把 `tcp:80` 切回假后端 `18090` 重做。基线:前台=launcher(App 在后台)、**pid `31092`**、`KeepAliveService` **3 条**(`isForeground=true` / `foregroundId=1` / `channel=keep_alive` / `ONGOING_EVENT|NO_CLEAR|SILENT`)、假后端 `wsClients=1`。挂了后台任务:**30 分钟抽查**(22:46)+ **2 小时复查**(00:16,含注入一条 proactive 看后台弹不弹)。
**★ 顺带推翻了一条猜想**:锚点三十五 §7 猜「上次复查时进程不在」可能是**保活开关没开**。这次 App 一启动我就查了——**服务立刻就在跑**(开关是开的)。所以那条猜想**是错的**,上次进程消失是**别的**原因(可能是系统低内存强杀,也可能是被手动划掉——**仍未定**,别当结论)。
**当前转发**:`tcp:80 → tcp:18090`(假后端)、`tcp:10942 → tcp:10942`(真后端)。要回真后端改这一条即可。
## ★★ 锚点补记三十七 · 长跑第 3 次定案(模拟器崩,不是 App 的问题)+ W57 端到端验收通过(2026-10-07 00:30)
### 1. 长跑第 3 次结果:**30 分钟有效通过;2 小时那次失败是模拟器自己崩了**
原始输出:
```
=== 30min 抽查 @ 22:46:34 === === 2h 复查 @ 00:16:34 ===
pid: 31092 pid: (空)
服务: 3 服务: 0
前台: launcher(仍在后台) 前台: Window{4f748ad …launcher}
ws: 1 ws: 0
注入 delivered=0 / proactive 通知数 0
```
**30 分钟那一条是有效证据**:pid 与基线一致(31092)、服务 3 条、仍在后台、WS 还连着 ⇒ **半小时后台存活成立**。
**2 小时那一条的失败与 App 无关**,证据链(五条独立):
1. `logcat` 在**模拟器时间 16:02(≈宿主 00:08,正卡在两次抽查之间)**出现整段**开机序列**(`sys.boot_completed=1`、lmkd 启动、apexd…)。
2. 但 `/proc/uptime` = **10.19 小时,没变** ⇒ **不是整机重启,是 system_server(framework)崩溃后重启**。
3. launcher 的 window id 从 `2cbfcc8` → `4f748ad` ⇒ launcher 也重启过(对上)。
4. `dumpsys activity exit-info top.yeij.cyrene`(共 16 条)里**没有 pid 31092 的退出记录**,最新一条还是宿主 21:29 我装包那次(`reason=16 PACKAGE UPDATED`)⇒ 走的**不是**正常进程退出路径。
5. 通知栏里那条 `keep_alive` 通知**还残留着** ⇒ 进程非正常死亡(系统没来得及清)。
**结论**:模拟器 framework 崩 ⇒ 连带杀掉所有 App ⇒ 与客户端保活能力无关。**但也不能反过来当「保活能撑 2 小时」的证据**(那次没跑到)。模拟器这台机器(摘要里就记过「极不稳、launcher ANR 过、system_server 会崩」)**不适合做小时级长跑**,要拿这个结论得上真机。
### 2. ⚠️ 一条要紧的平台发现:Android 14 起「从最近任务划掉」会终止 `dataSync` 前台服务
原本 W57 的验收步骤是照「把卡片划掉」写的 —— 那**测不出 W57**:Android 14 改变了行为,用户划掉 App 时它的前台服务会被一并终止(我们的服务类型是 `dataSync`),进程随之死掉,于是「Activity 没了但进程还在」这个场景**根本造不出来**。
**改用正确造法**(本次采用,成功):
```
adb shell am start -n top.yeij.cyrene/.app.MainActivity # 确保 Activity 在前台
adb shell input keyevent 4 ×4 # 逐层返回直到 Activity finish
```
判据:**前台变成 launcher**(Activity 已 finish)+ **pid 不变** + **服务条数不变** + **WS 仍连着** ⇒ 场景成立。
### 3. W57 端到端验收:**通过**(`emulator-5554` + 假后端 18090,含 W57 的 APK = `900d191`)
| 步骤 | 实测值 |
|---|---|
| 通知权限 | `POST_NOTIFICATIONS: granted=true` |
| 装包含 W57 的 APK 后 | `pid=3180`、`KeepAliveService` **3 条**(开关本来就是开的,服务随启动自起) |
| 造场景(返回键 ×4) | 前台 = **launcher**(Activity finish)、**pid 仍 3180**、服务 **3 条**、假后端 `wsClients=1` |
| 注入前 `channel=proactive` | **0** |
| 注入 `proactive_message`(content「W57验收:Activity 没了也要弹」) | `delivered=1` |
| 注入后 `channel=proactive` | **1** |
| 通知内容 | `android.title=昔涟`、`android.text=W57验收:Activity 没了也要弹`(`bigText` 同) |
| 注入后 pid | **3180**(与场景建立前同一个 ⇒ **同一进程**,不是重启后补弹) |
⇒ **Activity 已销毁、进程靠前台服务活着时,「她主动说的话」照常弹通知。** 这正是 W57 要修的那一档。
**⚠️ 如实说明我们没做的**:**没有做「改前不弹」的对照实验**(要另出 W57 之前的 APK、再走一遍,会占构建锁和一次装包)。所以「改前确实不弹」这一半靠的是**代码级确定性证据**(订阅挂在 `viewModelScope`、`replay=0` 的 SharedFlow 丢弃无订阅者的 emit)与 9 条单测,**不是**实验证据。**别把它说成「实验证明改前是坏的」。**
### 4. 环境现状(收工前)
- HEAD = **`900d191`** = `origin/android`;worktree/分支/构建锁**全清**;主仓唯一脏 = `devdocs/02`(后端在途 + 我那条 markup 登记)。
- 模拟器上装的是**含 W57 的 APK**;App 现在**进程活着但 Activity 已 finish**(前台 launcher),登录的是假后端 `localhost`。
- 转发:`tcp:80 → tcp:18090`(假后端)、`tcp:10942 → tcp:10942`(真后端)。**要回真后端只改 `tcp:80` 一条。**
- 假后端 18090 进程仍在。
### 5. 收尾(2026-10-07 01:00):假后端任务到点,环境已切回真后端
- 假后端 18090 那个长寿后台任务**到点被系统收掉**(`18090 已不在`)。长跑也已用完,于是收尾。
- **转发切回真后端**:`adb reverse --remove tcp:80 && adb reverse tcp:80 tcp:10942`;现在 `tcp:80 → 10942`(真后端)+ `tcp:10942 → 10942`。真后端 `health=200`。
- **App 已重连真后端并拉到真实会话**(截图确认:「已连接」+ 主人和昔涟的真实对话 + 旁白灰斜体 + 发送至微信/安卓/网页的来源角标)。`pid=3180`、`KeepAliveService` 3 条。
**一条如实记录的观察(不是缺口,但值得知道)**:切后端后,屏幕上**混进了验收期间留在本地的消息**(那条「W57验:Activity 没了也要弹」)。
- 我核实了:**真后端主会话最近 12 条里没有它** —— 它只活在 App 本地。
- 原因:这次切换只改了 `adb reverse` 的目标,**App 看到的服务器地址字符串始终是 `localhost`、账号也还是同一个** ⇒ 按 W48 的口径(VM key 用**账号指纹**)key 没变 ⇒ 数据保留。**客户端无从感知**,所以这不是它的 bug。
- 正常路径上不会发生:**换服务器地址必须走登录页**(地址栏只在登录页),而登出会 `clearAll` ⇒ 数据会被清掉。所以**不登记为缺口**,只记这里。
- 那条残留会在 App **冷启动**(重新从服务端拉历史)后消失;我**没有**强杀进程去清它(不想打断当时正开着的保活服务状态)。
## ★★ 锚点补记三十八 · 分享面板崩溃(W58,两次)—— 真机验收发现并修掉(2026-10-07 01:55)
> 主人这一轮说「**允许你截图验收**,1. 不用(不换前台服务类型)」,于是我开始自己上机验收 UI —— 第一次动分享面板就**验出一个 P0**。
### 1. 现象与根因(**同一个异常,踩了两次**)
真机崩溃栈(两次完全一致):
```
java.lang.IllegalStateException: Vertically scrollable component was measured with an
infinity maximum height constraints, which is disallowed.
at CheckScrollableContainerConstraintsKt.checkScrollableContainerConstraints
at androidx.compose.foundation.ScrollNode.measure
...
at androidx.compose.foundation.layout.SizeNode.measure ← heightIn(max = …)
at androidx.compose.ui.window.DialogLayout.internalOnMeasure
```
**第一次**(`01:37:30` pid 17630,分享进来、面板刚打开):`ShareConfirmBody` 里 `var cap by remember(text) { mutableStateOf(Dp.Infinity) }` —— **拿 `Dp.Infinity` 当「还没量到」的哨兵值**,于是首帧 `heightIn(max = Dp.Infinity)` = 不施加约束,同一串 modifier 上的 `verticalScroll` 就在无界高度下被测量。
**第二次**(`01:48:53` pid 2077,**点「展开」的那一下**):我第一笔修复把展开态的 `heightIn` 去掉了(改对了方向),**但把 `verticalScroll` 留着** —— 而外层 `ShareConfirmPreviews` **自己就是一个 `verticalScroll`**,它给内容的是**无界高度** ⇒ 这一层的 scroll 又在无界高度下被测量。
**修法(两笔合起来才完整)**:
1. `ShareConfirmPanel.kt`(`df7b504`):这一层的高度上限**只经纯函数 `shareConfirmWindowCap(measured)` 一个出口**(返回 `measured` 或 `SHARE_PREVIEW_MAX_HEIGHT`,**永远有限**),哨兵值改用 `null`;**展开态把这一层整个去掉(上限 + 滚动一起)**,只留外层那一层滚 —— 这也正是 W56 原本写在注释里的设计意图。
2. 测试:`ShareConfirmWindowCapTest` 4 条,钉住「任何输入都不会产出非有限上限」+「两种状态在链上都还有有限约束」。
3. 全量真跑:**114 suites / 1374 tests / 0 failures / 0 errors**。
### 2. 真机验收(`emulator-5554` + 真后端,改后重装)
用 `uiautomator dump` 的 **bounds** 当判据(比截图准,也能避开「抓到首帧」的坑):
| 状态 | 正文节点高度 | 控件 | pid |
|---|---|---|---|
| 收起 | `[127,1614][953,1767]` = **153px(3 行)** | 「展开」 | 4063 |
| 点「展开」后 | `[127,1512][953,1767]` = **255px**,面板整体上移 | 「收起」 | **4063** |
| 再点「收起」 | 回到 **153px** | — | **4063** |
三次操作**同一个 pid**,`exit-info` 里没有新崩溃记录 ⇒ **崩溃修掉、展开/收起功能都正常**。
### 3. ★ 三条教训(都值得写死)
1. **单测抓不到渲染层崩溃**。W54D / W56 都只有纯 JVM 单测(本模块没有 Compose UI 测试依赖),两笔改动都「测试全绿」——而真机上一打开面板就闪退、一点展开又闪退。**动渲染层必须上机**,这条从今天起按硬约束对待。
2. **「论证很详细」不等于「真机正确」**。W54D 的交付说明里专门论证过「`roundToPx` 不会退化成 0、不会触发 `checkScrollableContainerConstraints`」——方向全对,但**结论反了**:触发那个检查的恰恰是 `Constraints.Infinity` **本身**。子代理的反编译论证值得读,但**不能替代一次真机点按**。
3. **`verticalScroll` 的约束来源要看「谁在外面」**。同一个 `verticalScroll`,被 `heightIn(max = 有限值)` 包着就安全;一旦外面是另一个 `verticalScroll`(哪怕是隔了一层的 Column),它拿到的就是无界高度 ⇒ 崩。**「去掉上限」和「去掉滚动」是两件事,必须一起做。**
### 4. 顺带纠正一个我自己的误判(W56 的结论被推翻)
我第一次看截图时以为「3 行窗口和展开控件都没生效」(截图上正文显示了 5 行、也没有「展开」)。**是错的**:`uiautomator dump` 显示「展开」节点在、正文节点高 153px(3 行)。
真相是我**抓到了首帧**(此时 `cap` 还是 fallback `150dp`,还没收敛),而 dump 是稍后采的(已收敛到 3 行)。
⇒ 这反过来证明:**W56 当初判定「首帧那次回流看不见」是错的,它在真机上确实可见**(5 行 → 3 行跳一下)。
**待办**:要不要消掉它(W56 给的备选是组合期 `rememberTextMeasurer`)——**等主人定**,不是回归、不影响可用性。
### 5. 环境(本轮变化)
- **模拟器已重启**(主人建议):`adb emu kill` → 冷启动 `-avd Pixel_10_Pro_XL -no-boot-anim -no-snapshot-load`,约 35 秒 `boot_completed=1`。重启**会丢 `adb reverse`**,已重设:`tcp:80 → 10942`(真后端)、`tcp:10942 → 10942`。
- ⚠️ 重启前的旧模拟器上,**system_server 崩过至少三次**(`com.android.phone` 反复报 `DeadSystemException: The system died`,时间 `00:03` / `00:48` / `01:19`)——这也是长跑第 3 次 2 小时失败的原因(见上一节)。
- HEAD = **`df7b504`** = `origin/android`;主仓唯一脏 = `devdocs/02`(后端在途 + 我的 markup 登记)。
## ★★ 锚点补记三十九 · 后端回执二十五/二十六 + W59 工具卡结果区(2026-10-07 上午)
### 1. 后端说了什么(两条回执,都在 `devdocs/02`)
**`回执二十五`**(四条 + 一问 + 告知):
1. **`.end` 加可选 `resultSummary` ⇒ 做**。「与回看面 `toolCalls[].resultSummary` 同一份预算」:截断 ≤500 字符、**走同一条脱敏路径**。**缺席语义**:只有结果**真的拿到了**才出现;失败/取消/超时/未落图 ⇒ **整键缺席**,**绝不用空串顶位**、也别把缺席读成「没有输出」。原话:**「你们现在就可以把工具卡定死」**(在场渲染结果区 / 缺席只显示工具名与状态,同一套 UI)。排期=新切片、下一个契约版本;`.update` 仍不补。
2. **事实核对:我们对、他们注释错了** —— `proactive` 确实在帧的 `data` 里(不是帧壳顶层),他们已把 4 处注释 + 2 处测试注释改掉。
3. **新登记的依赖 ⇒ 确认**:`proactive_message.data.id` 与 `message_persisted{proactive}.data.message.id` **同源**(都是主会话落库行的 id)⇒ 登记成**成对契约**;⚠️ `proactive_message.messageId` 是 **IM 侧号**,**不能拿去对齐落库行**。
4. **`channel_persisted` 会不会带 `proactive`**:**结构上会**(帧型由会话 id 决定、`proactive` 是独立入参),但当前所有主动推送都落主会话 ⇒ 实际发的是 `message_persisted`。我们**两层都读**是正确做法,继续保持。
5. 告知:主动帧的 `sessionId` **恒为主会话 `web`**(所以「切主会话」与他们的口径一致)。
**`回执二十六` + `·补`**(回我们的 markup 畸形登记):
- 问 1 ⇒ **是 bug,不是有意形状**(契约里 `<p>` 就是「一段」,没有嵌套语义)。
- 问 2 ⇒ **认下,已修完推库 `8051906`**(`fix(turnreply): 产出侧不再产出未配对的段落标签`)。根因**两半且在同一个文件**:① `paragraph.go` 的段块正则**非贪婪** ⇒ 把 `<p><p>X</p>` 当成「已完整包裹」逐字保留,**连护栏都用同一个非贪婪正则**(护栏是盲的);② `normalizePiece` 对「未闭合的段首 `<p>`」**无条件再套一层** —— 他们用我们上报的那条串**逐字节复现**了。③ 顺带挖出幂等漏洞(落单 `</p>` 越跑越多)。
- ⚠️ **未部署**:跑着的后端是旧产物 ⇒ **畸形仍会产出**,要我们**继续保留容错**(我们已保留)。历史畸形只 **1 行**,不做回填脚本。
- 契约面会补一条**产出侧硬约束**(落库/对外文本不得出现未配对的段落标签)。
- ⚠️ 一条**与归因有关的诚实说明**(他们给的):那条具体是 (a) 还是 (b) 产生的**无法判定**(两条路径产出的串逐字节相同、原始模型输出没副本)。
### 2. 我们的动作
**① 去重口径复核 ⇒ 没踩坑(无需改动)**。他们提醒「`messageId` 别拿去重」,我核了 `proactiveNotifyKey`:它**不是拼接**,而是 `firstOrNull { !isNullOrBlank() }`,顺序 `id → messageId → timestamp → 文本兜底`。⇒ 实时帧(`id` = 落库行 id)与落库帧(`message.id` = 同一行 id)算出**同一个键**,去重正确;`messageId` 只在 `id` 缺席时才可能被用到,而那档(落库失败)根本没有对应的落库帧。**这条已写进给他们的答复。**
**② W59 工具卡结果区**(`316cd27` → 合并 **`e0bdaa3`** = `origin/android`):
- `WsEvent.ToolExecutionEnd` 加可选 `resultSummary`(共用载荷 `ToolExecutionData` 跟着加,**只有 `.end` 的 dispatch 读它**)。
- 判据做成纯函数 **`toolResultTextOf(tool)`** 三档:**有值** ⇒ 结果区显示它;**空串/纯空白** ⇒ 视同缺席(不画,防出空块);**缺席** ⇒ **连「结果」标签与它上面那道 Spacer 都不画**(与今天逐字节一致),绝不画「没有输出」这类替作者下结论的文案;缺席但有历史 `output` ⇒ 回落 `output`(老口径不变)。
- ⚠️ **没有引入任何 `verticalScroll` / 限高**(沿用参数块那套 `FoldCardBody`)⇒ **结构上不可能重复锚点三十八那次崩溃**。
- 14 条新单测(解码 5 + 判据/落卡 9);全量真跑 **114 suites / 1388 tests / 0 failures**。
**③ 02 里写了「客户端答复」**(收到 + 我们怎么做 + 去重复核结论 + 请他们别为历史那 1 行动手),**未提交** —— 02 仍压着后端的在途改动。
### 3. 真机验收的边界(如实,别夸大)
**W59 这一批实际上只有单测证据**,两个独立原因:① 后端 **未部署** ⇒ 真机上 `resultSummary` **恒缺席**,「有值」那档验不了;② 后端**当前没配工具** ⇒ **连工具卡本身都触发不出来**,「缺席时与老版一致」这一半也没有可点的入口。
⇒ 等后端部署 + 配了工具,再做真机验收(要造一次真的工具调用)。**在那之前不要把这一批报成「已真机验证」。**
## ★★ 锚点补记四十 · 后端回执二十八 + W60/W61/W62 三批落地(2026-10-07 中午)
### 1. 后端回执二十八(回我们的四问)—— 逐条
1. **`resultSummary` 的 500 是 rune(码点)不是字节** ⇒ **我们不需要宽容裁切**(不会出现半个中文字)。顺带登记一处口径差异:**实时帧 500 rune / 回看面 80 rune**。
2. **`8051906` 已部署**(10:37:44 新产物 / PID 630540);同一批还上了 `2c56bc9`(修「智谱无链接结果被当无效丢光」)。他们**纠了我一个前提**:「没配工具」不成立(工具全注册),他们给的解释是「那一轮 `tool_calls=0`、模型自己没调」。
- ⚠️ **但我们截图里见过「工具调用失败 · web-search」** ⇒ 工具确实会调、只是**失败**过。`2c56bc9` 已随这次重启带上 ⇒ 值得再试一次搜索。
3. **未读面**:没有独立「未读增量端点」,**但消息前向增量 `?after=`(毫秒 `createdAt`)早就在册** ⇒ **我们 W49 那次整表拉取可以直接换掉**(他们明确批了)。
4. **语音**:`POST /api/v1/audio/transcribe`(U5,**owner-only + 审计、同步**、base64、32 MiB)**早就有**;**没有**的是「上传音频变成一个带 `voice` 的消息」。还逐条答了 6 个子问(时长上限未定义 / 格式白名单未定义 / `voice_input` 那条转写文本只进 user `content` / 失败两套口径别混用 / 转写同步但回合异步)。
### 2. 畸形回归:**通过**(拿他们部署后的产物验的)
拉主会话最近 60 条(覆盖 `10-06 13:52` → `10-07 10:39`,含部署后的消息):**畸形只有 1 条**,`id = b88e55d6…`,`createdAt = 10-06 21:05:11` —— **正是我最初上报的那条历史行**(他们说「历史只有 1 行」,对上了)。⇒ **部署后没有产生新的畸形** ✓
### 3. W60:后端重启后自愈(`83a2a88` → 合并 `55612a3`)
**根因(子代理推翻了我的一个前提)**:`allClosed()` **不是孤儿**(`SessionIdle` / `SessionInterrupted` 会调它)—— 真正的缺口是**那两帧在后端重启时永远不会到**。而且这 bug 有**两半**:
- **回合绑定**:`TurnBinding.closed` 是唯一收口闸,只由 `turn_end` 类帧驱动;
- **流式卡(第二半)**:`turns` 的两个写点**都不碰 `streams`** ⇒「思考中…」「执行 X…」底栏「正在思考…」三条闸**全都不收**。
**「拉不到新消息」的真相(这条最精彩)**:补拉**跑了**(`reconnectPull` 命中 `Forward`),但**那条永远 `active` 的流式路永远是列表最后一项** ⇒ 「跟随底部」的触发键(`items.lastOrNull()?.key`)**不再变化** ⇒ **新消息进得来,屏幕不跟着滚**。
**修法**:新增 `ui/chat/ReconnectReset.kt` —— `isReconnect(previous, current, everConnected)`(用**只置不翻的 `everConnected`**,因为 `ws.state` 是 StateFlow,`Connected → Reconnecting → Connected` 的中间跳会被**合流吞掉**);触发「连接重建」时把未收尾的**回合 / 流式卡 / 工具卡**一次性收口(**不删任何内容**,只把 `active/thinkingActive/phase` 收掉);**第一次连接不算重建**。17 条新单测。全量 **115 suites / 1405 tests**。
- ⚠️ **设备复现验证还没做**(子代理给了可照抄的假后端步骤)。**别把这一批报成已真机验证。**
- 一条如实登记的代价:`busyMode = null` 会让「重建后第一次发送」不再先发 `interrupt`(服务端下次报 `session_busy` 即恢复)。
### 4. W61:分享路径审查(`22d655b` → 合并 `3d8bc61`)—— **主人报的「网易云分享文本闪退」归因**
**结论:当前 HEAD 这条路径没找到残留崩溃点** ⇒ 与已知 P0(`6d4f2cd`/`df7b504`:`Dp.Infinity` 当哨兵值 + 展开态没去掉 `verticalScroll`)**完全吻合**,证据指向**主人手机上的包早于那个修复**。
逐项(6 项全查过,证据都在回报里):
1. Intent 多样性:`EXTRA_SUBJECT`/`EXTRA_TITLE` **本端不读**;非 `CharSequence` 的 `EXTRA_TEXT` 由 SDK 的 `getCharSequence` **catch `ClassCastException`** 兜住(不抛);`content://` 在接收那刻就拷进 cache(`runCatching`)。
2. 空/边界输入:空串/空白/`<`/`<p><p>`/半个代理对/`\u0000`/10 万字符 —— **全不抛**。
3. 面板渲染:面板里**只有两个 `verticalScroll`**,各自前面都有一层**有限** `heightIn`;**反编译 miuix-ui-0.9.3 全部 545 个 class**,`WindowDialog`/`DialogLayout`/`TextField` **一个 scroll 节点都没有** ⇒ 「无界高度 + 滚动」在面板里不可能成立。
4. 出队闸门与并发:闸在 `take()` **之前**,认领落进 VM ⇒ 重建不会再取。
5. 发送后渲染:含 `<p>`/`<action>`/星号时按既有口径走;新增 **12 条跨层串测**(`ShareTextPipelineTest.kt`)——**此前跨层接缝完全空缺,而事故恰恰出在接缝上**。
6. 通知/服务:与 `ProactiveFrameIntake`/`KeepAliveService` 无任何调用关系。
**顺带加固一处**:`shareConfirmWindowCap` 加 `takeIf { value.isFinite() }` —— 把 W58 崩过两次的那条不变量从「靠调用方好习惯」变成**由唯一出口自己保证**(今天不可触发,是防御性收紧)。
**三处如实登记、未改的缺口**(都留给主人定):
- ⚠️ **`domain/parse` 有一处 O(N²)**:每个落单 `<p>` 都调 `hasContentAfter` 扫到串尾 ⇒ 实测 1000→189ms、4000→1107ms、**16000 个 → 17.9 秒**(**跑在主线程**的 `MessageBubble` `remember` 里)。触发需要「上千个落单 `<p>`」的文本(粘贴 HTML 残骸可能),网易云不是这个形状。修它要动 `domain/parse` 既有口径。
- ⚠️ **只有 `EXTRA_SUBJECT`/`EXTRA_TITLE` 而 `EXTRA_TEXT` 为空的分享会静默**(「点分享什么都没发生」)。**可达性未证实**,故只报不改。
- ⚠️ **面板预览不走解析器、气泡走** ⇒ `"<p><p>正文</p>"` 预览里是一串标签、气泡里只剩「正文」(不一致,改它会动已定稿的预览口径)。
### 5. W62:语音消息接收侧(`2fbf24a` → 合并 `9ced744`)—— **已真机验收通过**
契约三条硬纪律(`Docs/api-client.md` §4.4 / §6):**判据 = `voice` 键存在**(绝不从 `content` 认)、**按 `status` 分支**(`transcribed`/`empty`/`failed`)、**`name`/`mime`/`size`/`url` 四者同生共死**;`durationMs` **只有渠道如实给出时才有(绝不估算)**。🔴 **`voice` 只在 4 个 REST 读取面填充** ⇒ **实时帧上不要等 `voice`**(落库帧在「落库那一刻」发,而 voice 列是**落库之后**回填的)。
**实现**:`VoiceDto` + `MediaApi`(`@Streaming @GET @Url`)+ `MediaRepository`/`VoiceStore`(Bearer + 缓存到 `<cacheDir>/media/<name>`、原子落盘、**刻意不落 `documents/`** 以免扩大 FileProvider 暴露面)+ `domain/chat/VoiceMessage.kt`(判据唯一落点)+ `VoicePlayback.kt`(纯状态机)+ `VoicePlayer.kt`(**`android.media.MediaPlayer`,零新依赖**)+ `VoiceBubble.kt`。播放音源是**本地缓存文件**(不放网络流,因此不必把 Bearer 塞进播放器)。生命周期三处都收(点另一条 / 换会话 / 组件销毁)。**55 条新单测**(含 `MediaRepositoryTest` 的**线级**用例:真 OkHttp + 真 JDK HttpServer,断言 `Authorization: Bearer` 与 404/416 行为)。
**真机验收(假后端夹具,`emulator-5554`)**:
| 夹具 | 屏幕 |
|---|---|
| `transcribed` + 副本 + 2s | ▶ 语音 0:02 + 转写正文 |
| `empty` + 副本 | ▶ 语音 0:02 + 正文(非空不补话) |
| `failed` + 副本 | ▶ 语音 0:02 + **服务端那行提示原样** |
| **无副本** | 「语音 0:02」,**没有播放键** ✓ |
| **无 `durationMs`** | ▶ 语音,**不显示时长** ✓ |
**播放出声**(logcat 硬证据):`NuPlayerDriver created, clientPid(9122)` → `MediaCodec async mode` → `onAudioDeviceUpdate deviceIds: 2` → `AudioTrackShared: releaseBuffer`,**无 error**;点击后按钮变 `||` ✓。
**⚠️ 一条流程教训(我自己犯的)**:第一次验收时语音气泡**没出现**,原因是我**只合并推送、忘了重新装包** —— 模拟器上还是 W58 时期的 APK。**「合并」不等于「设备上是新包」**:验收前先确认装的 APK 是哪次构建的。
### 6. 环境(收工状态)
- HEAD = **`3d8bc61`** = `origin/android`;worktree / 分支 / 构建锁**全清**;主仓唯一脏 = `devdocs/02`(后端在途 + 我的登记与四问)。
- 模拟器装的**是最新包**(含 W58~W62);App 已重启并连回**真后端**;`adb reverse`:`tcp:80 → 10942`(真后端)、`tcp:10942 → 10942`。
- 假后端 18080 进程**仍在跑**(带语音夹具;在后台任务里,到点会被系统收)。
## ★★ 锚点补记四十一 · 通知横幅 + 免打扰开关 + W63~W66 四批落地(2026-10-07 下午)
### 1. 主人报的问题:「我在其他 APP 时没看到横幅」
**根因**:「她主动说的话」那条渠道的重要级是 **`IMPORTANCE_DEFAULT`(3)** —— 而**横幅(heads-up)要求 `HIGH`(4)**。代码注释里当初是**刻意**选 DEFAULT 的(「HIGH 会弹横幅盖住用户正在做的事,而这句话并没有急到那个程度」)⇒ 主人明确要横幅,即**口径变更**。
**修法(关键约束)**:🔴 **渠道的重要级一旦创建就改不了** —— 只有**用户**能在系统设置里改,App 改不动。所以光改常量对已建过渠道的设备**无效**,必须**换一个新渠道 id**:
- `PROACTIVE_CHANNEL_ID` = **`proactive_v2`**(`IMPORTANCE_HIGH`);
- 新增 `LEGACY_PROACTIVE_CHANNEL_ID = "proactive"`,建渠道时 `deleteNotificationChannel` 掉(重要级改不动,留着只会让系统设置里多一条永不出现的条目);
- `.setPriority(PRIORITY_HIGH)`(管 Android 7.x 及更早,那边还没渠道)。
**真机验收(`emulator-5554` + 假后端)**:注入一条 `proactive_message`,屏幕顶部弹出横幅「昔涟 · 现在 / 横幅验收:她主动说了一句」;`dumpsys notification` 显示 `channel=proactive_v2 importance=4` ✓
### 2. 主人要的「免打扰」开关(W66,`0e2df11` → 合并 `3b9936e`)
需求原话:「**开启时弹通知不弹横幅,关闭时两者都弹,默认都弹**」。
**为什么只能是「两条渠道 + 切 id」**:同上 —— 重要级改不了 ⇒ 运行时无法开关横幅。**不能**用 `setSilent(true)`(那是完全静音,语义不是「只关横幅」)。
| | 弹横幅那档(`quiet=false`,**默认**) | 免打扰那档(`quiet=true`) |
|---|---|---|
| 渠道 id | `proactive_v2` | `proactive_quiet` |
| 重要级 | `IMPORTANCE_HIGH`(4) ⇒ 横幅 | `IMPORTANCE_DEFAULT`(3) ⇒ 提示音、**不弹横幅** |
| 渠道名 | 她主动说的话 | 她主动说的话(免打扰:只在通知栏) |
- 判据纯函数 `proactiveChannelSpec(quiet)`(id/名/重要级/描述**成对给全**,防「换了 id 没换重要级」)+ `proactiveChannelId(quiet)`;`quiet` **刻意不给默认值**(让编译器拦住「忘了传」)。
- **惰性创建**:只 ensure「这一次要用的那一条」(两条都建 ⇒ 从没开过免打扰的用户会平白多一条没见过的渠道);**另一条不删**(用户可能对它调过设置);只删那条再也发不到的 legacy `proactive`。
- 设置项存进既有的 `AppPrefs`(键 `proactive_quiet`,**默认 false**),UI 在设置页新增的「**通知**」段(`PreferenceCard` 外包 `SwitchPreference`),文案写明「**不是静音**」。发通知那一刻现读热副本 ⇒ **开关一改,下一条就生效**(不必重启)。
- ⚠️ 如实登记的毫秒级窗口:冷启动首读盘未回时读到默认 `false`,代价封顶是「多看到一次横幅」,不丢通知。
- 全量 **123 suites / 1481 tests**(+2 suite / +8 tests)。
**真机验收**:设置页点开该开关(副文案从「**关(默认)**:她主…」变成「**已开:她主动找你说话只在通知栏提…**」);回后台再注入一条 ⇒ **屏幕顶部没有横幅**(截图:桌面干净),`dumpsys` 显示那条走的是 `channel=proactive_quiet importance=3`,通知栏里条目仍在、有提示音 ✓
### 3. W63:解析器去二次方(`0e834fc` → 合并 `3b9936e`)
审查只归因了一处,子代理**找出两处**(都在 `domain/parse/ChatContentParser.kt`):
1. `hasContentAfter` 每个落单 `<p>` 现扫到串尾(约占 23s);
2. **`tokenize` 里 `P_BLOCK_RE.findAll(raw)` 的懒惰回溯**(约占 8.2s)—— 只修第 1 处时,20000 个落单 `<p>` 仍是 **8308ms**。
**它还推翻了我给的建议方案**:我建议「记下最后一个有效内容的下标」,但答案**对起点不单调**(`"<p>"` 上 `hasContentAfter(0)=false` 而 `(1)=true` —— 起点落在标签内部也算有正文)⇒ 改成**逐位置答案表**(`ContentAfterQuery`,惰性建表,常规消息一个字节都不建)+ 对 `tokenize` 做「先切掉最后一个 `</p>` 之后」的前缀优化。
**实测(JVM)**:`parse("<p>".repeat(N))`:**2000 → 5ms**(原 854)、**8000 → 12ms**(原 4254)、**20000 → 17ms**(原 26405)。`ChatContentParserTest` 套件从 31.5s 降到 0.15s。全量 **121 suites / 1473 tests**(+2)。
**留了一处同类平方(如实登记、未修)**:`"<p ".repeat(N) + "</p>"`(`<p` 后一路无 `>`、末尾只有一个 `</p>`)仍走 `P_BLOCK_RE` 最坏路径,**改前改后一样**(20000 → 7.2s)。治它要手写线性匹配,会牵动气泡切分核心语义,超出「纯性能、语义不变」的范围 ⇒ **要不要另开切片等主人定**。
### 4. W64:分享回落链(`4fe5509` → 合并 `3b9936e`)
修「只有 `EXTRA_SUBJECT`/`EXTRA_TITLE` 的分享**静默**」:**回落链** `EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`(取第一个非空白);三者都空/读不到 ⇒ 走**既有**「读不进来」路径(不再静默);「连值都没有」(误触分享)照旧静默。
- 来源如实登记(`SharedContent.textSource`),用户看到一句「**这段文字来自对方的分享标题(对方没有提供正文)**」—— 走认领那一刻的**柔和横幅**,**绝不进正文**(有单测钉住 `shareConfirmBody` 逐字等于正文)。
- 防御式读取**自己核过 SDK 源码**:`BaseBundle.getCharSequence` 自己 catch `ClassCastException` 回 null;会抛的是内部的 `unparcel()` → `BadParcelableException` ⇒ 用「读原始值 + `as?` + `runCatching`」两条都挡。
- **子代理自己在设备上跑过** 4 条 fixture(只有 SUBJECT / 只有 TITLE / `--ei TEXT 42`(有值但类型读不出,今天=毫无反应)/ TEXT+SUBJECT 同时给 ⇒ TEXT 赢且不出提示),并截图确认。
- 全量 **123 suites / 1494 tests**(+2 suite / +23 tests)。
- ⚠️ 两条可选改进(**等主人点头**):① 把来源提示画进**面板内部**(Miuix `WindowDialog` 有 `summary` 参数,现调用点没传 ⇒ 加一行即可);② 让「读不进来」那句在**面板一开**就说(现在是发送时才说)。
### 5. W65:分享预览与气泡一致(`cdd58fa` → 合并 `3b9936e`)
让预览**走同一套 `parse()`**(多段成块、`<action>`/`<narration>` 变灰斜体),做到「**所见即所发**」;**超窗判据重做**为「内容自然高(`onSizeChanged` 挂在 `verticalScroll` 里面读到的、不受这一窗上限影响的高)vs 窗口高(量到的行高 × 3)」,**两边都是这一帧真实排出的像素**(旧判据「行数 > 3」在多块之后覆盖不住窗口的量)。
- **逐个核过每个 `verticalScroll` 前面的有限约束**(这条必须有,见锚点三十八):外层是 `sharePreviewMaxHeight(textExpanded)`(150/240dp 字面量);正文窗收起态是 `shareConfirmWindowCap(cap)`(唯一出口、恒有限),展开态**上限与滚动一起去掉** ⇒ 「无界高度 + 滚动」两种状态都不成立。
- 推翻了 W61 那条钉住「预览 trim、气泡解析」分工的断言(口径变更,改成新口径并写明理由),其余断言未放松。
- ⚠️ **待主人真机核对**(子代理只跑了单测):① 含 `<p>` 的分享 → 预览里**看不到尖括号**且与气泡字一致;② `<p>甲</p><p>乙</p><p>丙</p>` → 预览**三块分开**;③ `<action>看着窗外</action>` → 预览是灰斜体一行;④ 短分享**没有**「展开」;⑤ 面板打开/展开/收起三下**不崩**。
- 它也如实登记:`onSizeChanged` 挂在 scroll 里面能读到「自然高度」这条是**按 Compose 内部实现推的**,若推错,症状是「展开」控件永不出现(②④两条真机现象就是在核它)。
### 6. ★ 国产 ROM 上「横幅」还需要什么(回答主人的第二个问题)
**AOSP 层面(我们已经做对了)**:横幅的门槛就是**渠道重要级 = HIGH**(+ 7.x 的 `setPriority`、`CATEGORY_MESSAGE` 已在)。**不需要**「额外声明消息类型」—— Android 8+ 的**渠道本身就是那个声明**。
**国产 ROM 层面(App 做不到,只能引导用户去系统里开)**:
1. **该 App 的「横幅 / 悬浮通知」开关** —— MIUI/HyperOS、ColorOS 等在**渠道设置**里另有一个独立的横幅开关,默认可能关着;
2. **自启动 / 后台运行白名单 + 省电策略「无限制」** —— 这条管的不是横幅,是**进程能不能活着**(进程没了就收不到帧,也就没有通知);
3. **「后台弹出界面」权限**(MIUI/ColorOS 有)—— 主要影响从后台**弹 Activity**,对横幅通常不必要,但有些 ROM 会连带拦截。
⚠️ **本条与「推送」的关系(值得主人知道)**:上面全是「**进程还活着**」前提下的横幅。要做到「App 被划掉/被系统杀掉之后**仍然**收到通知」,**必须接厂商推送通道**(小米/华为/OPPO/vivo 各自的 Push,且**服务端也要接**)—— 那是另一个量级的工程(主人此前说「推送我继续研究」)。
### 7. 环境
- HEAD = **`3b9936e`** = `origin/android`;worktree / 分支 / 构建锁**全清**;主仓唯一脏 = `devdocs/02`(后端在途 + 我的登记与四问)。
- 全量真跑:**125 suites / 1509 tests / 0 failures / 0 errors**。
- 模拟器装的**是最新包**(含 W58~W66);晚些已把 `tcp:80` 切回真后端。假后端 18080(带语音夹具 + `/__control/frame`)在后台任务里跑着。
## ★★ 锚点补记四十二 · 行内 HTML 标签渲染(W67)+ 真机验收(2026-10-07 下午)
### 1. 需求与**取证**(不凭猜设计范围)
主人原话:「我看到她在聊天消息中插入了 html/md 标签,能否增加对此类标签的基本渲染支持?」
**取证**(拉真后端最近 **100 条**,按一组模式扫):
- **命中只有 1 种**:`<b>` —— **2 条**。实例:`今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。`
- **0 命中**:`<strong>`/`<i>`/`<em>`/`<code>`/`<br>`/`<a href>`/`<ul>/<ol>/<li>`/`<h1-6>`,以及 Markdown 的 `**`/`__`/`` ` ``/`#`/`-`/`[链接](url)`/`>`/围栏/表格。
⇒ **不做 Markdown 引擎**(尤其 🔴 **`*` 系列不能碰**:`ROLE_RE` 里全角 `*…*` 与半角 `*关键词…*` **已经是「旁白/动作」语义**,有既有测试钉着)。
### 2. 做了什么(`d7d93b4` → 合并 `3d4a9f2`)
| 标签 | 渲染 |
|---|---|
| `<b>` / `<strong>` | 粗体 |
| `<i>` / `<em>` | 斜体 |
| `<code>` | 等宽 + 浅底(内部**不再解析** ⇒ `<code><b>甲</b></code>` 显示字面的 `<b>甲</b>`) |
| `<br>` / `<br/>` / `<br />` | 换行(自闭合、不入栈) |
**侵入最小的做法**:**没动 `ContentPart`**(加变体或给 `Text` 加字段会波及 6+ 个调用点,其中 `MessageEditText` / `SessionSearchModel` / `ProactiveNotify` 读的是 `parts` **原文** —— 在那儿改就是静默改用户内容);只在 `ParsedBubble` 上加一个**带默认值**的字段 `runs: List<InlineRun>`(`InlineRun(text, styles: Set<InlineStyle>)`,相邻同样式已合并)⇒ `plainBubble`、`ShareConfirmPanel:700`(红线)、三个测试文件里的手搓气泡**一行都不用改**。解析只在 `toBubble` 加**一行调用**(段落定稿之后的最后一步,不碰 `tokenize` / `stripLooseTags` / `P_BLOCK_RE` / `ROLE_RE`)。UI 只在 `BubbleBody` 加一条分支:`runs` 为空走原来的 `Text(text)`(绝大多数消息**逐像素不变**)。
**口径(6 条边界,各有单测)**:
| 边界 | 口径 |
|---|---|
| 未配对 | **整项字面**(`<b>没闭合` 原样显示);配对成功的另一半照常生效 |
| 嵌套 | 严格嵌套;`<b>甲<i>乙</b>丙</i>` 里 `</b>` 关不掉 `i` ⇒ 它连同 `<b>` 字面 |
| 尖括号 | 只有「配成对 + 正确嵌套」才被吃;`1 < 2`、`a<b`、`a<b>c`、`<b`、`<p2>x</p2>`、`<B>`、`<b class="x">` **全部原样** |
| `<br>` 与空行 | 就是**一个 `\n`**,不与相邻 `\n` 去重、**不分气泡**、两端不留空行 |
| `code` 内部 | 不再解析 |
| HTML 实体 | **不做**(`&amp;`/`&lt;`/`&nbsp;` 全原样;0 命中 + 实体是开放清单,做一半比不做更糟) |
**大小写只认小写** —— 理由很硬:流式**扣留**侧的标签名扫描只认 `a..z`,认大小写会让「扣留」与「消费」两边**分叉**(中间态把 `<B>` 当正文吐上屏、收口时再吃掉 ⇒ **屏幕上显示过的字会改口**)。
**两条自定契约**:① **整段只有标记没有字**(`<b></b>` / `<br>`)⇒ **回落字面原文**(绝不把非空正文渲染成空);② **流式把未闭合的行内开标签同样入栈扣留**(`TAG_NAMES` 补词表,`<br>` 不入栈)。
**测试**:新增 `InlineMarkupTest` **36 条**(含**星号反证** `starsKeepTheirExistingMeaningInsteadOfBecomingMarkdown`、流式逐字符 `streamingWithholdsUnclosedInlineTags`)。全量 **126 suites / 1545 tests**。
### 3. 真机验收(`emulator-5554` + 假后端灌样本,**不污染主人真实会话**)
| 输入 | 屏幕 |
|---|---|
| `甲<b>粗</b>乙<i>斜</i>丙<code>码</code>丁<br>戊` | 「甲**粗**乙*斜*丙`码`丁」+ **换行**「戊」,**标签全部消失** |
| `1 < 2` / `a<b` / `<b>没闭合` / `<p2>x</p2>` | **全部原样** |
| `*弯起眼睛*` / `*内心:…*` | 旁白行灰斜体(**既有星号语义没被动**) |
| `**不该加粗**` | **保持字面**(md 没被误渲染) |
| `日落 <b>18:35</b>,…` | 「日落 **18:35**,…」粗体生效、无标签 |
### 4. 如实登记:本批**故意不做**的(等主人拍板)
1. **`<a href>`**(要点击行为 + 安全策略:只允许 http/https、禁 `intent://`)—— 0 命中;
2. **Markdown 全部语法**(与星号语义冲突 / 0 命中);
3. **HTML 实体解码**;
4. **旁白/动作行不做行内解析**(`<action>笑<b>了</b></action>` 里的 `<b>` 照原样)—— 因为那一行与分享预览**同源**(都读 `parts` 原文),只改一侧会让 W65 刚统一的「预览 == 气泡」在旁白上**重新裂开**;
5. **三处仍显示标签的地方**(都读 `parts` 原文,且都在红线文件里):搜索片段(搜索本身没问题)、**通知文案**(`ProactiveNotify` 是红线)、编辑框(**这个是对的** —— 编辑必须看到原文,否则保存会丢标记);
6. **分享预览**:正文跟着受益(不再露标签,因为走同一个 `parse`),但那一栏是红线、一字未改 ⇒ 预览里**没有**粗体/等宽底色,只有文字;
7. **`MessageEditText.KNOWN_TAG_RE` 没改**(口径「只认解析器真正认识的标签」现在与 `parse` 有了一点偏差);
8. **乐观气泡**(`PendingBubble` 走 `plainBubble`)原样显示,落库后才渲染。
### 5. 给后端登记了一问(`devdocs/02`,未提交)
**`<b>` 是产出侧有意的吗?契约要不要承认这一小撮行内标签?以及 Web / IM 出站是否也渲染它们** —— 特别是 **IM 出站**(`channelbridge/sanitize.go` 的 `splitBubbles`):若原样发到微信/QQ,对方看到的就是字面标签,**口径最好对齐**。
### 6. 环境
- HEAD = **`3d4a9f2`** = `origin/android`;worktree / 分支 / 构建锁**全清**;主仓唯一脏 = `devdocs/02`。
- 模拟器装最新包(含 W58~W67),`tcp:80` 已切回**真后端**。