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

1377 lines
145 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`。
>
> **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 代码)⇒ **那条建议目前不产生任何效果**,别当兜底。