# 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 端标记** (`

` / `` / ``,甚至有记录以一串空 `

` 开头)。 渲染前必须过 `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 ``` - **改动前的基线**(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>."}]`,**≤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 消息,**零条含标记**;所有 `

` / `` 都在 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 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 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 上午 · **文档已拆分** + `

` 规整上线 + 三件待办) > > ### ⚠️ 先看这条:`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. **`

` 规整已落地并上线**(后端 `5de708a`):照我们的裁定走 —— **写入侧**收口、**幂等守门断言**、 > 覆盖 **Web 与 IM 两条落库路径**(渲染口径不分叉)。**我方零改动。** > > ### ✅ 已拍板(主人 2026-10-05):历史回填 **做** > **决定:做**(主人「按最优解」)——三条约束见 `02-前后端协同事项.md` 末尾「历史回填」一节: > **① 先 dry-run 只统计不改(量大异常先对齐)② 留回滚点 ③ 一次性、不留常驻逻辑**。 > 范围严格限定为「只补 `

`」,不碰 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`,历史遗留与跨壳应用常见),**只接受 `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 ,`: > - 界面出现告知 **「有 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`**;输入栏上方的预览从一枚键变成**每张各自一枚移除键**(顺序即发送顺序);图片入口在**攒满 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:///api/v1/chat (NNN-byte body) > <-- 200 OK http:///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= socket= mirror= wsObject= route= reason= outcome= fromQueue=` > - `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=`,它的 `/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//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//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]` ⇒ **来源是源码**。 - **构建锁协议写死**(这批真踩到:有切片**整文件删除**,把别人的占用行一起删了)。三条约定:① **一行一个占用者**(`<分支名> <时间>`);② **只删自己那一行,绝不整文件删除**;③ **改/删之前先读一遍**当前有谁在占。 ### 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

-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① 「标签解析」——根因、证据、修复、验收(**这是主人报的那条**) **现象**:气泡正文里**字面显示** `

那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?`。 **根因(服务端产出的 markup 畸形)**:从真后端拉到的那条消息 `content` 逐字节是 ```

满意地点点头

\n

真乖♪ …放心的一句。

\n

那现在——…发会儿呆?

``` 第三段 = **`

` 两个开标签、一个 `

`**。客户端 `ChatContentParser.kt:70` 的 `P_BLOCK_RE` 是**非贪婪**的,必然把内层裸 `

` 收进本段正文,于是标签被当正文渲染。 **修(客户端容错,`464e916`)**:只剥**完整匹配标签正则**的落单 `

` / `

` / `

`(有完整块时也剥块外未认领的旁白标签);`1 < 2`、`<3`、`a`、`

`(大写,有既有反契约)、残缺 `

…` 这种扣留点是 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` 保留懒性——**没做**,因为那会让「订阅时刻」与「连接建出时刻」分开,多一处要解释。 **另一处必要项**:`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,不是有意形状**(契约里 `

` 就是「一段」,没有嵌套语义)。 - 问 2 ⇒ **认下,已修完推库 `8051906`**(`fix(turnreply): 产出侧不再产出未配对的段落标签`)。根因**两半且在同一个文件**:① `paragraph.go` 的段块正则**非贪婪** ⇒ 把 `

X

` 当成「已完整包裹」逐字保留,**连护栏都用同一个非贪婪正则**(护栏是盲的);② `normalizePiece` 对「未闭合的段首 `

`」**无条件再套一层** —— 他们用我们上报的那条串**逐字节复现**了。③ 顺带挖出幂等漏洞(落单 `

` 越跑越多)。 - ⚠️ **未部署**:跑着的后端是旧产物 ⇒ **畸形仍会产出**,要我们**继续保留容错**(我们已保留)。历史畸形只 **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. 空/边界输入:空串/空白/`<`/`

`/半个代理对/`\u0000`/10 万字符 —— **全不抛**。 3. 面板渲染:面板里**只有两个 `verticalScroll`**,各自前面都有一层**有限** `heightIn`;**反编译 miuix-ui-0.9.3 全部 545 个 class**,`WindowDialog`/`DialogLayout`/`TextField` **一个 scroll 节点都没有** ⇒ 「无界高度 + 滚动」在面板里不可能成立。 4. 出队闸门与并发:闸在 `take()` **之前**,认领落进 VM ⇒ 重建不会再取。 5. 发送后渲染:含 `

`/``/星号时按既有口径走;新增 **12 条跨层串测**(`ShareTextPipelineTest.kt`)——**此前跨层接缝完全空缺,而事故恰恰出在接缝上**。 6. 通知/服务:与 `ProactiveFrameIntake`/`KeepAliveService` 无任何调用关系。 **顺带加固一处**:`shareConfirmWindowCap` 加 `takeIf { value.isFinite() }` —— 把 W58 崩过两次的那条不变量从「靠调用方好习惯」变成**由唯一出口自己保证**(今天不可触发,是防御性收紧)。 **三处如实登记、未改的缺口**(都留给主人定): - ⚠️ **`domain/parse` 有一处 O(N²)**:每个落单 `

` 都调 `hasContentAfter` 扫到串尾 ⇒ 实测 1000→189ms、4000→1107ms、**16000 个 → 17.9 秒**(**跑在主线程**的 `MessageBubble` `remember` 里)。触发需要「上千个落单 `

`」的文本(粘贴 HTML 残骸可能),网易云不是这个形状。修它要动 `domain/parse` 既有口径。 - ⚠️ **只有 `EXTRA_SUBJECT`/`EXTRA_TITLE` 而 `EXTRA_TEXT` 为空的分享会静默**(「点分享什么都没发生」)。**可达性未证实**,故只报不改。 - ⚠️ **面板预览不走解析器、气泡走** ⇒ `"

正文

"` 预览里是一串标签、气泡里只剩「正文」(不一致,改它会动已定稿的预览口径)。 ### 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 + 缓存到 `/media/`、原子落盘、**刻意不落 `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` 每个落单 `

` 现扫到串尾(约占 23s); 2. **`tokenize` 里 `P_BLOCK_RE.findAll(raw)` 的懒惰回溯**(约占 8.2s)—— 只修第 1 处时,20000 个落单 `

` 仍是 **8308ms**。 **它还推翻了我给的建议方案**:我建议「记下最后一个有效内容的下标」,但答案**对起点不单调**(`"

"` 上 `hasContentAfter(0)=false` 而 `(1)=true` —— 起点落在标签内部也算有正文)⇒ 改成**逐位置答案表**(`ContentAfterQuery`,惰性建表,常规消息一个字节都不建)+ 对 `tokenize` 做「先切掉最后一个 `

` 之后」的前缀优化。 **实测(JVM)**:`parse("

".repeat(N))`:**2000 → 5ms**(原 854)、**8000 → 12ms**(原 4254)、**20000 → 17ms**(原 26405)。`ChatContentParserTest` 套件从 31.5s 降到 0.15s。全量 **121 suites / 1473 tests**(+2)。 **留了一处同类平方(如实登记、未修)**:`"

"`(``、末尾只有一个 `

`)仍走 `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()`**(多段成块、``/`` 变灰斜体),做到「**所见即所发**」;**超窗判据重做**为「内容自然高(`onSizeChanged` 挂在 `verticalScroll` 里面读到的、不受这一窗上限影响的高)vs 窗口高(量到的行高 × 3)」,**两边都是这一帧真实排出的像素**(旧判据「行数 > 3」在多块之后覆盖不住窗口的量)。 - **逐个核过每个 `verticalScroll` 前面的有限约束**(这条必须有,见锚点三十八):外层是 `sharePreviewMaxHeight(textExpanded)`(150/240dp 字面量);正文窗收起态是 `shareConfirmWindowCap(cap)`(唯一出口、恒有限),展开态**上限与滚动一起去掉** ⇒ 「无界高度 + 滚动」两种状态都不成立。 - 推翻了 W61 那条钉住「预览 trim、气泡解析」分工的断言(口径变更,改成新口径并写明理由),其余断言未放松。 - ⚠️ **待主人真机核对**(子代理只跑了单测):① 含 `

` 的分享 → 预览里**看不到尖括号**且与气泡字一致;② `

甲

乙

丙

` → 预览**三块分开**;③ `看着窗外` → 预览是灰斜体一行;④ 短分享**没有**「展开」;⑤ 面板打开/展开/收起三下**不崩**。 - 它也如实登记:`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 种**:`` —— **2 条**。实例:`今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。` - **0 命中**:``/``/``/``/`
`/``/`