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

245 KiB
Raw Blame History

05 — 开发进度与交接

本文用于跨会话交接:记录当前状态、构建管线、环境要点、易忘的实现约定。 最后更新:2026-10-04

当前状态

  • 分支 android,最新提交 664fd8d(= origin/android,全部已推)

构建(已全部在 PC 上)

开发已迁到 PC(Meowhome),编译就在本地跑:

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 上,主人明确说可以直接抓模拟器窗口。

所以现在允许也应该自己截图验证,方法两条一起用:

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}。

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 撑住才收得到响应体,否则只出一堆空行:

"$ADB" shell "(printf 'GET /api/v1/meta/enums HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n'; sleep 4) | toybox nc 127.0.0.1 10942"

签名坑:模拟器里原本装着一个 2026-05-26 的旧 top.yeij.cyrene,与本机 debug keystore 签名不匹配 → INSTALL_FAILED_UPDATE_INCOMPATIBLE。必须先 adb uninstall top.yeij.cyrene 再装。

时钟提醒:模拟器时钟比宿主机慢约 8 小时(约等于 UTC)。界面里 at 显示的「本地时间」 是模拟器时区格式化的结果,不是转换错误。

易忘的实现约定

  • 包名 top.yeij.cyrene,应用名 Cyrene
  • 版本:AGP 9.2.1(内置 Kotlin,不能再应用 org.jetbrains.kotlin.android)/ Kotlin 2.4.0 / Compose BOM 2026.05.01 / Miuix 0.9.3 / Gradle 9.7.1 / compileSdk 37 / minSdk 33 / buildToolsVersion = "37.0.0"
  • 基础图标是 MiuixIcons.Basic.Xxx(文档写 MiuixIcons.Xxx 是错的,以 AAR 为准);部分图标在 icon.extended.* 下、按 MiuixIcons.Xxx 访问(如 MiuixIcons.Add / MiuixIcons.Delete)——加图标前先看现有 import,别猜
  • 列表项一律用 BasicComponent(Miuix 所有 Preference 的底座,MD3 圆角卡片)——但它本身没有背景和圆角,必须外面包一层 ui/common/PreferenceCard.kt(surfaceContainer + 16dp 圆角);页面底色是 surface,两者在浅/深色下都能区分
  • 本项目没有导航库(无 NavHost、无 Navigation Compose)。路由是 ChatHomeScreen.kt 里手写的布尔状态 + BackHandler 分层。加新页面照这个模式来,别引入导航库
  • 输入框在 Scaffold.bottomBar + imePadding();Manifest 必须 windowSoftInputMode="adjustResize"(否则 IME 高度被算两次,输入框会被顶到顶部)
  • 系统栏图标明暗在 app/CyreneRoot.kt 里随主题切换(WindowInsetsControllerCompat)
  • 返回层级:管理页→聊天、设置页→聊天、抽屉打开→先关抽屉(ChatHomeScreen 里的 BackHandler),都关着才回桌面
  • 聊天列表 reverseLayout + followBottom 逻辑(只在滚动结束时更新跟随状态)才能「新消息自动置底、用户上滑时不打扰」
  • 文本选择用 SelectionContainer 逐块包裹(气泡/旁白/思维卡/工具卡各自独立,「全选」不跨块)
  • Retrofit 3 的 kotlinx converter:Kotlin 侧必须用扩展函数 json.asConverterFactory(mediaType)(那个 KotlinSerializationConverterFactory 只是 JVM facade)
  • 明文 HTTP 已放行(res/xml/network_security_config.xml),否则自部署地址会报「网络不可用」
  • DTO 不用 enum:mode / scope / kind / status / role / platform / mood 在后端都是开放集合,一律 String + 客户端映射,未知值原样显示(不丢弃/不报错/不占位符)
  • 服务端时间有两种形状,不能共用一个解析器:RFC3339 字符串(trace / audit)与 Unix 毫秒(backthink.at)
  • auth 的 expiresAt 是 Unix 秒(不是毫秒)——AuthManager 里已经 * 1000 再比,别改错方向
  • 别假设「文本字段就是纯文本」:后端的 backthink.conclusion 存的是Web 端标记 (<p> / <action> / <narration>,甚至有记录以一串空 <p></p> 开头)。 渲染前必须过 domain/parse/ChatContentParser.parse()——项目里就这么一处标记解析器, 不要另写正则。判断依据:只要字段可能来自 Web 渲染链路,就先 parse() 再渲染

管理区(C-2)的约定

  • 权限真源是 domain/admin/AdminAccess.kt 的纯函数(AdminRole / AdminTier / AdminPage / canAccess)。加一个管理页 = 在 AdminPage 里加一条枚举,入口页、ViewModel、标题都按它驱动
  • 角色取自 container.authManager.state 的 SessionState.LoggedIn(baseUrl, role)
  • 后端明确要求客户端自行降级(Cyrene_v2/Docs/api-client.md:「客户端需按本矩阵自行降级」)。 本端做三层 fail-closed:入口页禁用 / ViewModel 短路(不发请求)/ 服务端 403 也渲染「无权限」态。 任何失败都不得渲染成「暂无数据」——空态只在请求真的成功且列表为空时出现
  • RBAC 分档(后端 api-client.md §2.0,与 internal/services/gateway/routes.go 逐条一致):
    • viewer 可读(viewerAuth):GET /admin/personas、/admin/observability/metrics、/admin/observability/trace 等 12 条只读监控档
    • 仅 owner/admin:/admin/memories、/admin/backthink、/admin/users、/admin/llm、/admin/observability/audit 等 9 条
    • 仅 owner:35 条写操作 + 凭据路径
  • 未知角色按 viewer 降级(fail-closed),角色名显示原始字符串
  • 后端路线:手机端后续要做「全功能管理」(Cyrene_v2/DevDocs/工作小结.md 主线②), 其凭据前置(viewerAuth 接线、契约 v0.40)已完成——所以管理区要做成可加页的壳,别写成只读死胡同

C-2 的验证结论(2026-10-04)

已实跑通过,不再是「只编译过」。证据:

  • 4 页均由 App 真实取数成功(logcat 里 GET /api/v1/admin/... 全 200,含响应字节数), FATAL EXCEPTION 计数 0
  • 5 个端点的真实响应体已作为夹具进 AdminDtoRealPayloadTest,全部一次解出,无需改动生产代码
  • 截图留档(可自行复核):app/build/e2e-shots/
  • 未覆盖:角色降级路径(手上只有 owner 账号,viewer/admin 的 403 分支、禁用行、 AdminFailure.Forbidden 只被单测覆盖);刷新、错误重试态、空列表态;系统返回键

坑与勘误(2026-10-04 汇总)

客户端结构陷阱(改代码前先知道)

  • 每个 ChatScreen 的 VM 各建一条 ChatSocket(ChatScreen 用 viewModel(key = sessionId, …), VM 内 AppContainer.createChatSocket())。切会话后旧 socket 与旧 VM 仍然活着、照收全量广播 ——而广播是发给每条连接、没有按会话订阅。所以任何「只看帧内容就渲染」的代码都会串台。 凡流式帧,一律先按 sessionId 过滤。(2a49c25 修的是 history 那一条)
  • 结构化流帧本身就带 sessionId + turnId(后端 v0.36 起)。历史上本端 WsEvent.parse 把这两个字段丢了,才让 A1/D14 挂了很久——别再把「流式帧不带 sessionId」当事实。
  • 乐观消息归一化的真实规则是「待发列表里第一条文本完全相等」(ChatViewModel.onPersisted), 没有 role 判断、没有时间窗(03-客户端细化开发方案.md:57 的描述与代码不符,以代码为准)。
  • WS 帧超过 4096 字节不是报错,是直接断连(ReadMessage 出错 → readPump 退出 → 关连接重连)。 所以永远不要把长文本 / base64 图片塞进 WS 帧。
  • POST /chat 的 stream:true 走有损总线、且先回 200 再问 orchestrator → 200 不等于已受理,要以 message_persisted / session_busy 是否到达为准;全链路无幂等, 绝不自动重试。见 02/G2、02/G4。
  • base64 图片会被原样落库,于是每页历史都把它再传一遍 → 发图前先缩放 / 重编码。见 02/G3。
  • 本地快照一律带 baseUrl|username 指纹闸门:读侧过滤、登出清空(EnumPreferences / SessionPreferences 两个实现已对齐)。新加 last-good 快照时照这个来,否则换账号后会闪上个账号的数据。

工作流教训

  • 子代理的自述不是证据,必须自己复核。 实例:一个子代理报「没有任何凭据写进文件」, 而 EnumPrefsTest.kt 里有真实账号名(已改成中性夹具)。每个模块都要自己 grep + 直接读 JUnit XML。
  • 编译 + 单测全绿也可能功能静默失效,截图能抓到。 实例:抽屉「打开时刷新」第一次错误地挂在 onOpenChange 上,而 MiuixDrawer 只在关闭时回调它——功能完全没生效,是截图发现的。 这正是 2026-10-04 放开「允许自己截图验证」的直接价值。
  • 看像素能抓到断言抓不到的排版问题。 实例:预览剥标签时把旁白与正文粘成一个词 (…凑近了一点今天下午…),单测全绿,是看截图才发现的。
  • 别给要常驻的进程设超时。 实例:模拟器以 1800s 上限后台启动,到点被宿主收割, 连带 adb reverse 隧道一起没了。模拟器不加超时。

文档勘误(已被实测推翻,别再引用)

文档 写的 实际
02/A1 正文、03:114-115(D14) 流式帧不带 sessionId 后端 v0.36 起带 sessionId+turnId;是本端解析器丢了字段(见上「结构陷阱」)
03:57 乐观消息按「同 role + 时间窗 + 文本匹配」归一 实际只在待发列表里按文本完全相等匹配,无 role、无时间窗
Cyrene_v2/Docs/api-reference.md:1142(§6.6) OnStream 结构只填 seq、不带 turnId 实际已打 TurnID 并上帧;api-client.md:525(v0.36)记的就是这事
Cyrene_v2/DevDocs/02-development-constraints.md:159 文件上传限 50MB 实现是硬 5 MiB(upload.go:24),无配置项可覆盖(见 02/F3)

worktree 并行:约定与坑(下个会话必读)

本轮新增约定 / 坑(下个会话必读)

  • worktree 并行必须给绝对路径:W1 曾用相对路径编辑,结果写进了主树(10 分钟), 靠「worktree 构建在改动后仍报 UP-TO-DATE」这个反常信号才发现。派 agent 进 worktree 时, 任务书里必须写明「只用绝对路径」。(当次已自检+窄回退,我复核主树逐字节等于 HEAD,无损伤。)
  • 删 worktree 目录别用 git 自带的:Windows 会报 Filename too long(MAX_PATH,Gradle 的 build/ 路径太深)⇒ 用 Git Bash 的 rm -rf,它能处理。
  • /upload 产物的 URL 绑死当时的 host(r.Host 拼绝对地址 + 原样落库)⇒ 换服务器地址后旧图取不回。 已登记 02/F6①。上传也不能突破 5 MiB(02/F6②:那个上限只对 base64 形态成立,URL 受视觉层 50 MiB 约束)。
  • intent 共享 socket 债又加一条:SessionsViewModel 现在自己持有一条 socket(登录连、登出/ onCleared 关)——因为 AppContainer/ChatSocket 归属别的 agent。收敛方向仍是**一条应用级共享 socket
    • 按会话路由**。
  • chat.documents 契约形状(v0.59,别提前写死):数组 ≤5(不是单对象 —— 单对象会让 1 回合 5 个附件 里 4 个从历史消失);响应/历史 DTO 为 "documents":[{name,mime,size,chars,truncated,status}]; status:"unreadable" 才是「昔涟读不到」的信号;文档一律走 REST;只收服务端存储名、不收 URL。

派 agent 进 worktree 的硬规矩

派 agent 进 worktree 的硬规矩(沿用上轮教训):任务书必须写明「只用绝对路径」(上轮有 agent 用 相对路径把改动写进了主树);不许碰 adb/模拟器、不许 push/merge、不许动构建文件 (build.gradle.kts / settings.gradle.kts / libs.versions.toml / local.properties)。

后端协同

见 02-前后端协同事项.md。开发中一旦遇到后端不支持或未实现,当场写进该文件并反馈主人。

当前状态(2026-10-04):02 的 A/B 两档绝大多数已闭环——B4(枚举权威表)与 B2(token refresh) 本轮由客户端接入,A4/D5/B3(会话标题 / 置顶 / 预览)也已接入,A1 已由后端修复、约束只剩客户端一份 (见上方「文档勘误」)。尚未闭环的高优先项:F3(上传图片的 URL 在自部署链路取不到 → B1 的大图场景疑似不通)、G2(stream:true 可能静默不生效)、G4(全链路无幂等)。

实机验收方法(2026-10-04 深夜晚补,含本轮踩到的坑)

图片/大图路径的验收脚本(已跑通两遍,可直接复用):

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 一次):

SID=$(curl -s -X POST $BASE/api/v1/sessions -H "Authorization: Bearer $TOK" \
      -H 'Content-Type: application/json' -d '{}' \
      | /c/Python312/python -c "import sys,json;print(json.load(sys.stdin)['data']['sessionId'])")  # ← 键是 sessionId
curl -s -X PATCH "$BASE/api/v1/sessions/$SID" -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' -d '{"title":"E2E-XXX"}'      # 抽屉里就能按标题定位行
  • 本机没有 jq(which jq 为空)→ 解析 JSON 用 /c/Python312/python。
  • 驱动 UI 用 uiautomator dump + tr -d '\r' | sed 's/></>\n</g' 抽 text / content-desc 与 bounds; 像素截图只用来判断观感(本轮就是靠截图发现「缩略图里其实是『App 设置』页」这个关键事实的)。

从「另一个 App」触发分享的验收手段(不需要装第二个 App):

"$ADB" shell am start -a android.intent.action.SEND -t text/plain \
    --es android.intent.extra.TEXT "分享测试"
"$ADB" shell content query --uri content://media/external/images/media \
    --projection _id:_display_name --where "_display_name='cyrene-bigtest.png'"    # 取 media id
"$ADB" shell am start -a android.intent.action.SEND -t image/png -f 0x00000001 \
    --eu android.intent.extra.STREAM content://media/external/images/media/<id>
  • 改动前的基线(2026-10-04 实测):am start -a …SEND -t text/plain 起的是系统 resolver, Cyrene 不在候选里(pm query-activities -a android.intent.action.SEND -t text/plain 只有 Chrome / Drive 等)。分享实现后这条命令应当能选中 Cyrene。
  • -f 0x00000001 = FLAG_GRANT_READ_URI_PERMISSION,必须带,否则 App 读不到那个 content://。
  • 「未登录先暂存」的验法:先在 App 里登出 → 分享 → 再登录 → 看内容是否落地。

三条待拍板项的定案(2026-10-04,主人:「按你推荐的来」)

  1. 概览页「按路径」呈现 —— 保持 W4 的实现,不再改:分组键 = 剥掉 /api/v1 后的首段;条数 >4 的组默认收起、≤4 的默认展开;只有一条路径的组不出组头;路径等宽字体 + 段级中间省略。
  2. 新建会话选角色的交互 —— 保持 W5 的实现,不再改:抽屉「+」→ 弹层(点行选中 → 底部「创建」);角色名挂在会话行摘要前(未绑 = 不显示;角色表里查不到该 id = 原样显示 id,不猜、无硬编码映射)。
  3. 文档 / 「超限走文件」的两个产品决定(W6 只做了数据层,接线按这两条来):
    • 不做「长正文自动转文件」。 理由:POST /api/v1/chat 没有正文长度上限(02 已核:那个 4000 rune 只属于 PATCH /api/v1/messages/{id}),长正文已经由既有 ChatSendRouting 走 REST;把用户打的正文悄悄换成附件,只会让「历史里搜不到、气泡变成 chip」,是净损失。⇒ 转文件只用于真正的文件附件(从别的 App 分享进来的文件、或用户自己选的文件)。
    • 附件与正文各自独立存在:用户自己打的字照发,附件随 chat.documents 一起发(服务端两种都支持,纯附件回合也合法)。绝不把正文搬进附件,所以也就没有「转文件后正文还发不发」这个问题。
    • 接线时必须如实告知:document_unreadable(400) 客户端基本触发不到(那要显式索取正文,而 document-read 是服务端工具);客户端实际能看到的信号是 DTO 里的 status:"unreadable"。truncated 只表示注入被预算截断,不是「读不到」——两个信号不许互相顶替。
    • 附件上行载荷:"documents":[{"name":"<32hex>.<ext>"}],≤5 条、只给服务端存储名;带附件一律走 REST(客户端策略)。name 是唯一上行字段。

★ 当前状态与待办(恢复锚点 · 2026-10-04 深夜)

压缩/换会话后先读这一节,它是对「此刻到底在哪」的一句话答案;细节在上面各批次里。 ⚠️ 历史批次的细节(W4–W7 起各批)已移到同目录 05-archive-历史批次.md —— 这里说的「上面各批次」都在那份里,按原文顺序保留;本节的各锚点仍在主文件。

状态

  • android = origin/android = 85a922c,工作区干净,891 测试 / 0 失败 / 0 错误(--rerun-tasks 全量重跑后自己读 JUnit XML 求和)。
  • 可安装包:app/build/outputs/apk/debug/app-debug.apk。
  • 那轮「装机验收」的四项全部有设备证据(含 ① 的更正:本来就是好的,是我测量假象):见上一节「装机验收轮」。
  • 联调凭据(压缩后最容易丢,务必先看这条):后端调试用的 X-API-Key 已由主人授权落地到本机 dev-secrets.local.md(仓库根目录,已 gitignore、不在仓库里——已用 git check-ignore / git ls-files 双重核对)。用法:curl -H "X-API-Key: <该文件里的值>" $BASE/api/v1/…,owner 级(/sessions、/admin/observability/{metrics,logs}、/admin/system/storage、/admin/personas 全 200;不带 key 回 401)。上一个 key(rG14nMNX…)已失效(实测 401),不要再试。 轮换只改那个文件,代码/文档里没有任何硬编码。
  • devdocs/ 本身是入库的(主人 2026-10-04 授权),所以本锚点跟着仓库走(git log devdocs/ 可查);但凭据绝不进 devdocs/,只在那份 gitignore 的文件里。
  • 模拟器:AVD Pixel_10_Pro_XL,启动需加 -memory 6144(否则 2 GB 会自己 ANR,见「踩到的坑」),adb reverse tcp:10942 tcp:10942,App 服务器地址填 http://127.0.0.1:10942。

本轮(W8→W22)已落地并推送的模块(一行一个)

# 内容 提交
W8 设置页「版本」行 → 关于页 3ed2377→f24877d
W10 可观测性三帧(metrics/trace/log)→ 概览实时刷新 + 最近日志 b5e6f78→93941cd
W9 文档附件全链路(选文件 / 待发芯片 / 上传 / chat.documents / 气泡芯片 / 分享文件走文档) 30180ab→ab76798
— 契约 v0.59 已发布 → 更正旧注释;devdocs/ 入库 2973391 2369dd2
W13 队列满处置那条气泡(pending_full / 定向 chat_busy 的 clientMsgId) 3b4a790→9854cc4
W14 图片全屏查看 + 打开/保存 557ef3f→f109846
W15 重试复用幂等键(按内容指纹) af11a94→8b90703
W16 会话内搜索(顶栏入口 / 面板 / 点结果定位) 23b46f9→46ac15a
W17 重试记忆改多槽 d3fc0c8→440e441
W19 单条消息编辑与删除 3518917→ea16f48
W18 跟上 v0.61 的 .pdf(App 内预览对非文本拒收,不再出乱码) 48e4188→1f9d1dd
W21 重试记忆加 10 分钟时间上界 8938fff→df63783

(W20/W22 是修「非 bug」的空转,零改动,见上一节的教训。)

★ 本轮(压缩恢复后 · 2026-10-04 深夜续)已落地并推送

# 内容 提交
— 收下后端回执十一(四点确认 + knowledge 已立案 + IM-1/U1 在实施) e4915a6
A5 after 正向增量(断线补洞):掉线记游标 / 重连正向追 / 补拉循环抽成纯函数 28c3142
— 404 session_not_found 兜底:非主会话读不到 ⇒ 如实说一句 + 自动退回主会话 28c3142
W23 接上契约 v0.63 的 documents[].reason(只在 unreadable 时下发,原样显示) 2d382b1(e5fa1b8)
W24 编辑弹层只显示纯文本(真有标记时才剥;弹层明说会替换掉旁白) 1794e53(66c2291)

这一轮的两条关键事实(复用价值高)

  1. 可编辑的只有 user 消息,而 user 消息存的是用户原文、从不带标记 —— 我扫过线上主会话 64 条 user 消息,零条含标记;所有 <p> / <action> 都在 assistant 消息里(assistant 不可编辑)。所以 W24 的「剥标记」在今天是一条防御性口径,对真实数据是恒等变换;hasMarkup 的判据因此取的是「text != content」(= 保存真的会替换掉什么),而不是「出现过 <」。
  2. after 是升序、before 是倒序,且 after 方向的下一条游标是本页最新那条(不是最旧的)。服务端按 limit 从最旧一端给,所以取最旧当游标等于原地不动。同毫秒并列超过一页时纯 createdAt 游标推不动(契约 §A5 明写的边界)⇒ 循环里必须有「游标严格前进」+「轮数上限」两道闸,否则无限请求。

待办(按优先级)

  1. W19 剩下的那一处:删除后抽屉里的「最后消息预览」不刷新(本端只改了消息列表,没触发 GET /sessions)。① 编辑口径已由主人拍板并落地(W24)。
  2. 遗留 ①:连接所有权 ✅ 已闭环(W28,7b97440):现在进程内共享一条 ChatSocket(AppContainer 唯一持有、SessionsViewModel 是唯一 owner),连接数从 N+2 降到 1。装机验收见「补记四」。
  3. ChatSocket.sendInterrupt 零调用方(「切会话时打断」这个出口从未接线,是死 API)。
  4. observability_audit 帧(后端未做,审计仍是 REST);POST /admin/users 仍是 501 ⇒ 铸不出 viewer 账号,viewer 降级只能单测背书。
  5. 子会话流程(主人已确认搁置)。

本轮未验证的(别当成已验证)

  • after 的设备侧验收:真机上确实打出过真请求 —— GET /sessions/web/messages?limit=50&after=1791121966668 → 200(说明「重连补洞」那条路真的在跑)。但**「断线超过 50 条」那个洞没造出来**(要造得让后端不可达再灌消息,而后端当时正被另一个会话实时开发,我实测到一次 HTTP 000)⇒ 补洞的端到端仍是未验证。逻辑侧由 ChatRecoveryTest(循环两道闸)与 MessageAfterRepositoryTest(真 OkHttp 断言线上 URI)钉住。
  • 404 session_not_found 兜底:已装机端到端验通(b5c0b13)。证据:logcat GET /sessions/web:{已删}/messages?limit=50 → 404 →(0.9 s 后)GET /sessions → 界面回主会话;红字横幅「这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话」可见(app/build/e2e-shots/w7-gone-2.png)。第一次验收它是失败的 —— 切换对了、横幅一个字没显示(原因见下面那条新教训),修完才通过。
  • W24 的编辑弹层:弹层本身在设备上开过(user 消息 → ⋯ → 编辑;弹层正常,计数 0 / 4000、保存键按预期禁用)。但没验到「非空初值」:主会话里那一片 user 消息全是纯附件回合、content 长度为 0(已对着 GET /messages 逐条核过),初值本来就是空串。也没验到「原文带标记」那句告知(线上 64 条 user 消息零条含标记,见上)。⇒ 「剥标记」那条路径仍只有单测背书。
  • W23 的 reason 弹层显示:纯逻辑链(DTO → chips → previewPlan → 弹层文案)由 17 条单测打通,没有在设备上点开过一个真正 unreadable 的附件。
  • 已用设备证据确证的那一条:待发附件的小预览框 + 叉(主人点名要的那个)本来就存在且可用 —— 截图 app/build/e2e-shots/v4-draft.png(attach-demo.txt / TXT · 45 B + 叉,挂在输入框上方)与 v5-removed.png(点叉后预览框消失、发送键复灰)。零代码改动。

家务(2026-10-04 深夜续已办完)

  • 三条验收会话已删(主人放行后执行,带证据):E2E-DOC(1 条消息)/ E2E-PDF(2 条)/ E2E-RETRY(2 条)三条 DELETE /api/v1/sessions/{id} 全 200;现在服务端只剩主会话 web。
  • 18 个 worktree 已全部清除(W4–W19 + W23/W24),用的是 Git Bash 的 rm -rf(Windows 直接用 git 自带的删会撞 MAX_PATH),随后 git worktree prune;git worktree list 现在只剩主树。 分支引用全部保留(每个都 git merge-base --is-ancestor <branch> android 验过已并入, 留着方便回退)。
  • 清理手法留在下面的「构建与环境」一节:这两个动作都是不可逆的,删之前先跑一次 「每个分支是否已并入」的检查。

与本轮配套的两条新纪律(已进上面的教训段,这里再点一次)

  1. 动代码之前先用像素确认症状;uiautomator dump 只用来取坐标。
  2. 症状与代码结构矛盾时,先怀疑自己的测量。

还有一条:那个「未响应弹窗」的真相(主人问过两次)

  • 现象:模拟器隔一阵弹 Cyrene isn't responding / Process system isn't responding。
  • 证据:我在 40 秒内连跑 3 次 uiautomator dump,紧接着一次 adb shell input tap 48 秒才返回; WindowManager 报 ANR in top.yeij.cyrene … Waited 5121ms for MotionEvent,而同一段日志里还有 PointerEventDispatcher0 is not responding. Waited 37729ms —— 系统自己的输入分发器都卡了 37 秒,那不是 App 的 bug。紧接着就弹了 Process system isn't responding。
  • ⇒ uiautomator dump 要省着用(它是整棵视图树的重量级遍历),一次够用就够;一次交互超过 几秒没回来就当环境卡了,别记成 App 的 bug。真卡住了就 adb emu kill 重启(本次重启后 同一操作的延迟从 48 s 回到 0.27 s)。

★ 锚点补记(2026-10-04 深夜 · 压缩前落):后端回了 回执十,契约升到 v0.63,其中两条直接冲我们:

  1. documents[].reason(可选) —— 只在 status:"unreadable" 时下发;文案是他们注入模型清单里的同一份字符串(10 条,例如「无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」)。⚠️ 别做整句相等判断(真实字符串比登记里给我看的更完整),要分支就用稳定子串(如「需要 OCR」),默认原样显示。 ⇒ 新待办:客户端接上它(现在我们只能显示通用的「读不到」,W18 那条缺口就是它)。
  2. GET /sessions/{id}/messages 对不存在/已删除会话改为 404 session_not_found(行为变更;存在但空仍是 200 {"data":[]})。隔离会话 sess-* 也回 404,而我们的 App 不会按 sess-* 拉消息(已核代码:id 只来自 GET/POST /sessions;sess- 只在两处注释里出现)⇒ 已答复他们「不必加例外」。 ⇒ 新待办:用这个 404 做「当前会话已被删除 ⇒ 自动退回主会话 + 如实说一句」的兜底(就是主人那次「图片凭空消失」的体验问题)。

另有 v0.62 的 POST /admin/llm/reset-from-env(owner-only 写、要 confirm:true)——客户端暂无 LLM 管理页,纯增量、不用管。

又一条硬教训:一次性告知不能挂在「会被销毁的状态」上

404 兜底的第一版把「会话已删」那句横幅放在 ChatUiState.sessionGone 上。逻辑看着完全对,真机上 用户一个字都看不到:

onSessionGone 一被调用 ⇒ activeSessionId 收敛回主会话 ⇒ ChatScreen 的 key 变化 ⇒ 承载那面横幅的 VM 连同状态立刻被销毁。

⇒ 规矩:告知要挂到活得比它久的那一层(这里是 ChatHomeScreen 的 remember),并且 先立告知、再触发刷新 —— 顺序反了同样看不到。

这类 bug 单测抓不到、编译也不报,只有真机看一眼才会发现。→ 又一个「UI 改动必须真的看一遍」的证据。

★ 锚点补记二(2026-10-04 深夜续 · 压缩前落):本轮把三条待办做完了两条半,另收了后端 回执十二(knowledge 根因 + 并行 tool_use 配对的新 P0 + 契约 v0.64 的 GET /admin/llm/usage)。

  1. after 正向增量已接(28c3142):掉线记游标 / 重连正向追 / 循环抽成纯函数 loadForwardPages (两道闸:游标严格前进 + 20 轮上限)。真机打出过真请求,但「断线超 50 条」的洞没造出来。
  2. documents[].reason 已接(W23 / 2d382b1):弹层里原样显示他们那句,芯片仍是「读不到」。 ⚠️ 一处我们主动选的口径:unreadable 且 mime 是 text/ 时让 reason 压过正文渲染 (理由与代价都写进了 02 的登记,等他们确认)。
  3. 404 session_not_found 兜底已装机验通(28c3142 + 修复 b5c0b13)。
  4. W24 编辑弹层只显示纯文本(1796e53→66c2291 合并为 1794e53):真有标记才剥; hasMarkup 取「text != content」(= 保存真的会替换掉什么),弹层明说会替换掉旁白/动作。
  5. 家务已办完(3 条验收会话 + 18 个 worktree,见上一节)。
  6. 后端那边与我们有关的两条:knowledge 的修法已经落地(历史里会落一条诚实说明,我们在 89776fe3… 那条会话里亲眼看到);并行 tool_use 配对的 P0 在修,客户端不需要适配。
  7. .env 新增 LLM_MULTIMODAL(主人加的)⇒ 主模型能识图,H1 那条结构上消失。 我们还没做「如果主模型本身能识图」的客户端侧跟进,等主人指路。

★ 锚点补记三(2026-10-04 深夜续二 · 自主推进一批):主人去睡了,我按主线往下推 + 做了一次只读审计。 这一批全部是「界面上/文档上声称能做到、代码里其实没接线」类的问题(主人最恨的一类)。

本批落地(HEAD 85a922c,891 测试 / 0 失败)

# 内容 提交
审计 只读审计(假开关 / 死代码 / UI 规范 / 文档漂移四张清单,每条带 文件:行号) 无提交(产出清单)
W25 设置页的「打断模式」真接线 —— 它此前是个假开关 e4c6c7c(cfdc094)
W26 stream_chunk 不再静默丢正文 + 错误横幅可关闭 + 分享不再自相打断 + 两处死代码 b7ac6b8(eacc65a)
W27 删只被测试养的 availablePages + 图片 base64 上限公式去重 + 03 四处文档更正 b7ac6b8(805d8b5)
— WsEvent.SessionInterrupted.dropped 的过时 KDoc 更正 85a922c

三个「假开关 / 静默」的真相(这批最值钱的东西)

  1. 设置页的「她正忙时按发送的行为」是个假开关:InterruptMode 能读能写能显示, 全仓没有任何发送路径读它(连 ChatSocket.sendInterrupt 都是零调用方的死 API)。 用户切成「打断」,行为与「追加队列」一模一样。⇒ 现已接上:默认档零行为变化, 「打断」档且会话正忙时先发 interrupt{scope:"current"}(保留待处理队列)。 判据抽成纯函数 interruptScopeForSend(mode, busy),8 条测试钉住两档 × 忙闲穷举。
  2. stream_chunk 被静默丢掉:契约明写它是非流式回退路径(「未映射/无法确定的事件」), 而 onEvent 的兜底是 else -> Unit ⇒ 只要服务端走那条路,整段回复一个字都不显示、且没有任何信号。 现已把它当 delta 喂进同一个流式解析器。(它与结构化帧互斥,依据是契约那句「回退路径」。)
  3. 分享多图会自相打断:W25 接线后暴露 —— 分享 N 张图 = N 次投递,第 2 张起各发一条 interrupt 把前一张刚起的回合取消掉。⇒ 分享路径一律 allowInterrupt = false(批量动作自相残杀一定是错的)。

两条「查完发现不是 bug」的(别再返工)

  1. 「删除后抽屉里的最后消息预览不刷新」——不是 bug(W19 留下的待办)。 证据两面:① 服务端实时重算 —— 我用 REST 实测:删掉某会话最后一条 assistant 消息后, GET /sessions 的 lastMessage 立刻退回前一条 user 消息;② 客户端每次打开抽屉都会 refresh(silent = true)(ChatHomeScreen 的 onOpenDrawer)⇒ 用户看到的是新预览。 唯一剩下的极端场景是「抽屉正开着时别的端删了消息」——那属于「抽屉不订阅 message_deleted」, 是另一个(更小的)话题,别和这条混起来。
  2. session_interrupted{dropped} 的「已打断,丢弃 N 条」在本端不可达:契约 v0.56 明文 scope:"current" 时 dropped 恒为 0,而本端从不发 scope:"all"(见上 W25)。 ⇒ 只更正了文档与 KDoc(03 §4 与 WsEvent.kt),没有去实现一个用不上的计数。

装机验证(有设备证据的)

  • 错误横幅的关闭叉:验通。造错手法:撤掉 adb reverse 后重开 App(老教训:--remove 断不开已建立的连接,得让 App 重开会话)⇒ ConnectException ⇒ 抽屉底部出现红字「无法连接到服务器」
    • 右侧 ✕(app/build/e2e-shots/x2-drawer-error.png);点 ✕ 后那条消失、 而「网络异常,显示 刚刚 的结果」正确保留(x3-dismissed.png)—— 两件事本来就是两个信号。
  • stream_chunk 的端到端仍未验(要服务端真走进回退路径;本端没法自己造), 收益侧是「与 MessageDelta 逐字同路」,风险侧只有契约那两句话作依据。
  • W25 的打断帧未在真机上看过发出(要「偏好切成打断 + 会话正忙 + 发一条」三条同时满足; 后端在另一个会话手里,我没有主动去占它的回合)。逻辑侧由 8 条纯函数测试钉住。
  • 登录页的输入框是否被 IME 遮挡:未验。审计把它标成「存疑」(字面违反「输入框在 bottomBar + imePadding()」这条约定,但 LoginScreen 有 verticalScroll + Manifest 是 adjustResize, TextField 通常自己会把光标带进可视区)。要验就得清空 App 数据回到登录页—— 风险是万一登不回去,今晚所有装机验证能力一起没了,所以我没做,如实留在这里。 判断:风险低但不是零;主人下次在干净设备上装一次就能一眼定论。

本批没做的(含一个我刻意不做的)

  • 连接所有权(共享 socket + 按会话路由):没有动手。它是架构级改动, 动到四条 VM 的帧路由,而今晚模拟器状态不稳、后端在另一个会话手里 —— 一旦引入回归,我未必能当场发现(这比不做更糟)。已写设计要点与验收清单(见本节末)。
  • DocumentDelivery 里那组「正文转文件」的判决 API(plan / textAsFile / textNeedsRest
    • AsDocuments.textAsDocument 字段)没有删,也没有接线:所有权归属有口径冲突—— 05 的三条定案写着「不做长正文自动转文件」(理由:POST /chat 没有正文长度上限,长正文 已经走 REST 了),而主人后来在 G 条里点名「超限走文件也要做」。两者多半指的不是同一件事 (前者 = 用户在 App 里打的字自动变附件;后者 = 从别的 App 分享进来的超长内容走文件), 但我不替主人合并这两个口径。已登记进 02 请他拍板。
  • 另有一条 W26 留下的边界记在这里备查:回退路径没有 message.start ⇒ MessageStart(index==0) 里那句 freshTurnIfFinished() 在回退轮次不触发;日常看不见问题 (落库/stream_end 各自会收尾),只有「上一轮被取消且没落库 + 紧接着来一轮回退流」才会让 上一轮的思维链/工具卡滞留。加它属于行为增强,当时按「只加消费分支」停手了。

连接所有权的设计要点(下一批做它之前先读这段)

  • 现状:ChatViewModel(每个会话一个 VM)、SessionsViewModel、AdminViewModel 各建一条 ChatSocket,而 chat 的 VM 活在 Activity 级 ViewModelStore 里 ⇒ 访问过的每个会话都永久留一条 连接(各自带 OkHttpClient + 连接池 + 重连循环),删会话也不回收。
  • 自然解法:共享一条 socket + 按 sessionId 路由帧(WsEvent.isForSession 已经是现成的判据)。
  • 验收清单(缺一不可):① 主会话流式正常;② 切到次级会话时不串台(旧连接收到的别的会话帧必须被丢); ③ 管理页三帧(metrics/trace/log)仍到;④ 抽屉未读角标仍对;⑤ 断线重连后 after 补洞仍跑; ⑥ 登出时要断开且重登要接上(现有 authManager.state 那条 connect()/close() 逻辑要跟着搬); ⑦ 删会话后那条连接不再留着。

★ 锚点补记四(2026-10-05 上午 · 主人给了明确指示后的一批)

状态

  • android = origin/android = 7b97440,工作区干净,891 测试 / 0 失败 / 0 错误。
  • APK:app/build/outputs/apk/debug/app-debug.apk(就是 7b97440 这棵树)。

本批落地

# 内容 提交
W28 进程内共享一条 ChatSocket:连接数 N+2 → 1,SessionsViewModel 为唯一 owner 7b97440(276b011)
FB 可编程假后端测试台(devtools/fake-backend/,纯 Python 标准库、零依赖) 27d5680
— 02 三处登记:回应回执十三 / 置顶「微信文件看不到」P0 / IM 语音消息的新需求 691b073 ae49853 a008580

W28 的关键取舍(别退回)

  • 共享的 socket 谁都不许 close:onCleared 里的 close() 全删了(否则切个会话就把全 App 的连接掐断)。 现在全仓 connect() / close() 各只剩一处,都在 SessionsViewModel(它由 ChatHomeScreen 在进登录态时创建、活在 Activity 级 store 里,存活期正好覆盖「登录→登出→重登」)。
  • _events 的 replay 必须恒为 0:共享后新建的 ChatViewModel 会立刻订阅同一个 SharedFlow,replay > 0 就会把历史帧重放给它。
  • 两个已知的固有代价(都在 W28 的 commit 里写明):① 晚于连接建立才创建的 VM 不再收到 history 帧 —— 实测无影响,新 VM 的正文由 REST loadLatest 给(设备上验证过:次级会话首次访问 GET …/messages?limit=50 → 200);② Activity 结束不再断开连接(进程级共享的必然结果),只在登出 / 令牌失效时断。

W28 装机验收(有设备证据)

  • 连接数不随访问会话增长:awk 数 /proc/net/tcp{,6} 里 App uid 的已建立连接 —— 只访问主会话时 4,切到第二个会话后仍然 4(改前这里会 +1)。这是 W28 全部意义所在。
  • 切会话 → 切回主会话,渲染完全正常(消息、来源角标、旁白都在)。
  • 管理页三个端点全到(admin/observability/{logs,metrics,trace})并渲染出真实数据(总请求 205 / 平均延迟 20ms)。
  • 未验:重连补洞(after)在共享 socket 下的表现、登出/重登的 owner 逻辑、viewer 分支 —— 都还没有设备证据。

假后端(FB)—— 它是「验不了」这个借口的解药

devtools/fake-backend/(python fake_backend.py --port 18080 + ws_probe.py)。它解锁三件真后端做不了的验证:

  1. after 补洞:POST /__control/messages?count=120 免费灌 120 条,握手只回放 50 条 ⇒ 精确造出那个洞。 (这解释了为什么之前说「验不了」是错的:我当时只想着用真后端灌 51 条 = 51 次模型回合。该用假后端。)
  2. 打断帧:out/upstream.jsonl 记录所有上行帧 —— 这是「W25 那帧到底发没发」的唯一直接证据。已实跑抓到 {"type":"interrupt","data":{"sessionId":"web","scope":"current"}}。
  3. stream_chunk 回退路径:能指定走旧形状,正好用来验 W29 要修的那条分支。
  • App 指过来:adb reverse tcp:18080 tcp:18080 + 登录页服务器地址填 http://127.0.0.1:18080(账号随便填)。
  • ⚠️ 它的事实来源是客户端代码与协作文档,不是真后端 —— 别拿它当后端行为的依据。

本批的一条硬教训:问一句,比自己猜一整天都值

stream_chunk 是我昨夜「按增量」接的(把它当 MessageDelta 追加)。今早问后端,他们回复:delta 是整段 res.Text(而且生产里已无触发源)。 ⇒ 真到达会追加成重影。我昨夜那版是错的,而且如果没有问,它会以「已修好」的样子留在仓库里。 同类:viewer 凭据我一直挂着当红验收项,问了才知全仓没有任何铸造路径,应当直接摘掉。

待办(更新)

  1. W29(在跑):stream_chunk 改成「整段替换 + 有结构化帧则忽略」/ 空壳消息如实说一句 / 分享超长内容走文件 / F6① 图片 URL 兜 host。
  2. 等后端:把 IM 入站的文件挂到消息的 documents 上(P0,02 已置顶);IM 渠道语音消息的入站 ASR。
  3. 用假后端补三条设备验收:after 补洞 / 打断帧 / stream_chunk(等 W29 合完)。
  4. 子会话流程(主人说搁置);图片/工具卡实机验证(后端未配工具)。
  5. 已结案(别再返工):登录页输入框被 IME 遮挡 —— 不成立,2026-10-05 装机实测(密码框聚焦、键盘弹出时完整可见,截图 app/build/e2e-shots/y3-ime.png)。

★ 锚点补记五(2026-10-05 上午 · 忙时并行批次 + 思考流排查)

状态

  • android = origin/android = c3cfef7,工作区干净,1033 测试 / 0 失败 / 0 错误。

本批落地(一行一个)

# 内容 提交
W28 进程内共享一条 ChatSocket(连接数 N+2 → 1;SessionsViewModel 是唯一 owner);装机验过「连第二个会话连接数不涨」 7b97440
FB 可编程假后端(devtools/fake-backend/,纯 Python 标准库) 27d5680
W29 stream_chunk 改整段语义 / 空壳消息如实说一句 / 分享超长走文件 / 图片 URL 兜 host ac3f63f
W30 忙时并行 UI 半边:六态 + 灰态 + (sessionId, turnId) 两级分桶,帧侧全部可降级 0057205
W31 设置栏「她忙时发消息」追加第三档「并行」(忙时上行 chat.parallel=true,绝不发 interrupt) 4847d74
W32 忙时被拒那条横幅调柔和(只那一档,真错误仍红)+ 并行额度落回排队如实说一句 79fcd80
W33 parallel_limit 提示加「本端确在并行档」的闸 + 查证横幅(结论:代码无缺) 1f6c16c
FB2 假后端加 --hold-replies(收到消息不自动落库)⇒「待发气泡还挂着」可稳定复现 c3cfef7

装机验过的(有设备证据)

  • after 正向增量补洞:重连后她自己发出 messages?limit=50&after=…(这是之前两天判定「验不了」的那条 —— 错在我想窄了,该用假后端)
  • 打断帧:上行帧原文 {"type":"interrupt","data":{"sessionId":"web","scope":"current"}},先于 chat 帧
  • stream_chunk:假后端发一帧整段 → 渲染 CHUNKONE 一次,无重影
  • W28 连接数、错误横幅关闭叉、登录页 IME(不成立)

⚠️ 思考流(主人 2026-10-05 报的)—— 根因在后端/模型侧,已查到一半

  1. 证据:真后端最近 5 条 assistant 消息全都没有 thinking 键(GET /messages 是 owner 可见的唯一历史思维链来源)⇒ 后端手里根本没有思考内容,不是客户端不显示。
  2. 配置(Cyrene_v2/data/models.json):provider: "anthropic"、baseUrl: https://api.deepseek.com/anthropic、model: deepseek-flash、没有 reasoning 键。
  3. reasoning 键加不了(别再试):models.go:180-191 的 validateReasoning 明确规定 —— anthropic 组上除 auto/空之外任何 kind 都非法,写了会让后端下次启动校验失败。
  4. 原因在代码里写着:anthropic.go:195-201「anthropic 协议自带思考形状,因此这里声明 openai 形状是无效的」⇒ reasoning.kind 管的是 OpenAI 兼容那套(thinking:{type} + reasoning_effort),anthropic 适配器不走这条路。
  5. 所以开关在别处:在 anthropic 适配器自己的请求构造里(协议原生的 thinking 块)。 ⚠️ 待查(我读到 anthropic.go:195 就停了):它到底发不发 thinking、什么条件下发; 以及 DeepSeek 的 anthropic 兼容端点支不支持 anthropic 的 thinking 形状。 ⇒ 最省的办法:直接问后端会话(他们写的,一句话就够)。

待办(按优先级)

  1. 用假后端把「忙时被拒」那条横幅端到端验一次(上次没验成:当时待发气泡是 0 条 ⇒ 落 Ignore ⇒ 刻意什么都不写,那是设计不是 bug)。 做法已在 devtools/fake-backend/README.md 第 6 节(--hold-replies → 发 chat 挂住 → persist 补落库 → 注入 pending_full),逐条实跑过。
  2. 思考流那条的后续(见上):问后端 anthropic 适配器的 thinking 下发条件。
  3. 等后端 v0.65:chat.parallel / turn_begin / turn_id 的精确位置登记后接帧(我们形状已备好,全可降级)。
  4. 旧的:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。
  5. 作废的说法(别再引用):曾记「假后端 --stream-chunk 建模与契约不符」——错,它的 chunks=1 就是契约的「一帧整段」。

环境与纪律(这次踩到的)

  • 模拟器今天很脆:连 launcher 都 ANR 过,App 进程被系统重建。新会话开工前先重启模拟器。
  • 假后端进程还占着 18080 / 18081 / 18083(taskkill 对它们不太好使,重启机器最干净)。
  • 我这次又犯了「先怀疑代码、而不是先怀疑自己的测量」:截图上没看到横幅时,我先去查代码,其实当时只是待发气泡为 0(设计如此)。这条纪律写在上一节,要一直记着。

⚠️ 纪律补记(2026-10-05 上午 · 主人问「为什么项目文件夹里那么多本项目的文件夹」)

那些是 git worktree —— 给并行子代理各开一个独立工作区用(避免两个代理同时改同一棵树)。 问题不是它们存在,而是我合并完忘了清:这一批(W28–W33 + FB/FB2)8 个全都残留下来了, 主人先在文件管理器里发现的。上一次(W4–W24)我做过一轮「家务清理」,之后就再没做。

⇒ 从今往后写成硬规则:

  1. 每合并一个 worktree,就 git worktree remove / rm -rf 它(别攒着)——同一次操作里做完, 不要留到「以后一起清」。
  2. 清理用 Git Bash 的 rm -rf(Windows 直接用 git 自带的删会撞 MAX_PATH),然后 git worktree prune。
  3. 删之前先验:git merge-base --is-ancestor <branch> android —— 只有已并入的才删。
  4. 分支引用保留(方便回退),只删目录。
  5. 收尾自检:git worktree list 应当只剩主树、ls -d /d/Project/Code/Uni/Cyrene-Android-* 应当为空。

本次已清:8 个全删,git worktree list 只剩主树;feat/W4-* … feat/W33-* 分支全部保留。

另一条同源的提醒:我给子代理开的「假后端」进程(python,占 18080/18081/18083)也是我留下的 —— taskkill 对它们不太好使,重启机器最干净。以后用完就该关。

★★ 锚点补记六(2026-10-05 上午 · 文档已拆分 + <p> 规整上线 + 三件待办)

⚠️ 先看这条:devdocs/ 的结构变了(新会话别找错文件)

想找什么 读哪一份
当前状态 / 待办 / 锚点 / 纪律 05-开发进度与交接.md(本文,624 行)
历史批次的详细记录(W4–W7 等) 05-archive-历史批次.md(418 行,只读追溯)
当前开放的登记 + 状态一览 + 置顶 P0 + 最近往来 02-前后端协同事项.md(472 行)
历史回执(一~十七)与已闭环的登记 02-archive-历史回执.md(1681 行,只读追溯)

规则:新回执写在 02 的末尾;闭环的登记由我方定期挪进归档。02 是与后端会话共用的文件 —— 他们也会往里写,改动前先看 git status(这次就撞上过一次:他们未提交的追加把我的 merge 挡了, 处理方式见下)。

状态

  • android = origin/android = 681ae96,工作区干净,1033 测试 / 0 失败 / 0 错误。
  • 本批只动文档,没有代码改动。

本批发生的事

  1. 拆分过长文档(W34,两轮):02 2143 → 472 行主 + 1681 归档;05 1035 → 624 行主 + 418 归档。 零丢失有两层证明:代表性字符串 grep -c 前后相等 + 逐行多重集 diff(差异只有新增说明行)。 第二轮按主人要求把 A~E/G/H 那批已闭环的登记(约 430 行)也归档,并修了状态表那处会断的指针。
  2. 撞上一次协作竞态(值得记):后端会话在我拆的期间往 02 追加了内容(未提交)⇒ merge Aborting。 处理:先把他们的补丁 git diff > /tmp,git checkout -- 清工作区,合并拆分,再把他们的内容原样追加回新主文件末尾。 ⇒ 两边都没丢。教训:动 02 之前先 git status。
  3. <p> 规整已落地并上线(后端 5de708a):照我们的裁定走 —— 写入侧收口、幂等守门断言、 覆盖 Web 与 IM 两条落库路径(渲染口径不分叉)。我方零改动。

✅ 已拍板(主人 2026-10-05):历史回填 做

决定:做(主人「按最优解」)——三条约束见 02-前后端协同事项.md 末尾「历史回填」一节: ① 先 dry-run 只统计不改(量大异常先对齐)② 留回滚点 ③ 一次性、不留常驻逻辑。 范围严格限定为「只补 <p>」,不碰 role/时间戳/documents/thinking/记忆派生数据。

原问题描述(保留备查):历史消息要不要一并回填重写? 后端的原话:本次是写入侧收口 ⇒ 已落库的旧「半包」消息不会自动重写(按既有解析规则仍会塌)。 要重写就是一次性数据回填(涉及 data/**,需主人点头)。不做的话:新消息从此正常、老消息照旧塌。

待办(按优先级)

  1. 真机验证「并行」两路:后端已确认 turn_begin / steer_ack.turnId / interrupt.turnId / chat_messages.turn_id 都已进 HEAD 并已上线(契约 v0.66 待升)⇒ 「等登记」这个阻塞已解除。我方形状早备好且全部可降级 (W30),入口也在(W31:设置页「她忙时发消息」第三档)。装机步骤:先重启模拟器(见下)→ 装 APK → 设置切「并行」→ 在她正忙时发一条 → 看是否真开第二路 + 灰态 + 两级分桶。
  2. 用假后端验「忙时被拒」横幅(上次没验成的那笔):devtools/fake-backend/README.md 第 6 节有逐条跑过的命令序列 (--hold-replies → 发 chat 挂住 → persist 补落库 → 注入 pending_full)。关键:必须让待发气泡 ≥1 条 (0 条会落 Ignore,刻意什么都不显示,那是设计不是 bug——我上次就栽在这)。
  3. 等后端补「思考开关在哪开」:已确认端点支持(anthropic_reasoning.go:76 的 anthropicDeepSeekReasoning, 走 output_config.effort,thinking.budget_tokens 会被忽略并 WARN),且开关是请求级的、来自角色/组的思考设置 ⇒ 不是改 models.json。⚠️ 绝对不要再试 reasoning.kind:validateReasoning(models.go:180-191) 在 anthropic 组上会拒绝它,加了会让后端下次启动校验失败。
  4. 旧的:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。

环境与纪律

  • 模拟器今天极不稳(连 launcher 都 ANR 过、App 进程被系统重建)⇒ 开工前先重启模拟器。
  • 假后端进程还占着 18080 / 18081 / 18083(taskkill 不太好使,重启机器最干净)。
  • worktree 纪律(上一轮主人要求写死的):每合并一个当场 rm -rf + git worktree prune, 删前先 merge-base --is-ancestor 验已并入,只删目录保留分支,收尾自检 git worktree list 只剩主树。 本批已按此执行(W34 合并后立刻清掉)。
  • 我又犯了一次「先怀疑代码、而不是先怀疑自己的测量」(截图看不到横幅时)。这条已经写过两次了, 是第三次 —— 下次第一反应必须是"我的验证条件对不对"。

★★ 锚点补记七(2026-10-05 下午 · 并行两路实机验通 + 一个误标 bug 已修 + 假后端横幅两档已验 + 模拟器调参)

状态

  • android = origin/android = 39a63f8,主树全量 assembleDebug + testDebugUnitTest 跑过: 1055 测试 / 0 失败 / 0 错误 / 0 跳过。
  • 本批代码两个都已合并、已推送、worktree 已清:W35 6e00697(分享来源角标)、W36 39a63f8(并行路角标改以 kind 为准)。
  • ⚠️ 工作区有 02 的在途改动 —— 那是后端会话的追加(回执十八等),不是我们的,别提交它。 处理手法见本文末。

✅ ①「并行」两路:实机验通(这次是真证据,不是看角标猜的)

三条互相独立的判据:

  1. 后端日志(%USERPROFILE%\cy.log):msg="parallel leg started" session=web turn=turn-4743035881e410af leg=2 active_turns=2 —— 这行只在并行启动路径打(session_parallel.go:175),active_turns=2 按定义就是「起第二路时第一路仍在飞」。
  2. 库内 turnId(GET /sessions/web/messages):write 2000 words about the ocean = turn-b618432ddb21ed42(11:56:27.8), para2 = turn-4743035881e410af(11:56:39.4)⇒ 两条 user 消息不同 turnId;leg1 的 assistant 行 11:56:41.8 才落库 ⇒ 与 leg2 真重叠。
  3. 客户端画面:底栏显示「正在思考… 并行 2 路」(= activeTurns=2),两路回复各自成块、没互糊。

⚠️ 差点被自己骗过去的坑(写死它):第一轮我把「角标显示并行路 2」当通过 —— 是假阳性。 后端 leg 来自 st.legSeq++(session_parallel.go:202,每条新路都自增,串行回合也算)⇒ leg=2 只说明「本会话第 2 条路」。 判并行只能看 turn_begin.kind / active_turns,或后端那行 parallel leg started 日志。

🐞 副产物 bug(已修 W36):leg >= 2 就挂「并行路 N」

复现:会话里发第二条普通消息,气泡下就出现「并行路 2」。根因是客户端把 turn_begin.kind 丢掉了,角标只认 leg。 W36 把 kind 带进 TurnBinding.parallel,判据改成 parallel && leg >= 2;kind 缺席(老服务端)⇒ 一枚都不挂(宁可少挂,不说假话)。

✅ ②「忙时被拒」横幅:假后端两档都验过

手法(比改 App 服务器地址干净):不动 App 配置,把 adb reverse 的 10942 转到假后端端口 —— adb reverse --remove-all; adb reverse tcp:10942 tcp:18099,App 照旧访问 http://127.0.0.1:10942;验完还原 adb reverse tcp:10942 tcp:10942。

  • 待发 1 条 ⇒ 横幅「她正忙且队列已满,这条没发出去,请稍后手动重试」+ 气泡摘掉、正文退回输入栏(e2e-shots/fb3-banner.png)
  • 待发 ≥2 条 ⇒ 横幅「她忙不过来,这条可能没发出去」+ 一个气泡都不动(e2e-shots/fb5-unknown.png)
  • 关键前置:待发气泡必须 ≥1 条(用 --hold-replies 挂住,别让它落库)

🔧 模拟器调参(今天那串 ANR 的真因不是代码)

宿主 Ryzen 5 5600G(6C/12T)/ 28G;AVD 原本 4 vCPU + 6G(config.ini 里 hw.ramSize=2048,靠 -memory 6144 顶)。 客户机 top 显示 iowait 196% + swap 已用 457MB ⇒ 瓶颈是 IO + 内存,不是核数。 已改 C:\Users\Aska\.android\avd\Pixel_10_Pro_XL.avd\config.ini:hw.ramSize=8192 / hw.cpu.ncore=6 / vm.heapSize=512。 效果:iowait 196% → 14%,swap 457MB → 0。 另外别再每次 -no-snapshot-save —— 冷启动那一遍 IO 最贵;现在让它存快照,下次热启动。

⚠️ 新踩的两个坑

  1. settings put global hide_error_dialogs 1 会让 ANR 直接杀掉 App(日志 reason=6 (ANR) + Killing … user request after error)。 它对自动化友好,但代价是丢状态 —— 配合「不在负载高峰驱动 UI」一起用。
  2. 别一边跑 Gradle 一边驱动模拟器 UI(今天就这么把 App 饿到 ANR)。 判断「是不是 App 的 bug」的正确做法:拉 ANR trace 看主线程栈 —— 在 /data/system/procexitstore/,adb root 后可 pull。 那次主线程是 __epoll_pwait(空闲),且 launcher 同时段以同样理由 ANR ⇒ 模拟器被饿死,不是 App 的问题。
  3. uiautomator dump 失败会留着上一次的快照:每次用唯一文件名并检查输出行 UI hierchary dumped to:,否则会对着旧快照做判断(本批栽过一次)。

📦 分享接入:其实早就做完了

子代理查实:Manifest 两个 intent-filter、app/ShareIntake.kt、domain/share/{SharePayload,PendingShareHolder}.kt、ChatViewModel.sendShared 全在, 且 05-archive-历史批次.md:124-139 有 2026-10-04 的装机验收(文本 / 单图 / 未登录暂存 / 登录后落地四条)。 本次补的是**「可识别」那一半**(W35):分享进来的消息 clientMsgId 用 share- 前缀,渲染层挂 [用户分享] 角标,正文零改动(守 02 的 F4 红线)。 ⚠️ 一次观察(未结案):用 am start -a SEND --es …TEXT 投分享时,客户端出现「发送失败,请重试」并把正文退回输入栏,库里没有多出那条消息; 当时 WS 刚在附近重连过一次。未定位,先记着。

待办(按优先级)

  1. 分享路径剩下三条实机验证(超长正文走文件 4000 rune / SEND_MULTIPLE 多图 / 冷启动)—— 命令见 README 与子代理报告。
  2. W36 的真机目视:单测钉住了,但「第二条普通消息不再冒角标」没在设备上目视过。
  3. 旧账:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。

环境与纪律(更新)

  • 假后端用完即关(本次已关,18099 已释放);别长期占 18080/18081/18083/18099。
  • worktree 纪律照旧(合并当场 rm -rf + prune);本批 W35/W36 都已执行,git worktree list 只剩主树。
  • 动 02 之前先 git status:本批 02 一直带着后端会话的在途追加,处理手法 = 先存他们的补丁 → git checkout -- 还原 → 只提交我的 → 再把他们的贴回末尾。两边都没丢。

★★ 锚点补记八(2026-10-05 傍晚 · 分享路径设备验证的成绩单 + W37 + 三条新环境坑)

状态

  • android = origin/android = a2350a6(含 W37 的合并提交),1084 测试 / 0 失败 / 0 错误 / 0 跳过(主树实测)。
  • W37 6cdf111(分享:认下字符串形态的 EXTRA_STREAM + 消灭静默失败)已合并、已推送、worktree 已清。
  • 02 里已回后端回执十九(0764bc2):给出那次分享失败的精确时间锚点与「无 error 帧 ⇒ 帧根本没发出」的证据链。

✅ 分享路径:三条通过

场景 结果 证据
纯文本・热启动(连接正常) ✅ 库内 clientMsgId=share-5f65cb44… + 正文 share one;气泡挂 [用户分享] 角标
纯文本・冷启动 ✅ App 被 ANR 杀掉后由分享冷启动,share two 照样落库(share-ea81310c…)
超长正文(4200 rune)→ 文件 ✅ 后端 POST /api/v1/documents 201;库内 content_len=0、documents=[{…txt, size:4200, chars:4200, status:"extracted"}];她回「人家打开你发来的那个文件了」

⛔ 分享路径:图片(单图/多图)没拿到设备证据 —— 原因是工具,不是产品判定

adb shell am 造不出「带授权的 URI 分享」,两条都试过:

  • --esa(String[]):类型不对 ⇒ Cannot cast [Ljava.lang.String; to android.net.Uri(W37 已让它不再静默);
  • --eu(单 Uri)+ -f 0x00000001:没有 ClipData ⇒ 授权传不到目标应用 ⇒ App 打不开那个 content:// ⇒ 静默无反应(同一处缺陷);
  • am 帮助里确认没有 ClipData 选项(只有 --eu/--esa/--grant-*),所以这条路在 adb 上封死。 真路径是:相册(或任何真实发送方)→ 分享面板 → Cyrene。我试过,但今天 UI 驱动不可靠(见下面三条坑 + 模拟器 system_server 崩过一次)。 ⇒ 要么主人在真机上装最新 debug APK 试一次「相册多选 → 分享」,要么等下次环境稳定再做。不要再花时间用 adb 造 URI intent。

🔧 W37 修了什么(都是今天实机暴露的)

  1. 字符串形态的 EXTRA_STREAM 现在认了(String[] / ArrayList<String>,历史遗留与跨壳应用常见),只接受 content://(file:///http(s):// 一律拒,理由写在 stringStreamUri 的 KDoc);非法项逐条跳过,不整体失败。
  2. 消灭静默失败:EXTRA_STREAM/ClipData 里确实有内容迹象却一条都读不进来 ⇒ 给一条柔和告知「这次分享的内容没能读进来,没有发送任何内容…」(刻意不写「发送失败」)。 边界:EXTRA_TEXT 与 EXTRA_STREAM 都为空 ⇒ 仍然静默(两条判据同源)。

⚠️ 今天新踩的三条环境坑(比代码更耽误时间)

  1. 模拟器会自己黑屏,而 svc power stayon true 不一定挡得住。后果极坏:黑屏下的 input tap 全部落空 ⇒ 我会误判成「点了没反应 / 长按不生效 / 焦点乱跑」。开测前先 input keyevent 224 + 82 唤醒并截图确认屏幕是亮的,另加 settings put system screen_off_timeout 1800000。
  2. adb root 会重启 adbd,把 adb reverse 全部冲掉。之后 App 就「无法连接到服务器」。adb root 用完必须补回 adb reverse tcp:10942 tcp:10942。
  3. 模拟器的 system_server 会崩(症状:Can't find service: window、am 报 Failure calling service activity: Broken pipe、负载飙到 11)。此时除了重启模拟器没有别的办法;别把这类现象当 App 的问题。

📌 主人问的「开了并行之后连串行消息都成了并行处理?」—— 结论:不会,今天的数据里也没有发生过

三条证据:

  1. 服务端有硬门:session_orchestrator.go:300 的 if st.mode != ModeIdle —— 会话空闲时根本不进并行分支,无论客户端声明什么都不可能开第二路(空闲只有一条串行路)。
  2. 客户端只在忙时声明:parallelForSend(mode, busy) = busy && mode.wireParallel(InterruptScope.kt:65),空闲时三档逐字节相同。
  3. 今天全部日志(含轮转备份)里 parallel leg started 只有 1 条:turn-4743035881e410af leg=2 active_turns=2(11:56:39)—— 正是我做并行实测时故意制造的。 ⇒ 最可能的真相:主人手机上装的是 W36 之前的 APK,于是任何会话的第 2 条及以后的消息都会被误挂「并行路 N」(leg 是「本会话第几条路」,不是并行与否)——已修为 39a63f8,请装最新 debug APK 再看。 ⇒ 若看到的其实是底栏那句「正在思考… 并行 N 路」:那句来自 ChatUiState.activeTurns > 1(ChatScreen.kt:863),是另一个信号;服务端 session_idle 用 busyPayload(…, ModeIdle, 0) 发 0(session_orchestrator.go:455)⇒ 正常情况下会清掉。若仍能看到,请告诉我,我按 activeTurns 陈旧这条线单独查。

最新 debug APK

app/build/outputs/apk/debug/app-debug.apk(由 d8228fb 构建;含 W35 分享角标 / W36 角标闸 / W37 字符串形态与告知 / W38 分享等连接)。

★★ 锚点补记九(2026-10-05 夜 · 分享「等连接」缺陷已修(W38)+ W37 设备验证通过 + 「并行」误标的设备证据)

状态

  • android = origin/android = d8228fb(W38 合并提交;分支提交 aeb3745);主树 1091 测试 / 0 失败 / 0 错误。
  • W38 worktree 已清,git worktree list 只剩主树。02 工作区仍是后端会话的在途追加(本次没动它)。

✅ W38:分享「等连接」缺陷已修 —— 这处是今天唯一确认的真缺陷

缺陷(实机证据链见 02 回执十九与补记八):分享消费点把 withTimeoutOrNull 的返回值丢掉 ⇒「等 3 秒没等到」与「等到了」走同一条下游: 照样 take() + sendShared → WS 未就绪 ⇒ sendChat 回 false ⇒ rollback(localId, "发送失败,请重试")。 三个后果:① 硬错误横幅把「一次都没发出去」说成「发送失败」;② take() 原子出队且无归还路径 ⇒ 内容既不在 holder 也不在库里,重试资格丢了;③ effect 键不含连接状态 ⇒ 连接后来恢复也不会补发。

修法(aeb3745,4 files / +277 −20):

  • 新增 ui/chat/ShareLanding.kt:纯函数 shareLanding(hasPending, connection, waitedLong, alreadyTold) → Nothing / KeepWaiting / TellWaiting / Send(返回单值动作 ⇒ 发送与告知在类型上不可能同时发生)。
  • 落地 effect 的键加了连接状态(连接一变就重跑判据,无轮询);只在 Send 分支才 take()。
  • 等太久(3 秒)⇒ 走既有柔和通道 showShareNotice 说一次「还没连上、会等连上后自动发出」,不是 error 横幅、不含「失败/重试」字样,抖动不刷屏。
  • SHARE_CONNECT_TIMEOUT_MILLIS 改名为 SHARE_WAIT_NOTICE_MILLIS:含义从「到点就照发的发送时限」变成「等多久才值得说一句」,KDoc 明写不是发送时限。
  • 新增 ShareLandingTest 7 例(穷举 WsState.entries × 两个标志);未动普通发送路径(输入框/图片/附件)、未动 PendingShareHolder.take() 的原子语义。

✅ 真机回归已做(2026-10-05 夜,设备:模拟器 Pixel_10_Pro_XL / 包 f15f0bf 构建):

  1. 断线时分享 ⇒ 不再报「发送失败」:adb kill-server 真断线(reverse 清空)后投分享 —— 顶栏「重连中…」, 横幅是**「还没连上,分享的内容会留着,连上后自动发出」**(柔和),没有 error 横幅;库里查不到那条消息 ⇒ 内容没丢、也没假装发出去。
  2. 连接恢复 ⇒ 自动补发,且只发一次:adb reverse tcp:10942 tcp:10942 恢复后(等 ~50 秒,含退避)库里出现恰好一条 user 消息 cli=share-914435c5…、turn-8cbf94e7(同 turn 的另一条是她的回复,不是重复发送)。 ⇒ 三条回归里 ① 断网分享后恢复 ② 横幅不是 error 都拿到了;冷启动分享未单独重跑(补记八里冷启动路径已验过一次,且那条路径的差异只在入口)。 截图留档:app/build/e2e-shots/w38-offline.png(柔和告知)/ w38-recovered.png。

✅ W37 设备验证通过(补记八里说它「只有单测背书」)

装 a2350a6 的包,用 am start -a SEND_MULTIPLE -t image/png -f 0x00000001 --esa android.intent.extra.STREAM <uri1>,<uri2>:

  • 界面出现告知 「有 2 个文件读不到,未发送」 ⇒ App 真的认下了这 2 个字符串形态的流(W37 生效),只是 adb 给不出读授权所以读不到;
  • App 侧不再有 Cannot cast [Ljava.lang.String; to android.net.Uri(logcat 里剩下的那几条来自 system_server 的 ActivityCallerState,pid 1315,不是我们的进程)。

🐞 顺带发现一处文案瑕疵(未修,登记):这次告知显示成两句叠在一起 ——「有 2 个文件读不到,未发送**;分享的内容是空的,没有可发送的内容」。 前者是「认下了但读不到」,后者是「一条都没认下」,同时出现读起来像两个不同的问题(ChatScreen.kt:905-911 的拼接顺序)。 建议:failedImages/failedDocuments > 0 时不再追加**「内容是空的」那句。

✅ 主人问的「并行档下串行消息也变并行」——设备证据到手,不会

步骤(可复现):设置页切并行 → 她空闲 → 用分享投一条普通消息 serial gate check,然后看两处:

  • 后端日志新增 parallel leg started = 0 条(那条消息只有一个 turnId:turn-780c3aa2);
  • 界面助手气泡 footer 只有「发送至 安卓」,没有「并行路 N」(含 W36 的包)。 ⇒ 并行档只在她真的在忙时才另起一路;空闲时就是串行。主人若仍看到角标,几乎确定是旧包(W36 之前 leg>=2 就挂标)。

⚠️ 一条今天的测量教训(差点又误判)

adb reverse --remove-all 不会杀掉已经建立的连接 —— 我用它模拟「断线」,结果那条探测消息照样落库(share-f7cd2374 的 offline share probe)。 ⇒ 想真的把 App 与后端切断,要 adb kill-server(再 start-server + 补回 adb reverse)。别拿「移除转发」当断线。

待办(更新)

  1. W38 的三条真机回归(冷启动分享 / 断网分享后恢复 / 横幅不是 error)—— 真机或下次环境稳定时做。
  2. 文案瑕疵(failed* > 0 时不再追加「内容是空的」)—— 小改动,与下面两条同区,建议合成一批。
  3. W38 子代理提的三条后续(未做,待主人定):① deliver 在 WS 未就绪时对输入栏那条路仍一律 rollback(…, "发送失败,请重试")(「未就绪」与「发失败」仍是两件事);② WsState.Offline(不可恢复)目前也走「等 + 连上后自动发出」,会一直等一句永不到来的「连上」,可改成「需要重新登录 / 检查服务器地址」;③ 待发内容只在进程内存里,一直连不上且进程被杀就丢(既有语义)。
  4. 02 的 §四/回执十九 仍以「修法方向」口吻描述这处缺陷 —— 下次动 02 时补一句「已修 aeb3745」(本次没动它,避免与后端会话的在途追加打架)。
  5. 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、相册多选→分享(图片路径仍需真机)。

★★ 锚点补记十(2026-10-05 夜 · W39 三处「措辞不诚实」已修)

状态

  • android = origin/android = b55d220(W39 合并提交;分支提交 a763592);主树 1101 测试 / 0 失败 / 0 错误。W39 worktree 已清。
  • ⚠️ 本批只有单测与编译证据,没有真机回归(真机那三条留给下次或主人)。

① 分享告知「两句叠加」已消

改前实机原文:「有 2 个文件读不到,未发送**;分享的内容是空的,没有可发送的内容」——读起来像两个不同的问题。 改法:shareNoticeFor 里新增 hasFailedPieces = failedImages > 0 || failedDocuments > 0,isEmpty 那档改成 isEmpty && !hasFailedPieces(ChatScreen.kt:997 / 1002)。丢件那句逐字未改**。 ⚠️ 子代理发现的事实(值得记):加了这个前提之后,「分享的内容是空的」这句一次都不会再出现——因为 worthDelivering 要求 isEmpty 的分享必须带丢件计数或 contentUnreadable,而后者已被「读不进来」那档接走。分支保留并在 KDoc 里写明它已无出口(没删,避免猜错路径时反而丢告知)。

② deliver:未连接与发送失败分开说(只改文案,行为不变)

  • 新常量:SEND_FAILED_MESSAGE = "发送失败,请重试"(逐字不变)、SEND_NOT_CONNECTED_MESSAGE = "还没连上,这条没发出去,请稍后手动重试"。
  • 新纯函数 sendFailureMessage(connection: WsState):Connected 一句、其余四档另一句;SendFailureMessageTest 覆盖 WsState.entries 全部。
  • 接线处取的是 socket 自己的 state(正是 sendChat 判的那个值),不用界面镜像那格,避免晚一帧读成 Connected。回滚、幂等键、无自动重发全部照旧。
  • ⚠️ 没做(单列,等主人定案):让「未就绪」像 W38 的分享那样不取走内容、等连上再发。那会改变普通发送的重试语义(未连接时是留在输入栏,还是自动排队重发),属产品定案。

③ WsState.Offline 不再承诺「连上后自动发出」

  • ShareLanding 加 TellOffline(时机一字未改:仍是等够久才说、同一轮只说一次,只换措辞);新纯函数 shareWaitingNotice(landing) 决定哪档说哪句,Compose effect 只剩一行接线。
  • 文案:SHARE_OFFLINE_NOTICE = "现在处于离线,需要重新登录或检查服务器地址;分享的内容一个字节都没发出去,不会丢" —— 不含「连上」「自动发」「失败」。
  • 仍不 take()、不发送;恢复 Connected 后照旧自动补发(W38 的硬要求未动)。
  • ⚠️ 没做:Offline 时立刻告知(现在仍等 SHARE_WAIT_NOTICE_MILLIS)。属独立改动。

待办(更新)

  1. 主人定案项:普通发送在「未连接」时该不该也自动排队重发(现在是留在输入栏 + 诚实文案)。
  2. 未纳入新判据的一处字面量:ChatViewModel.kt:1552(REST 回执被拒那条路)仍是 rejected.message.ifBlank { "发送失败,请重试" },刻意未动。
  3. Offline 立刻告知(不等 3 秒)。
  4. W38/W39 的真机回归:W38 已验(补记九);W39 这三条只在真机上扫一眼即可(触发条件都少见)。
  5. 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、相册多选→分享(图片路径仍需真机;App 一个权限都没声明 ⇒ adb 造不出可读 URI,这条只能走真机或驱动相册 UI)。

★★ 锚点补记十一(2026-10-05 · W40:普通发送「未连接 ⇒ 进队列」,不再回滚)

状态

  • worktree 分支 feat/W40-send-queue-offline(基线 e60ae7a),分支提交 df9fc85;未合并、未推送(主树与 origin/android 都没动)。
  • 本批证据:assembleDebug 成功;testDebugUnitTest 1118 测试 / 0 失败 / 0 错误 / 0 跳过(83 套件)。没有真机回归(这一批只有单测与编译证据)。
  • 净变化:W39 加的 SendFailureMessageTest(4 条)删掉,其断言改成新事实后并入新的 SendQueueTest(21 条)⇒ 1101 − 4 + 21 = 1118。

① 主人的裁决落地:未连接 ⇒ 进队列(上一批的「待办 1」已定案并做完)

  • 新纯函数 wsSendOutcome(sent, connection)(ui/chat/SendQueue.kt)三档:Dispatched(帧交给 socket;成功路径一个字没动)/ Queued(未就绪四档:Idle / Connecting / Reconnecting / Offline)/ Failed(连接就绪却仍发不出去 ⇒ 今天那条 rollback + SEND_FAILED_MESSAGE)。
  • deliver 的 WS 分支只改「未就绪」这一支:不回滚、不进 error 横幅,改由 queueForConnection 留在待发态(ChatUiState.queuedForConnection:条目只有键与并行声明,正文仍从气泡上现取)。
  • 触发点:onConnectionChanged 里 connection == WsState.Connected 那一格(由 socket.state 驱动,无轮询)+入队后的竞态兜底。
  • 顺序:FIFO = 用户发送的先后顺序;补发只看队头。只发一次:队头 + 出队恰好一次(dropQueuedSend);第二道是补发复用同一个 clientMsgId(服务端 chat_duplicate 判重)。flushQueuedSends 是同步函数(无挂起点),两个触发点不会交错。
  • ⚠️ 已知边界:打断声明(interrupt{scope:"current"})不随补发重放(补发时在跑的那一轮早已不是用户按发送时看到的那一个);内容仍只在进程内存里,一直连不上又杀进程就丢(既有语义)。

② 状态表达:③ 排队中 + 小标「还没连上」,不新增第七态

  • phaseOf(待发气泡) 的 ③ 判据扩为「收到过入队回执 或 本端因未连接留住」;气泡小标由新纯函数 pendingBubbleFooter 给:排队中 · 还没连上(服务端忙时排队仍是 排队中)——挂在气泡上而不是只靠告知,因为告知 8 秒就收走,而这是会持续几十秒的状态。
  • 柔和告知走新通道 ChatUiState.sendQueueNotice(与 shareNotice 分开:两条说不同的动作):SEND_QUEUED_NOTICE = "还没连上,这条排队等着,连上后自动发出";Offline 那一档换 SEND_QUEUED_OFFLINE_NOTICE = "现在处于离线,需要重新登录或检查服务器地址;这条还没发出去,已排队不会丢"(不提「连上」「自动发」)。不刷屏:一轮「队列从空到空」只说一次。

③ REST 通道(长文 / 图片 / 附件)未覆盖,仍是回滚 + 网络错误文案

理由:它给不出「未连接」这个信号(只有一次网络错误,请求可能已被服务端收到),且补发要重跑图片重编码与附件上传(本地 URI 授权可能已失效)。影响面仅这一条通道。

待办(更新)

  1. W40 的真机回归:断网发普通消息 ⇒ 应看到「排队中 · 还没连上」+ 柔和告知(没有红色横幅、输入栏不退回)⇒ 恢复连接 ⇒ 自动补发且只落库一条。
  2. REST 通道(长文 / 图片 / 附件)未连接时要不要也排队——需主人定案(代价见 ③)。
  3. Offline 立刻告知(不等 3 秒)——W39 遗留,未动。
  4. 未纳入新判据的一处字面量:REST 回执被拒那条路仍是 rejected.message.ifBlank { "发送失败,请重试" },刻意未动。
  5. 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、相册多选→分享(只能走真机或驱动相册 UI)。

★★ 锚点补记十二(2026-10-05 深夜 · AVD 调参第二轮:尺寸/核数/无窗口)

状态

android = origin/android = 31e8e31(W40 合并提交);主树 1118 测试 / 0 失败 / 0 错误;worktree 只剩主树。

先纠正一条容易走错的路:硬件加速本来就是开着的

模拟器日志:WHPX on Windows 10.0.26100 detected + Windows Hypervisor Platform accelerator is operational;CPU VirtualizationFirmwareEnabled=True。 ⇒ 慢不是「没吃到加速」,别往那个方向修。

本轮改了什么(Pixel_10_Pro_XL.avd/config.ini)

项 改前 改后 理由
hw.lcd.width/height/density 1344×2992 @480 1080×2340 @440 像素少 ~37%,Compose 每帧重绘直接变轻
hw.cpu.ncore 6 5 宿主只有 6 个物理核;给满会与宿主机互相抢(WHPX 下超额分配会互相等)
skin.name / skin.path / skin.dynamic 指向 pixel_10_pro_xl 皮肤 整三行删除 皮肤会把屏幕尺寸钉死,hw.lcd.* 完全不生效(实测 wm size 仍是 1344×2992);删掉后 wm size 才是 1080×2340
showDeviceFrame yes no 无皮肤时没必要画外框
备份留在 app/build/e2e-shots/config.ini.bak(改坏了可原样还回去)。
hw.ramSize=8192 / vm.heapSize=512 沿用上一轮(那一轮把 iowait 196%→14%、swap 457MB→0,是本项目至今最大的一次性能收益)。

❌ -no-window 不要用(这条与直觉相反,实测证据)

无窗口启动时模拟器把渲染降级成软件 GL:日志 emuglConfig_init: vulkan_mode_selected:lapvaipe gles_mode_selected:swiftshader ⇒ 省了宿主窗口开销,却丢了宿主 GPU(原先是有窗口时的 vulkan:host / gles:host,用的是 AMD RX 6600 XT)。 对以 Compose 重绘为主的负载,这是倒退。⇒ 保留窗口跑。

结论:真机做验证、模拟器做冒烟与看日志

全系统模拟本来就比真机慢 510 倍,宿主是 6 核 Zen3,再压也压不出真机的手感。其余可做的:测试期间别同时跑 Gradle / 别开 Docker 与浏览器(今天就有一次构建把 App 饿到 ANR);保持快照启动(已开)。 「换别的虚拟机」这条路对这个项目基本走不通:WSA 微软 2025 已停服;MuMu/LDPlayer/BlueStacks 多是 Android 912,而我们 minSdk 33 至少要 Android 13 才装得上;Genymotion 个人版付费且镜像滞后;Waydroid 只有 Linux。官方模拟器 + 真机仍是最优组合。

待办(本节的)

  • 新尺寸下没重跑任何设备验证(App 已装、登录态在;下次做设备测试时留意旧的点击坐标全部作废,要按 1080×2340 重新取样)。

✅ 补:W40 的真机回归已做(2026-10-05 深夜,1080×2340 新 AVD,包 31e8e31 构建)

手法(可复现):adb kill-server + start-server 真断线(adb reverse 被清空)→ 在输入栏打 q1 并点发送 → 再 adb reverse tcp:10942 tcp:10942 恢复。

阶段 结果
断线时 顶栏「重连中…」;柔和横幅 「还没连上,这条排队等着,连上后自动发出」;没有红横幅、输入栏没有退回;库里 q1 0 条
恢复后(等 ~55 秒,含退避) 库里 q1 恰好 1 条(cli=local-bccbb362-7…、turn-d2b6457a)⇒ 自动补发、顺序 FIFO、只发一次
⇒ W40 主人定案的「未连接进队列」在设备上成立。

📍 新 AVD(1080×2340 @440dpi)的实测点击坐标(省得下次重新取样)

目标 坐标
输入框 (560, 2170)
发送按钮 (950, 2170)
抽屉(左上菜单) 按 1080 宽重算,约为 (86, 188)(未实测,下次用前先截图确认)
注意:软键盘仍是禁用状态(ime disable 写在 userdata 里,跨重启有效),所以布局不会因 IME 位移;这是刻意的(见补记八第 1 条坑)。

★★ 锚点补记十三(2026-10-06 凌晨 · W41 三级阶梯:未连接时先探 health,通就发出去)

状态

android = origin/android = eb22521(W41 合并提交;分支提交 9f77fb4);主树 1135 测试 / 0 失败 / 0 错误。W41 worktree 已清。 ⚠️ 本批只有编译 + 纯判据单测证据,没有真机回归(子代理按任务书禁碰 adb)。

主人给的思路(比原来那版好)

我原先只做了「未连接 ⇒ 进队列」。主人指出:「未连接状态你就不能拿 httpAPI 尝试吗?」→「可以走 health 端点验证连通性」。 关键理由(我原来那份评估的漏洞):我曾担心「REST 分不清有没有送达,所以不敢重发」—— 而这条已被 clientMsgId 幂等解掉(服务端按 (session_id, client_msg_id) 唯一索引仲裁,重复到达只回 chat_duplicate)。既然重发安全,就没有理由不试。

三级阶梯(ui/chat/SendFallback.kt 的 sendRouteFor)

  1. WS 已发出 ⇒ Ws(连接状态与探测都不再参与;成功路径零改动)
  2. WsState.Offline ⇒ Queue,连探测都不做。理由:该档语义是「握手 401 且续期也失败」,会话已知是死的;而 /api/v1/health 不鉴权(routes.go:51、rest.go:219-221 只回 {"status":"ok"})⇒ 探它必然 200 ⇒ 会发一次注定 401 的 REST,把「已排队不会丢」换成一次回滚 + 一句误导人的鉴权错误。
  3. 未就绪(Idle/Connecting/Reconnecting)+ 探测通 ⇒ Rest:回落 POST /api/v1/chat 把消息当场发出去,不进队列。
  4. 其余一切(不通 / 超时 / 抛异常 / 还没探过)⇒ Queue(fail-closed,null 与「不通」同路)。

超时:探测 2500 ms(callTimeout 覆盖 DNS+连接+写+读);回落 REST 另加 10000 ms 上限。 无网时的上界:WS 就绪 = 0 次探测;Offline = 0(不探);完全无网 ⇒ ≤ ~2.5 秒进队列且一次 REST 都不会发;探测通但 REST 卡住 ⇒ ≤ ~12.5 秒后进队列(气泡全程「发送中…」,不是无响应)。

✅ 它顺手补掉了一个真洞(值得记)

「回落 REST 之后回复怎么回来」要求核实 —— 结论是能回来(catchUp → loadAfter → absorb,ChatViewModel.kt:923/924-932/2091/2106 + WS 握手 history 兜底), 但发现 gapCursor 只在「离开 Connected」那一刻记:若本 VM 从没连上过(进会话时就是断的)它为 null,重连那一步什么都不会拉,而回落那一轮的回复恰好只能靠它。 ⇒ 新增 armGapCursorForFallback(:1614-1615),只在回落被受理后、且游标还是 null 时补记一次(绝不改写掉线时记下的起点)。 ⚠️ 仍缺的一段(它如实写了,没假装补上):本端一条消息都没有时(newestKnownAt == null)游标补不上 —— after=0 会从最旧方向拉,20 轮上限停在最旧 1000 条上,正好错过要看的那条。主会话由 history 的 50 条兜住;次级会话 + 首屏为空 + WS 从未连上过这个组合下要用户自己刷新。

边界(它核实后写的,别记错)

  • 有图 / 有附件的消息走不到这条阶梯:ChatSendRouting.channelFor 对「有图或有附件」恒回 Rest,而只有 WS 那一支才进阶梯 ⇒ 进阶梯的必然是纯文本。 ⇒ 不存在「本地 URI 授权失效」「图片重编码」被这条新路踩到的可能;有图/有附件的行为与 W40 之前逐字相同。
  • REST 失败分档:null(超时/映射不出)与网络族(NoNetwork/Dns/Timeout/ConnectFailed)⇒ 进队列;Server/Unauthorized 与配置族 ⇒ 仍回滚 + 文案(服务端给过裁决、等下去不会变好)。
  • 队列仍不持久化(进程被杀就丢)——W40/W41 一致,未改。
  • ⚠️ 「任何 REST 失败都进队列」没做(主人若指这个,需先设计「补发时怎么安全重放图片/附件」):本次只覆盖阶梯第 2 步那次回落 REST。

顺带改了两处(任务书外,有 KDoc/commit 说明)

devtools/fake-backend 补上 GET /api/v1/health(否则本机跑假后端时探测吃 404、阶梯退化成直接排队);补发由 W40 的「同步无挂起点」改成单飞协程 + 串行出队(阶梯要挂起,必然)。

待办(本节的)

  1. W41 的真机回归:用假后端 --hold-replies + 断 WS 但留着 HTTP,看是否真走 REST 出去、重连后回复是否补齐。
  2. 「任何 REST 失败都进队列」若要做,先设计图片/附件的安全重放。
  3. gapCursor == null 且本端为空那条残留洞:可加「重连时 loadLatest 一次」兜底。
  4. W42(图片按钮改多选)已派单(feat/W42-image-multipick,基线已追到 eb22521)。

★★ 锚点补记十四(2026-10-06 凌晨 · W42 图片入口改多选)

状态

android = origin/android = 4355ebd(W42 合并提交;分支提交 5a9f52a);主树 1143 测试 / 0 失败 / 0 错误。W42 worktree 已清。 ⚠️ 只有编译 + JVM 单测证据,没有真机(多选器交互与缩略图观感未经仪器测试)。

改了什么

  • PickVisualMedia()(单选)→ PickMultipleVisualMedia(ShareRules.MAX_IMAGES)(= 4),仍然零权限(没退回 SAF/存储授权)。回调变 onImagesPicked(List);发送键可用性判据改看列表。
  • ChatUiState.draftImage: String? → draftImages: List<String>;输入栏上方的预览从一枚键变成每张各自一枚移除键(顺序即发送顺序);图片入口在攒满 4 张后隐藏(先移除才能重选)。
  • 切分抽成唯一出处:新增纯函数 ui/chat/ImageSendPlan.kt 的 imageSendPlan(text, images, documents)。 判据:N=0 ⇒ 1 条(正文+附件);N=1 ⇒ 1 条(正文+图+附件);N=2…4 ⇒ N 条(第一条带正文+第一张图+全部附件,其余每条空正文+一张图);N>4 ⇒ 只切 4 条,超限张数如实报出。
  • 复用而非另起:分享路径原本那段切分是 sendShared 里内联的 forEach(不是纯函数),本次把它抽成 imageSendPlan + 私有 emitPlannedMessages,输入栏那条路与分享落地共用同一段。每张图的解码/缩放/重编码与「内联 or /upload」仍逐张走既有 emitAndDeliver → deliver → deliverImage。
  • 超限文案抽成 ShareRules.truncatedImagesNotice(kept, dropped)(逐字沿用既有那句),分享路径与图片入口共用。

⚠️ 两处要主人过目(子代理主动交代的)

  1. N≥2 时「她忙时发消息」偏好只套用在第一条(applySendPreference && index == 0)。理由:第 2 条起声明打断=打断自己刚起的上一轮。 替代方案是像分享路径那样整批不声明(改一行)。N=1 时与今天逐字节相同,所以这是个只影响新形态的选择。
  2. 文案措辞:那句超限告知原文以「分享的图片」开头,现在也会出现在图片入口(那里并没有"分享")。任务书要求逐字沿用,所以照做;要改成入口中性的措辞属新文案,需主人拍板。

待办(本节的)

  1. W42 真机:多选器交互、输入栏那排缩略图观感、N 张落成 N 条的落库形态(模拟器或真机都行)。
  2. 上一条那两个决定等主人一句话。

★★ 锚点补记十五(2026-10-06 · W43 + W41/W42 的拍板结果 + 一次没跑成的回归)

状态

android = origin/android = 3c1d3fd(W43 合并提交;分支提交 5556573);主树 1150 测试 / 0 失败 / 0 错误。worktree 只剩主树。

主人 2026-10-06 的四条裁决

指令 落点
N≥2 时「忙时发消息」偏好只套第一条 ⇒ 按我推荐 保留现状(唯一不会「打断自己刚起的上一轮」的选项)
超限文案改中性 ⇒ 按我推荐 W43 已改(见下)
跑 W41 回归 没跑成(见下,是操作问题)
长文/图片/附件的 REST 失败放着不做;但加一条:多次重试失败就静默 W43 已实现

① 超限告知新文案(逐字,两处共用同一句)

一次最多 4 张,超出的没发,只发送前 3 张

($MAX_IMAGES / $kept 插值;dropped == 0 时仍回 null。)说清三件事:上限 4 张、超出的没发、发出去的是前 N 张;无「失败」「错误」,无「分享」。

②「多次重试失败就静默」

  • 阈值 3(QUEUED_RESEND_SILENCE_THRESHOLD,ui/chat/SendQueue.kt):第 1 次失败必说,2、3 次仍允许说,之后不再说(取 1~2 等于没告知,故禁止)。
  • 记账口径:本会话的一个补发批次(队列从空到非空那一段),落在 VM 实例字段 ⇒ 天然按会话,无全局单例。只计「补发没能上路、内容仍留在队列里」那种失败。
  • 清零:补发真的成功一次就归零(dropQueuedSend(sent = true));回滚/被收编那两种出队不清零。
  • 静默 ≠ 丢弃:这个数只进告知判据,queuedForConnection / nextQueuedSend / 出队逻辑一行未动;连接一好照旧按序补发(同一个 clientMsgId)。测试 silence never touches the queue 钉住。
  • ⚠️ 一处要主人过目的边界(子代理主动交代):静默一旦生效会持续到某次补发真正成功,不是「每轮结束就恢复」;期间气泡的「排队中 · 还没连上」小标照常可见。要改成「每轮结束恢复告知」只改一行(清零点从 sent=true 移到 forgetQueuedNoticeIfDrained)。

⛔ W41 的真机回归:没跑成(如实记,结论是「未验证」)

场景造出来了,而且判据很干净:我写了个只掐 /ws/chat 的窄代理 app/build/e2e-shots/ws_block_proxy.py,adb reverse tcp:10942 tcp:18098 把 App 引过去。代理日志证明:

  • BLOCKED GET /ws/chat?token=… ×9(WS 确实断了,App 在退避重试)
  • PASS GET /api/v1/sessions/web/messages?limit=50 / meta/enums / sessions / personas -> 200(HTTP 确实通)

但代理日志里一条 /api/v1/health 都没有 ⇒ App 从来没探过健康端点 ⇒ 消息压根没发出去,三级阶梯一次都没被触发。 原因在我:第一次软键盘是弹着的(布局上移,坐标落到键盘上);第二次禁用 IME 后重试仍未命中(大概率输入框没拿到焦点,而我没先确认就复用了坐标)。 ⇒ 不能写成「通过」,也不能写成「失败」——它是「未验证」。

可复用资产与教训:

  • ws_block_proxy.py 值得留着:因为 Web 前端直连 127.0.0.1:10942、不经过 adb reverse,所以代理日志里的每一行都只可能来自 App —— 这是分辨「App 到底发了什么」的最干净判据。
  • 打字前必须先截图确认输入框已聚焦,别复用坐标(这条我今天栽了两次;补记八第 1 条的黑屏坑是同一类)。

🔴 补记十五·补(2026-10-06 · W41 回归重跑成功,并且找到一个真缺口)

场景复现成功(ws_block_proxy.py + adb reverse tcp:10942 tcp:18098,表头显示「重连中…」而不是「离线」)。这次消息真的发出去了——但它进了队列,不是回落 REST。

判据(uiautomator dump 原文,决定性):

text="" class="android.view.View" clickable="true" enabled="false" bounds="[867,2130][1047,2268]"
     └ text="发送"                                     ← 按钮 disabled(因为草稿已清空)
text="w41c"      … bounds="[891,1916][1002,1970]"     ← "w41c" 不在输入框,而是一条待发气泡
text="排队中 · 还没连上" … bounds="[792,2015][1024,2059]"  ← 正是 W41 新增的角标 ⇒ 它进了 queuedForConnection
EditText … 占位文案「和昔涟说点什么…」                  ← 输入框是空的

代理日志(只可能来自 App):BLOCKED /ws/chat ×N、sessions / messages / enums / personas 全 200,但一条 /api/v1/health 都没有。

结论

  1. 路由选了 Queue 而不是 Rest —— 在「WS 断、HTTP 通……」这个正是它为它而写的场景里,三级阶梯没有生效。
  2. 探测请求根本没发出去(代理日志一条都没有)。而表头是「重连中…」(Reconnecting)不是「离线」,按 sendRouteFor 判据应该去探测。 ⇒ 最可能:探测在「发出请求之前」就失败了,被 probeReachability 外面的 catch(Exception) → Unreachable 兜住 ⇒ 落到 fail-closed 的 Queue。 ⇒ 下一步该查:data/api/ApiClientFactory.kt 的 createProbe(它是否真能建出可用的 client?baseUrl / 拦截器 / callTimeout 是否与主链路一致?)与 data/repository/ConnectivityRepository.probe 的错误映射。 ⚠️ HTTP 侧本身是通的(sessions/messages/enums/personas 都 200 走过同一个代理)⇒ 不是「代理挡住了探测」。
  3. 这不是本轮新增的 bug:W41 只在编译 + 纯判据单测层面验过(子代理明确说了没做真机)。这正是「真机回归」存在的意义 —— 纯函数判据全绿,运行时接线却断了。
  4. 顺带坐实一条既有边界:那条被排进队列的消息在我 force-stop 之后跟着没了(队列只在进程内存里)——与 W40/W41 文档一致,不是新问题。

另外两轮的失败原因(免得下次又猜)

第 1、2 轮之所以「什么都没发生」:前台根本不是我们的 App,而是 com.android.settings/.SubSettings(dumpsys window 看出来的)。所以我的点击全落在设置页上。 ⇒ 教训升级:不只要确认输入框聚焦,还要先确认前台就是我们的 App(dumpsys window | grep mCurrentFocus)。

W44(2026-10-06 · W41 那个「缺口」的判定:A 已否,B 只到形态,根因要一次设备取证)

分支 feat/W44-probe-diagnosis(基线 866a3c3)。本轮没动任何语义:sendRouteFor 的 fail-closed、探测的 2.5 秒上限、三档路由一个字都没改——判定的结论是「代码这条路是对的,失败生在连接阶段」,而那一档在本机看不出成因。

1. 假设 A(503 被判成 Offline、界面镜像滞后)——否

ChatSocket.isAuthFailure(app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt:320-323)只认 response.code == 401(拿不到 response 时才退化成消息文本含 401);非 101 的握手响应(代理回 503)落到 SessionEnd.Closed ⇒ Reconnecting + 退避重连(同文件 :301-304、:230-235)。而顶栏副标题「重连中…」正是 WsState.Reconnecting 的文案(ui/chat/ChatScreen.kt:918,Offline 那档是「离线」)。 ⇒ 那一刻 socket 的真值就是 Reconnecting,sendRouteFor 在这一档会看探测结论,界面镜像也没有滞后。A 不成立。

2. 假设 B ——形态成立,但「这条链根本发不出请求」不成立

  • 调用点是无条件求值的实参:sendRouteFor(wsDispatched = false, connection = state, reachability = probeReachability())(ui/chat/ChatViewModel.kt:1614)⇒ 只要走到 WsSendOutcome.Queued,探测一定被调用(连 Offline 那一档也会,sendRouteFor 只是把结论丢掉——这一点与本轮前的 KDoc 说法相反,已改)。
  • 新增 app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt(6 例,真 OkHttp + 真 Retrofit + JDK HttpServer,与 ChatRepositoryTest 同一手法):证实这条链能在真 socket 上留下 GET /api/v1/health、不带 Authorization、200 ⇒ Reachable、503 ⇒ Unreachable、2xx 但回执解析不出来 ⇒ Unreachable、黑洞服务端在 2.5 秒上限处失败。⇒ 「探测压根发不出去」不是代码事实。
  • 但真机代理日志里一条 /api/v1/health 都没有 ⇒ 那个 Unreachable 是在连接之前产生的。而它在外面完全同形(三种成因写在 data/repository/ConnectivityRepository.kt 类注释新增的那一节):
    • (甲) 建 client 就抛:baseUrl 为空或格式不合法时 Retrofit.baseUrl 直接抛,被 probeReachability 的 catch (Exception) 折成不通。socket 上一个字节都没有。
    • (乙) 连上了、一个字都没写:createProbe 的 callTimeout 是整通上限(含连接),而探测从不复用连接池 ⇒ 每次都要现做一次冷握手,预算只有 2.5 秒,比共用客户端的 10 秒连接预算紧 4 倍。连接比它慢时 OkHttp 在写请求之前就放弃了 ⇒ 对端只看到「连上又不说话」,日志里一条请求行都不会有。
    • (丙) 请求出去了、回执非 2xx(或回执解析不出来):只有这一档在对端留痕。 ⇒ 所以「代理日志里没有 health 那一行」推不出「App 没探过」;(甲)(乙) 都不留痕。上一轮结论里那句「App 从来没探过健康端点」需要收回一半:没探过是可能的,(甲)(乙) 也同样符合全部现有证据。

3. 本轮改动(除一处 DEBUG 日志外,全部是文档/测试;无行为变化)

  • 新增测试 app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt(6 例)。
  • data/api/ApiClientFactory.kt 的 createProbe:DEBUG 下挂 BASIC 日志(与通用 create 同一约定)。这是本轮要的诊断仪:--> 打在连接之前,所以「到底有没有探」与「死在连接还是死在回执」当场可分。探测无令牌、无请求体,日志不涉凭据。
  • KDoc 失真修正:ui/chat/SendFallback.kt 的 sendRouteFor(「连探测都不做」只在判据层面成立)、ChatViewModel 的调用点与 probeReachability、ConnectivityRepository 补「三种成因 + logcat 判据」、data/ws/WsState.kt 的 Offline 补「非 401 的握手失败不属于这一档」。

4. 下一步(要设备,一次就能定论)

  1. 同一套场景重跑(ws_block_proxy.py + adb reverse tcp:10942 tcp:18098),在输入栏发一条纯文本。
  2. 看发消息那一刻的 adb logcat -s OkHttp(探针客户端走 OkHttp 的 Platform 日志):
    • 没有 --> GET http://127.0.0.1:10942/api/v1/health ⇒ (甲):死在建 client,去查 App 里配置的服务器地址(空 / 缺 scheme / 有非法字符)。
    • 有 --> 后跟 HTTP FAILED: java.io.InterruptedIOException: timeout ⇒ (乙):2.5 秒整通上限被连接吃掉。(不许为此放宽 fail-closed;要不要让探测的连接预算与链路对齐,是主人裁决。)
    • 有 --> 后跟 HTTP FAILED: java.net.ConnectException ⇒ 对端根本没在听(adb reverse 被冲掉 / 代理没起)⇒ 这不是 App 的 bug。
  3. 同时确认发完消息之后代理日志里还有没有新的 BLOCKED /ws/chat:没有就说明路已经断了(⇒ 第 2 条的第三种),那一轮阶梯其实从没在「HTTP 通」的条件下被考过 —— W40 那一轮就是用 adb kill-server/adb root 造「真断线」的,这两件都会把 adb reverse 一起冲掉(见本文件 W39/W40 两处踩坑记录)。

★★ 锚点补记十六(2026-10-06 · W45 把「先探 health」换成「直接试 REST」)

状态

android = origin/android = 1d548dd(W45 合并提交;分支提交 ec8d54f);主树 1153 测试 / 0 失败 / 0 错误。W45 worktree 已清。

⚠️ 先说作废:本文件里关于「连通性探测」的描述全部不再成立

下面这些句子是历史记录,保留但已作废,别再照着做:

  • 补记十三(W41):三档路由里「Offline 连探测都不做」「未就绪 + 探测通 ⇒ Rest」、探测 2500 ms(callTimeout)+ 回落 REST 10000 ms、无网上界那段「0 次探测 / 一次 REST 都不会发」——探测这套机制已被删除。
  • 补记十三里「fake-backend 补上 GET /api/v1/health(否则探测吃 404)」——理由随探测消失(那条路由仍在假后端里,属无害的开发夹具,未清)。
  • 补记十五·补 + W44 那一整段:probeReachability / createProbe / ProbeClientTest / sendRouteFor(..., reachability = …) 以及「logcat 看 --> GET /api/v1/health」的判据全部作废。 保留的结论只有一条:那个 Unreachable 生在连接之前——正是它逼出了这次替换。

W45 做了什么(设计替换,不是修 bug)

判据:sendRouteFor(wsDispatched, connection)(ui/chat/SendFallback.kt:112)——

  1. wsDispatched ⇒ Ws(成功路径逐字不变)
  2. connection == Offline ⇒ Queue,一个请求都不发(保留 W41 的理由:该档是「握手 401 且续期也失败」,试了只会拿 401 并落到回滚,比「已排队不会丢」更差)
  3. 其余三档 ⇒ Rest(直接试)

结果分档:restAttemptLanding(attempt)(:206)四档 —— null(整通上限到点)⇒ Unsent 进队列(fail-closed);success 且 isAccepted() ⇒ Accepted(不进队列);success 但被拒 ⇒ Refused(回滚 + 既有文案);failure ⇒ 交给 restFailureLanding(网络族/映射不出 ⇒ Unsent;Server/Unauthorized/配置族 ⇒ Errored 回滚)。 判据里已没有任何 Reachability 输入(签名本身就是这条事实)。

两个超时(与任务书的措辞有出入,取舍已写进 KDoc)

  • 连接超时 2.5 秒挂在那个共用 client 上(data/api/ApiClientFactory.kt:39):短到「无网快速进队列」,又不会连累长文本/图片/文档下载。
  • 整通上限 10 秒仍在调用点(REST_FALLBACK_TIMEOUT_MILLIS + withTimeoutOrNull,ChatViewModel.kt:1662)。 为什么没做成 client 级 callTimeout:这个 client 是共用的,client 级 callTimeout 会把长文本 / 内联图片那两条(本轮不碰的路)一并框住,还连累 DocumentsApi 的字节下载 ⇒ 等于给它们引入新失败面。而「无网快速进队列」只靠连接超时就保住了。
  • 无网上界:完全无网 ≤ ~2.5 秒进队列(与 W41 持平,没有变长)。

一条被证伪的担心(记下来免得再犯)

我曾担心「stream:true 的回执是内容流,所以要先关掉 stream 才能判『收下了』」。W45 核实后不成立: POST /api/v1/chat 的 stream:true 回执是小 JSON(chat_stream_dispatch.go:201 的 writeChatStreamAck:200 {stream:true} / 202 {queued:true} / 200 {duplicate:true} / 409 / 503),回合跑在自己派生的 goroutine 上,回执写完即返回 ⇒ 读完这段 body 就有 ChatAck,不等整轮生成、不用改契约。

删了什么 / 测试怎么承接

删除(删前 grep 全仓确认无其它调用方):data/repository/ConnectivityRepository.kt、data/api/HealthApi.kt、data/api/dto/HealthDto.kt、ProbeClientTest.kt、ApiClientFactory.createProbe 与 PROBE_TIMEOUT_MILLIS、AppContainer 的 healthApi/connectivityRepository 接线。 新增 RestAttemptClientTest(6 例,真 OkHttp + 真 Retrofit + JDK HttpServer)承接仍然成立的事实:请求真的写出去(现在是 POST /api/v1/chat)、请求体带 clientMsgId/stream:true、这次带令牌(Authorization + X-API-Version,与探针相反)、三种「收下了」的回执 ⇒ Accepted、409 ⇒ Refused、连不上 ⇒ 快速失败且 Unsent。 丢掉的四条(钉的都是已不存在的机制):GET /api/v1/health 落到线上、探针不带令牌、200 但 data 空 ⇒ Unreachable、探针 callTimeout 处失败。fail-closed 这条事实没有丢(由「整通上限到点」与「异常映射不出」两条承接)。 唯一略降级:连接超时的数值现在由纯断言钉(2s ≤ x ≤ 3s),运行时那侧只用「没人听的端口 ⇒ 很快失败 ⇒ 队列」兜住。

可观测性(下一轮设备验证就靠它)

主 REST client 的 OkHttp BASIC 日志本来就开着(ApiClientFactory.kt:88-94,DEBUG 下所有 create 都挂),不需要改动:

--> POST http://<host>/api/v1/chat (NNN-byte body)
<-- 200 OK http://<host>/api/v1/chat (12ms, 41-byte body)

失败时第二行是 <-- HTTP FAILED: java.net.ConnectException…(或被短连接超时掐断的 SocketTimeoutException: connect timed out)。 --> 打在 chain.proceed() 之前(连接都还没开始建)⇒ 「这条请求有没有发出去」与「死在连接还是死在回执」当场可分。 BASIC 不打印任何请求头(HEADERS/BODY 才会;redactHeader("Authorization") 是双保险),级别没有上抬。 注:WS 那条连接(ChatSocket)没挂日志,所以 -s OkHttp 里出现的都是 REST 调用——正好是下一轮要的判据。

⚠️ 一条事故记录(已核实无害)

W45 子代理的文件工具把相对路径解析到了主仓库(不是它的 worktree),最初三次编辑落在主仓的 AppContainer.kt / ApiClientFactory.kt / SendFallback.kt 上。它自己发现后用 git checkout -- 还原,并用绝对路径重做。 我亲自核过:主仓 git status 只有 devdocs/02(后端会话的在途追加)、HEAD 仍是当时的 2bee0b5、那三个文件 git diff HEAD 零差异 ⇒ 确实还原干净。 教训:给子代理的路径一律写绝对路径,且收工后必查主仓 git status。

待办(更新)

  1. 🔴 设备验证(下一轮第一件):ws_block_proxy.py + adb reverse tcp:10942 tcp:18098(WS 断、HTTP 通),发一条纯文本 ⇒ 用 adb logcat -s OkHttp 确认 --> POST /api/v1/chat 与 <-- 200,并确认库里落库、没进队列。先 dumpsys window | grep mCurrentFocus 确认前台是我们的 App(补记十五·补的教训)。
  2. devtools/fake-backend 里的 GET /api/v1/health 路由与 README 的说法现在是死的(无害夹具,未清)。
  3. 待主人过目:静默持续到某次补发成功 vs 每轮结束恢复告知(改一行)。
  4. 待办:子会话流程跑一遍出清单;W42 多选器真机观感。

★★ 锚点补记十七(2026-10-06 · W45 那条阶梯验通了,以及我连续三次的误判)

状态

android = origin/android = 16699c4(W46 合并提交;分支提交 d546f31);主树 1155 测试 / 0 失败 / 0 错误。W46 worktree 已清。02 里另有一笔已推的登记(b8399b5,工具卡缺数据面,见下)。

✅ 设备验证:通过(判据三条独立)

场景:ws_block_proxy.py 只掐 GET /ws/chat(回 503)+ adb reverse tcp:10942 tcp:18098;新包(含 W46 的 CyreneRoute 日志);输入栏发一条纯文本。

CyreneRoute: wsDispatched=false socket=Reconnecting mirror=Reconnecting wsObject=absent route=Rest reason=ConnectionNotReady outcome=Queued fromQueue=false
代理日志: PASS POST /api/v1/chat -> 200
库里: 恰好 1 条

⇒ route=Rest ⇒ 直接试 REST ⇒ 请求真的发出去(代理看到 200)⇒ 消息落库。 W45「把探测换成直接尝试」成立。 顺带把 W41 那个「探测不可用」的缺口绕过去并消掉了(那套机制已被删除)。

⚠️ 我在这一串里连着错了三次(必须记下来)

  1. 「App 从来没探过 /health」——推不出来(W44 证明:Unreachable 生在连接之前,三种成因在对端同形)。
  2. 「零请求 + 进队列 ⇒ 只能是 Offline 那一档」——错。这次日志给出的真实答案是 route=Rest,REST 确实发出去了。
  3. 前几轮「发送没发生」的结论——错,有两次是我点在了 com.android.settings 上(前台不是我们的 App),有一次消息其实进了队列(我当时只查了网络侧、没看界面)。

共同点:我拿「对端日志缺席」当「客户端没做」的证据,而中间还隔着几层我看不见的东西。 纪律(写死):① 设备验证第一步永远是 dumpsys window | grep mCurrentFocus 确认前台是自己的 App;② 要判「客户端做了什么」,优先看客户端自己的输出(CyreneRoute / logcat -s OkHttp),不要从代理/后端日志反推;③ 一次只改一个变量。

一条仍未解释的旧现象(如实留着)

W45 那次(同一场景)气泡停在「排队中 · 还没连上」、代理没有 POST、库里没有那条。与本次唯一可见的差别是时机:那次我在 App 启动后 ~55 秒就发了(正撞上启动期的请求洪峰),这次 App 已稳定运行一分多钟。 最可能的解释:route=Rest 但那次 REST 死在整通上限 10 秒(被启动期拥挤的连接/续期吃掉)⇒ Unsent ⇒ 进队列。未经证实,W46 的日志下一次复现时可以直接读到(看 route 与 reason,再看 OkHttp 有没有 --> POST)。

W46 留下的可观测性(以后常备)

ChatViewModel.sendThroughLadder 一进入就一行 Log.d("CyreneRoute", …)(仅 DEBUG): wsDispatched=<bool> socket=<WsState> mirror=<WsState> wsObject=<present|absent> route=<SendRoute> reason=<SendRouteReason> outcome=<WsSendOutcome> fromQueue=<bool>

  • socket 是权威值(ChatSocket.state.value),mirror 是顶栏读的那个镜像 —— 一行就能分辨「镜像滞后」。
  • reason 三档:FrameDispatched(走 WS)/ ConnectionOffline(进队列,一个请求都不发)/ ConnectionNotReady(直接试 REST)。
  • 这是全仓唯一一处 android.util.Log,引入理由:必须是稳定可 grep 的固定 tag,而 OkHttp 的 BASIC 日志只照得到 REST 侧、照不到这条判据。
  • SendFallbackTest 有「结果 + 原因」的全表测试。
  • 复核结论:WsState.Offline 的写点只有两处(ChatSocket.kt:233 ConfigMissing、:284 鉴权失败),503 到不了 Offline —— 所以「零请求 + 进队列」在纯函数层面只有 ConnectionOffline 一条,本次实测也确认了不是它。

另外:主人报的「工具调用记录回合结束后消失」——根因已定,已登记给后端

数据面缺失:工具卡的数据只活在流式路的本地状态里;助手行落库时客户端把整条路丢掉,而落库消息 DTO 里没有工具字段可渲染。 证据是同一段代码里的对比:思维链有「打捞 + 滞留 10 分钟」的补救(因为 DTO 里有 thinking),工具一个字段都没有。 不是我方本地状态的 bug(我方与 devdocs/03:141「thinking / tool_call:仅 WS 实时有」逐字一致)。后端其实已经把工具调用落进 flow_graphs,缺的是「取出来给聊天端」。 已登记进 02(b8399b5):要可选字段(omitempty,缺席=老行为逐字不变)、覆盖面对齐 turnId、归属要明确、允许开独立端点。 我方接线点:目前 ToolCard 在「已落库消息」那条渲染路径上是不可达代码(渲染器签名里没有 tools 参数)——字段一到即可接。

★★ 锚点补记十八(2026-10-06 · W47 队列静默改为「按事故复位」)

状态

android = 合并提交见下;分支提交 ce50bd2;主树 1158 测试 / 0 失败 / 0 错误。W47 worktree 已清。

主人裁决

「静默持续到某次补发成功(A)vs 每批结束复位(B)——哪个从体验上更好就用哪个」。判定:B,但把 B 改准成「按事故复位」。 为什么不选 A(原实现):A 只在「某次补发成功」时清零 ⇒ 若一个坏网络时段的队列最后以回滚/被收编结束(而不是以成功结束),静默会一直挂着;之后连接恢复、再断一次、用户又发消息时永远得不到解释。那不是减少噪音,是把解释永久关掉。

改了什么

  • ui/chat/SendQueue.kt 新增纯函数 queuedResendFailuresAfterDelivery(failures, sent, drained) = if (sent || drained) 0 else failures —— 清零点判据的唯一出处(与 queuedSendNoticeAllowed 同一写法)。
  • ChatViewModel.forgetQueuedNoticeIfDrained(sent) 从「只清 queuedNoticeTold」改成同时按新判据复位 queuedResendFailures;dropQueuedSend 里原来那行 if (sent) queuedResendFailures = 0 删掉,改为出队后调它 ⇒ 两个清零点收在同一个函数里。
  • 阈值仍是 3、「一批从空到非空只说一次」仍由 queuedNoticeTold 负责、withoutQueuedSend 等队列操作一行未动。

「事故」的界定与验收体验(已写进 KDoc)

事故 = 队列从空到非空、再回到空的那一段(与 queuedNoticeTold 的「一轮」同口径)。结束就复位,不管它是怎么结束的。

  • 一次事故内连发几条 ⇒ 只有一次横幅(重试本身不产生横幅);
  • 事故结束后再遇新事故 ⇒ 计数已复位 ⇒ 会重新解释,不会永久闭嘴;
  • 长期断网不停发 ⇒ 消息堆在队列里、队列不清空 ⇒ 不算新事故 ⇒ 不会每条都弹横幅;
  • 静默 ≠ 丢弃:计数只进告知判据,silence never touches the queue 那条用例继续通过。

未做

  • 设备验证(任务书禁碰 adb):「一次事故一次横幅」的体验只由纯函数单测 + KDoc 钉住。若要设备验收,用 ws_block_proxy.py 造场景即可。
  • 项目没有 ChatViewModel 单测装置,所以复位判据提成纯函数来钉(与 W43 同一做法);清零点被调用这件事靠既有架构保证。

★★ 锚点补记十九(2026-10-06 · 「子会话流程」从模糊待办变成清单)

依据:HEAD=ff699f5 的只读静态核对(含后端定点交叉验证)。⚠️ 除 S3/S6 之外均未做设备实测(完成判据里「需真机」已逐条标出)。

⚠️ 先纠正两条我自己写错的前提(免得沿用错的方向)

  1. 「每个 ChatScreen 各建一条 ChatSocket」已经不成立 —— W28(7b97440)已收成进程级唯一:AppContainer.kt:229(by lazy 唯一实例)、ChatViewModel.kt:964-967(只订阅)、SessionsViewModel.kt:135-153(唯一 connect/close 主人)。剩下的是VM 不回收那一半(S4)。
  2. sendInterrupt 不再是死 API(devdocs/02:144 已闭环):唯一调用方 ChatViewModel.kt:2006-2009(「设置=打断 且 忙」时才发)。但**「切会话时打断」这个原始出口确实从未接线**(S5),且全 App 没有停止按钮。

清单(按严重度降序)

S1 · 客户端问题 · 体验瑕疵(换服务器 = 看到别的后端的内容):登出/重登或换服务器地址后,聊天页仍显示登出前那份消息列表(抽屉则空着,直到打开一次才回来)。 证据:ChatScreen.kt:150-153(viewModel(key = sessionId),owner 是 Activity 级 store)+ ChatViewModel.kt:932-968(只在 init 拉一次)/:2912-2914(onCleared 空)+ CyreneApp.kt:23-31(登出只把它移出组合,实例仍在);会话列表有 baseUrl+username 指纹闸(SessionPreferences.kt:34-36、SessionsViewModel.kt:397-398),ChatViewModel 没有对应指纹。 修法方向:给 VM key 加账号指纹(一行);真正的回收要自定义 ViewModelStoreOwner,而它与「切回来状态还在」强绑定 ⇒ 属要主人拍板的设计取舍。

S2 · 客户端问题 · 体验瑕疵:次级会话没有「握手 history」这第二条安全网。进次级会话时 WS 未连(冷启动离线)→ 此后连接恢复不补拉;主会话由 history(最近 50 条)兜住。 证据:ChatViewModel.kt:982-997(gapCursor 只在「离开 Connected」时记、重连才补)、:1787-1805(armGapCursorForFallback 的既有边界);后端 ws_hub_conn.go:57 固定 replayHistory(MainSessionID)、ws_history.go:36。

S3 · 客户端问题 · 需真机确认:冷启动进次级会话可能闪一下「还没有消息」。 证据:ChatViewModel.kt:2408-2409(history 帧过滤后为空就无条件 loading=false)而初值 true(:610),正文要等 repo.loadLatest;渲染分支 ChatScreen.kt:486-487。

S4 · 客户端问题 · 仅技术债:每个访问过的会话各留一个常驻 VM(仍订阅共享流、每次重连各自发一轮 after 补拉、删会话不回收),各留消息列表(含内联 base64 图片)。 证据:ChatScreen.kt:150-153/177-182、AppContainer.kt:215-221(KDoc 自认)、ChatViewModel.kt:966/982-997/2303-2310/2960(20 页上限)。 ⚠️ 顺带一处文档硬伤:ChatHomeScreen.kt:26-33 与 ChatScreen.kt:161-164 写「onSessionGone 一被调用,那个会话的 VM 连同状态立刻被销毁」——这是错的(key 变了只是组合被销毁,VM 仍在 store 里继续收帧)。行为结论没错、成因写错了,建议顺手改对(零成本)。

S5 · 客户端问题 · 体验瑕疵:切会话不打断——在次级会话发一条后立刻切回主会话,那一轮继续在服务端跑完(算力照花),抽屉也看不出谁在生成。 证据:sendInterrupt 唯一调用点 ChatViewModel.kt:2008(仅发送时刻按 InterruptScope.kt:39-40 发)、会话切换路径 ChatHomeScreen.kt:119-122 里没有任何打断动作。 注:不接线今天未必更差(切走不打断 + VM 不回收,恰好让「切回来那一轮还在、流式完整」成立)。真正缺的是能看见 + 能停的新入口(可复用 S9 的广播帧)。

S6 · 契约缺口(要登记后端)+ 客户端 · 体验瑕疵:抽屉未读对「非激活会话」不实时增长——停在主会话时次级落了新消息,红点不出现,必须开一次抽屉才补上。 证据:SessionsViewModel.kt:359-369(message_persisted 只在 sessionId == activeSessionId 时推进)、列表更新唯一触发点 ChatHomeScreen.kt:96-102;后端设计里已登记为遗留:Cyrene_v2/DevDocs/design/16:103。归类理由:message_persisted 不带 unreadCount ⇒ 客户端算不准(契约面缺增量);客户端可自愈(节流重拉 GET /sessions)。

S7 · 客户端问题 · 仅技术债:已删除会话的僵尸 VM 下次重连会发一次注定 404 的补拉,404 被吞(sessionGone 位没有消费者)。证据:SessionsViewModel.kt:265-278、ChatViewModel.kt:2303-2310/:2333-2336/:2912-2914。 ⚠️ 本行有一处我写错了(W51 更正):sessionGone 的消费者一直都在 —— ChatScreen.kt:165-166 的 LaunchedEffect(state.sessionGone) → ChatHomeScreen.kt:109 的 onSessionGone(一句如实告知 + 带回主会话),由 b5c0b13(2026-10-04,本行所依据提交 ff699f5 的祖先) 引入,git merge-base --is-ancestor 已验证。真正缺的那一环是「出路不能依赖网络」(原来只靠 refresh 成功后的回落),见锚点补记二十九。

S8 · 结论:以下几处主/次「没有分叉」,别再返工:发送/流式/灰态/并行分桶、分页与补拉、主会话禁删(三处一致)、改名/置顶、已读游标、分享落地/D2 搜索/图片与附件 —— 全部按 sessionId 等值收敛,同一份代码。 另:sessionId → N 序号映射已在 c09a211 整块退役(devdocs/02:146)⇒「次级会话序号」不再是待办。

S9 · 客户端问题 · 仅技术债:「这个会话正在生成」既看不见、也没有冷启动可查的状态面:session_busy/session_idle/turn_begin 都带 sessionId 且在广播,但 SessionsViewModel.onEvent(:335-344)只认 session_read/message_persisted;握手也不回放状态。

优先级建议(排查方给的)

先修:S1(唯一会把「别的环境的对话内容」摆上屏幕的东西;便宜的一步是给 VM key 加账号指纹)、S6(日常最容易撞到;客户端可自愈 + 给后端登记)。可继续搁置:S4/S5/S3/S7/S2(各有理由,且 S4/S5 的正解与产品取舍绑定)。零成本顺手:把 S4 里那处 ChatHomeScreen.kt:30 / ChatScreen.kt:163 的错误说法改对。

设备验收步骤(12 步,已设计好;除 S3/S6 外都还没跑)

步骤骨架(每步的判据在排查报告里,逐条都很具体):0 纪律(先 dumpsys window | grep mCurrentFocus 确认前台是本 App + adb reverse tcp:10942 tcp:10942)→ 1 建次级会话(判 GET /sessions 里有 type:"sub")→ 2 次级发送+流式(判 logcat -s CyreneRoute 恰好一行 route=Ws、库里两条)→ 3 冷启动进次级(盯 S3 那一下闪;判无主会话内容混入)→ 4 切会话不打断取证(假后端 out/upstream.jsonl 无 interrupt)→ 5 次级没有 history 兜底(--history-replay 2,判 history 帧只含 web)→ 6 「次级+首屏空+WS 从未连上」的已知洞(需 ws_block_proxy.py)→ 7 未读/已读(S6 现场)→ 8 置顶/改名/禁删 → 9 删除(判 GET .../messages 404)→ 10 别处删除的兜底横幅 → 11 重连补拉在次级跑不跑(S2 闭环)→ 12 登出/重登陈旧状态(S1 现场)。

⚠️ 工具位置(排查方找不到它)

ws_block_proxy.py 不在 git 里 —— 它在 app/build/e2e-shots/(被 gitignore)。它很好用(只掐 /ws/chat,且 Web 前端不经过 adb reverse ⇒ 代理日志只来自 App),但一个 clean 就可能把它扫掉。建议下一步把它挪进 devtools/(连同一小段用法说明),否则下次又得重写。

排查方自己标注的不确定(照抄,别当成全量结论)

结论都是静态核对;S1/S2/S3 需真机确认。后端只做了定点核对(握手回放、广播路由、会话 404 契约、上行帧路由、回合播种)——没有通读编排器,所以「次级会话的回合在服务端与主会话 100% 同形」不能背书;记忆写回、主动消息、后台思考是否只走主会话未逐条核对。

★★ 锚点补记二十(2026-10-06 · W42 图片多选设备验收:通过)

结论:功能通过;一处观感待主人定

环境:模拟器 Pixel_10_Pro_XL(1080×2340),包 = 当前 android 的 assembleDebug 产物,服务器 127.0.0.1:10942。

判据(逐步,都是实机):

  1. 点输入栏左起第一个图标(图片)⇒ 打开了系统 Photo Picker(mCurrentFocus = com.google.android.providers.media.photopicker.PhotoPickerActivity,文案「此应用只能访问您选择的照片」)⇒ 多选器入口成立。
  2. 点两张图 ⇒ 两张同时被选中(各是蓝底对勾,不是单选圆点)⇒ 确实是多选,不是单选。
  3. 确认按钮文案 = 「添加(2 项)」(bounds=[734,2081][1036,2225])⇒ 计数正确。
  4. 点它 ⇒ 回到聊天页,输入栏上方出现两枚缩略图,各带一枚移除键(X) ⇒ 「N 张图 = N 条消息」的前置(草稿态)成立。

⚠️ 一处观感要主人拍板:那枚移除键(X)叠在缩略图的中间(不是常见的角标位置)。是刻意(整张点击即移除)还是布局问题,属观感取舍,我不替他下结论。截图:app/build/e2e-shots/p4-selected.png(选择器)/ p5-composer.png(输入栏)。

未验的邻近一格(本次没做,留个尾巴以免误以为都验了):点那枚 X 是否真的移除那一张、以及真发出去后 N 张是否落成 N 条消息(后者需真的发送一条带两图的消息,会真的花钱并往会话里写消息)。

过程中撞到的环境坑(已记过,但仍复现了):svc power stayon true 与 screen_off_timeout 都没挡住黑屏 ⇒ 第一次点击全落空、screencap 全黑。开测前先 input keyevent 224 + 82 唤醒,并立刻截图确认屏幕是亮的。

★★ 锚点补记二十一(2026-10-06 · 移除键验到了;③ 的第一次尝试没跑成)

✅ ② 的「未验邻近格」补验一格:移除键有效

输入栏两枚缩略图,点第一枚的 X ⇒ 变成一枚(第二枚按布局回流左移)。 ⚠️ 我的测试失误(记下来):我第二次点击用了旧坐标,结果点空(第一枚移除后第二枚已左移)——这是布局回流的正常行为,不是 bug。教训:每次点击前重新取样坐标,别在同一排会回流的东西上复用。 ⇒ ② 的两个未验格里,「移除键」这一格补上了;「N 张真发出去是否落成 N 条」仍未验(要真发一条带图消息)。

⛔ ③ 第一次尝试:没跑成(判据三条一致:发送没触发)

做法:ws_block_proxy.py + adb reverse tcp:10942 tcp:18098 + force-stop 重启 App(必须重启:否则旧连接还活着 ⇒ 会走 Ws 档,压根考不到阶梯);logcat -c 清过;启动后 45 秒发一条纯文本。 结果:CyreneRoute 日志为空、代理没有 POST、库里没有那条 ⇒ sendThroughLadder 一次都没被调用,即发送没发生。 最可能的原因与前几次同源:输入注入没命中(45 秒时 App 可能仍在加载;我没有在打字后先确认「文字真的进了输入框」就点了发送——这条纪律我今天写了两次,这回又没执行)。 结论:③ 仍未定论。 下一轮的做法必须改成三步都确认:① dumpsys 确认前台是本 App → ② 打字后 dump 确认输入框里确实有那段文字 → ③ 再点发送;然后读 CyreneRoute + 代理 + 库。

★★ 锚点补记二十二(2026-10-06 · ③ 有结论了:机制正常,那一次异常未复现)

结论

「WS 断、HTTP 通 ⇒ 回落 REST」这条阶梯是工作的(两次有效观测都走通了);那一次异常(W45 那轮停在队列)未能复现,且当时 CyreneRoute 日志还不存在 ⇒ 无法确定它当时选了哪条路。按完成判据,这一条以「说明为何不可复现」结案。

两次有效观测(判据一致)

场景:ws_block_proxy.py 只掐 /ws/chat(503)+ adb reverse tcp:10942 tcp:18098 + force-stop 重启 App(必须重启:否则旧连接还活着 ⇒ 会走 Ws 档,考不到阶梯)+ logcat -c 清过。

次 CyreneRoute 网络侧 库
W46 那轮 route=Rest reason=ConnectionNotReady 代理 PASS POST /api/v1/chat -> 200 1 条
本轮(w49) route=Rest reason=ConnectionNotReady (未取代理日志,落库即证明 POST 成功) 1 条(cli=local-32f2c53d)

那次异常(未复现)

现象(W45 那轮):气泡停在「排队中 · 还没连上」、代理没有 POST、库里没有那条。 最可能的解释(仍未证实):route=Rest,但那次 REST 死在整通上限 10 秒(被启动期的请求洪峰吃掉连接预算)⇒ Unsent ⇒ 进队列。 为什么不能定论:那轮发生在 CyreneRoute 日志加入之前(W46 才加),所以「它当时选了 Rest 还是 Queue」没有证据;之后两次复现都正常。

★ 本轮最关键的收获:一套可靠的设备驱动协议(写死它)

这一轮之所以终于拿到数据点,是因为严格执行了三步,而且第三步是从 dump 里取真实坐标:

  1. dumpsys window | grep mCurrentFocus 确认前台是本 App;
  2. 打字后 dump 并检查那段文字的 bounds——判断它在输入框里(x 落在 275..845、y 被软键盘顶到 ~1293..1435)还是已经成了列表里的气泡(y < 2000)。这一步就是「发送到底触发了没有」的判据;
  3. 不要复用发送键坐标:软键盘会把整条输入栏顶高约 820px。本轮 发送 的真实 bounds 是 [911,1333][1003,1399](不是无键盘时的 [911,2166][1003,2232])⇒ 从 dump 里取 bounds 再点。 ⇒ 我今天在这个坑上栽了 4 次(黑屏、前台不是本 App、复用坐标、不确认文字落点)。这四步之后,设备验证才算「可靠」。

★★ 锚点补记二十三(2026-10-06 夜 · 两个子代理在飞 + S6 的契约缺口已登记)

在飞(都已派单,未合并)

  • W48 feat/W48-account-scoped-vm:① 修 S1(换账号/换服务器后聊天页仍显示上一环境的消息 —— 给 ChatScreen 的 VM key 加账号指纹,沿用 SessionPreferences 的 baseUrl+username 口径,不回收旧 VM);② 改掉 ChatHomeScreen.kt:26-33 / ChatScreen.kt:161-164 那两处写错的注释(「VM 立刻被销毁」是错的,行为不动);③ 把 ws_block_proxy.py 从 gitignored 的 app/build/e2e-shots/ 挪进 devtools/ws-block-proxy/ 并配 README(否则一次 clean 就没了)。
  • W49 feat/W49-inactive-unread:修 S6 的客户端一半 —— 收到非激活会话的 message_persisted 时节流地静默重拉列表,让红点在不开抽屉时也能出现;要求节流 + trailing 语义(最后一条之后必须还能再拉一次),且不许新写取数路径、不许 loading 闪烁。

已登记给后端(02,本轮)

S6 的契约缺口:message_persisted 不带 unreadCount ⇒ 客户端算不准未读、只能整表重拉。请他们在新消息广播上带未读增量或 unreadCount(omitempty,缺席=老行为),并说清是绝对值还是增量、以谁的已读游标为基准;或给一个批量查询未读的面。我方自愈不能替代它(每次仍是一次完整 GET /sessions,会话多了会变贵)。 顺带提了:这条他们自己的设计文档里也记着(Cyrene_v2/DevDocs/design/16:103)。

合并时的纪律(提醒下一轮的自己)

两个 worktree 的基线都是 df8df67;合并要分别 --no-ff + 全量 assembleDebug/testDebugUnitTest + 当场 rm -rf + git worktree prune,并核对主仓 git status 只有 02 那笔私有改动(曾有子代理误写主仓)。

★★ 锚点补记二十四(2026-10-06 深夜 · 一条流程教训:写 02 之前必须先读它的最新追加)

我差点犯的错

我给后端写了一整节(7d1c56e「我方依赖的线形状」)之前只看了 git status 知道 02 是脏的,就假定那还是我上次看到的那批追加。 主人一句「你确定不先读一下?」把我拦住了 —— 一读发现后端早已回了 回执二十二 · 补,里面有 🔴 一条点名问我们的 P1 和另外三问。 ⇒ 纪律(写死):动 02 之前,除了 git status,还要 git diff 看它最新那几十行(git diff -- devdocs/02-… | grep '^+' | tail -45)。「脏」不等于「还是那批内容」。

后端正在等我们回的四件事(回执二十二 · 补,原文节选)

  1. 🔴 P1:主动消息会不会显示两次? —— v0.72 起她主动说的话既是实时帧、也是主会话历史里的一条真消息,且帧里新增了 data.id(= 落库行权威 id)。若我们「用帧画一个气泡 + 拉历史再画一个」⇒ 同一条显示两次;他们说 Web 侧已按 id 去重,问我们做了没有(未做就是 P1 客户端项)。好处:做了之后刷新/重开 App,她主动说的话仍在。
  2. tool_execution 取哪个口径:(a) 我们**只认 .start/.end + 按 id 关联⇒ 他们不再补.update**;或 (b) 我们确实要不要 .update`(中间进度)⇒ 他们排一片后端工作。要我们选一个。
  3. 我们那份待办清单的现状(哪些要他们配合)—— 注意:他们列举里的「ChatScreen 每屏一条 WS」已经是过时前提(W28 早就收成进程级唯一连接了;剩下的是 VM 不回收),回信时要纠正。
  4. 移动端要不要系统推送(形状层面)—— 主人说过等他自建推送再定。 另两条无需回复的 FYI:v0.70 起实时 backthink 帧带 urgency(routine/warning/critical);v0.69~v0.75 除 v0.72/v0.74 外全是 admin 面(对我们零改动)。 ⚠️ 我读到的只是他们追加的尾部 45 行,第 4 条的句子在 190 字处被截断 ⇒ 回信前要读全。

已派单核实

只读排查(agent-8qtm01gd)就为这两条取证并给出建议:问题一的「帧画的 vs 历史拉的会不会被按 id 合并成一条」(以及最小修法),问题二的「.start/.update/.end 三档各自是否解析 / 是否消费」以及只发 start/end 时工具卡够不够用。

附:本轮回后端的两个决定与一个新 P1(补记二十四处)

① P1(我方接):主动消息实时看不到。 核实结论:proactive_message 帧我们只解了错的 id(messageId,而落库行是另一个 id;ignoreUnknownKeys 把新 id 静默丢弃),且整帧零消费(ChatViewModel.onEvent 无该分支)⇒ 「显示两次」不会发生,但「实时看不到她主动说的话」是真缺口(只能等重连/重开/切会话拉历史)。修法位置已定(WsEvent.ProactiveData 补 id/createdAt + ChatViewModel 加分支,按已落库 assistant 行并入 mergeMessages,必须自己钉死主会话——该帧壳不带 sessionId,否则会窜进每个次级会话)。 ② tool_execution 口径:选 (a)(只认 .start/.end 按 id 关联;不要他们补 .update)。取舍已明确:没有 .update ⇒ 工具卡的结果区是空的(ToolCallState.output 只由 .update 的 delta 追加)⇒ 我们真正要的是他们已认领的「持久化 toolCalls[] 回看面」,并请求把输出/摘要一并带上。 ③ 移动端推送:暂不接(等主人自建)。并已告知他们:他们建议的「应用内提示」message_persisted{proactive:true} 在我们这边零实现(PersistedData 只读 message;客户端无任何 Notification 代码)⇒ 那条建议目前不产生任何效果,别当兜底。

★★ 锚点补记二十五(2026-10-06 深夜 · W48 + W49 落地:S1 与 S6 的客户端半边)

状态

android = origin/android = 5dc6408(两个合并提交);主树 1177 测试 / 0 失败 / 0 错误;worktree 全清(只剩主树),主仓只剩 02 那笔后端会话的在途追加。

W48(分支 4f860aa,合并 e589ab3):修 S1 + 注释纠错 + 工具归位

  • 账号指纹进 VM key:新增顶层纯函数 accountFingerprint(baseUrl, username)(core/prefs/SessionPreferences.kt,口径就是既有的 baseUrl|username,不另造第二套 —— SessionsViewModel 的私有实现也改成调它),VM key = 指纹 + "|" + sessionId(ui/chat/ChatScreen.kt 的 chatViewModelKey)。
  • 顺带修了同类缺陷:会话内搜索小 VM 的 key 也加了指纹(它持有的 hits 就是消息正文,换环境后在主会话(id 恒为 web)重开搜索会把别的服务器的消息摆进面板)。
  • 5 处错注释纠正(ChatHomeScreen ×3、ChatViewModel ×2,加上点名的 ChatScreen:161-164):原写「key 一变 VM 立刻被销毁」是错的(key 变只销毁组合,VM 仍在 Activity store 里收帧)。行为一字未动(onCleared 仍空)。
  • ws_block_proxy.py 挪进 devtools/ws-block-proxy/ + README.md(用途 / 用法 / 「Web 前端不经过 adb reverse ⇒ 代理日志每一行都只可能来自 App」这条关键性质 / adb reverse tcp:10942 tcp:18098 的典型用法 / 提醒抹掉日志里的 token 查询串),并冒烟测过(503 BLOCKED + 200 PASS)。一次 clean 再也不会把它扫掉了。
  • ⚠️ 未做设备验收(禁碰 adb):换账号后屏幕上是否真的不再有旧消息,只由纯函数单测钉住。

W49(分支 93cf3e3,合并 5dc6408):修 S6 的客户端半边

  • 新增判据层 ui/sessions/InactiveSessionPull.kt:silentRefreshStep 等纯函数 + 3 秒节流窗口。
  • SessionsViewModel:非激活会话落 message_persisted ⇒ 节流地走既有 refresh(silent = true)(与打开抽屉那次逐字相同,含 withReadCursors 遮罩),让红点在不开抽屉时也能亮;登录态变化时清账本 + 取消补拉任务。
  • 3 秒的理由(写进 KDoc):够快(3 秒还在「就是刚发生」的感知里);够粗(一次静默重拉是两个请求,而播出方按每条消息推帧,3 秒能把人手连打 5 条收进同一窗口);与 SHARE_WAIT_NOTICE_MILLIS 同量级。
  • trailing 是结构性保证:同一时刻最多一个补拉任务;窗口内的后续成因只更新账本并返回 Ignore;到点直接拉并把窗口重开(不回头问判据,避免自锁);边界取 >=。
  • 不拉的三档:当前打开的会话(那条路「看到即已读」,多拉是白花两个请求)、同一窗口内的后续消息、窗口内且补拉已排上。
  • 它不是「造数」:一个数都不编 —— 帧本身只说「某会话有新消息落库」,客户端据此去问一次服务端。同理这是「增量到位之前红点唯一能亮的办法」;后端补上 unreadCount 后这条路可退成不必走。
  • ⚠️ 一个刻意的偏差:select(切会话)不接触发 —— 它紧接着发 POST /read,而游标还没落地时重拉会按旧游标算出未读、把刚清掉的角标闪回来。判据对「切会话」够用,KDoc 写明了理由。若将来发现「切回来后红点不对」,这里就是第一嫌疑处。
  • ⚠️ 未做设备验收:「红点确实在没开抽屉时自己出现」没有实机证据。

顺带要改的旧引用(我)

devdocs/05 里那条「建议把 ws_block_proxy.py 挪进 devtools/」的建议已执行(devtools/ws-block-proxy/);文中提到旧路径(app/build/e2e-shots/ws_block_proxy.py)的地方现在过时,下次一并更正。

★★★ 锚点补记二十六(2026-10-06 深夜 · 续接锚点:从这里开始)

0. 一句话现状

客户端 W38~W49 已全部落地并推送;W50(接上主动消息消费)正在跑;主树 1177 测试 / 0 失败 / 0 错误;worktree 只剩主树。

1. 仓库与环境

  • 仓库 D:\Project\Code\Uni\Cyrene-For-Android,分支 android。
  • 主仓只有一笔脏改动:devdocs/02-前后端协同事项.md —— 那是后端会话的在途追加,不是我们的。永远别 git checkout -- 它、也别把它提交进来。
  • 动 02 的纪律(今天写死的):① git status;② 再 git diff 看它最新那几十行(「脏」≠「还是那批内容」——我今天差点因此给后端写信时漏掉他们的回执);③ 提交走「另存他们的补丁 → git checkout -- → 只提交我这一节 → 再把他们那份贴回末尾」,两边都不丢。
  • 模拟器 AVD Pixel_10_Pro_XL:1080×2340 @440dpi / 5 vCPU / 8G(删掉 skin.* 三行才让 hw.lcd.* 生效);adb reverse tcp:10942 tcp:10942;软键盘默认禁用(布局不因 IME 位移)。
  • ⚠️ 环境坑:屏幕会自己黑屏(svc power stayon true 也挡不住)⇒ 开测前 input keyevent 224 + 82;adb root 会冲掉 adb reverse;模拟器 system_server 会崩(Can't find service: window、am 报 Broken pipe)⇒ 只能重启模拟器。

2. ⭐ 设备驱动协议(今天用四次栽跟头换来的,务必照做)

  1. dumpsys window | grep mCurrentFocus 确认前台是我们的 App(我今天两次点在 com.android.settings 上,白跑两轮);
  2. 打字后 dump 并读那段文字的 bounds:x 落在输入框范围、y 被软键盘顶高(约 1293~1435)⇒ 在输入框里;y 在列表区(<2000)⇒ 已成气泡。这就是「发送到底触发没触发」的判据;
  3. 绝不复用发送键坐标(软键盘把输入栏顶高约 820px)⇒ 从 dump 取 bounds 再点;
  4. 要判「客户端做了什么」⇒ 优先看客户端自己的输出(logcat -s CyreneRoute、-s OkHttp),不要从代理/后端日志反推 —— 对端看不到「没发出去的请求」,我因此误判三次。

3. 在飞:W50(feat/W50-proactive-consume,基线 d5d2fee)

缺口(真实产品缺口):她主动说的话实时到不了安卓。原因两条:① proactive_message 帧零消费(ChatViewModel.onEvent 无该分支,全仓 app/src/main 无消费点);② 我们只解了错的 id(只解 messageId,而落库行是另一个 id;ignoreUnknownKeys 把新 id 静默丢弃)。 任务书硬要求(合并前逐条核对): ① 按已落库的 assistant 行经 mergeMessages 并入(不造 UI 气泡)⇒ 实时可见 + 刷新后仍在 + 按 id 不会重复; ② 必须自己钉死主会话(帧壳不带 sessionId,isForSession 会放行给每一个会话 ⇒ 不钉就在每个次级会话里冒一条); ③ 无 id 时退化为本地气泡(后端落库失败那一档),不许静默丢弃; ④ 可选项:顺带触发一次 W49 的节流静默重拉(因为后端不为这条路补 message_persisted ⇒ 主会话红点不会自己亮); ⑤ 红线:不动 mergeMessages 语义、不动 W49 节流判据、不动 W48 的 VM key、不许把这条帧接成「收到就整表重拉」。 合并后要做:把「她主动说的话现在能实时到达安卓」写进文档 + 做一次设备验收(用上面的协议 —— 这是新的实时路径)。

4. 今天已落地(W38~W49;细节看各自锚点)

批次 内容 锚点
W38 分享落地等连接就绪(超时不再等于发送失败、内容不丢、连上自动补发) 补记九(+ 真机回归通过)
W39 三处措辞不诚实(告知不再两句叠加 / 未连接与发失败分开 / Offline 不再承诺自动发出) 补记十
W40 普通发送未连接进队列(+ 真机回归通过) 补记十一/十二
W41→W45 先探 /health 改成直接试 REST(探测整套已删除)⇒ 已真机验证 route=Rest + 落库 补记十三/十六/十七
W42 图片入口改多选(N 张 = N 条消息)+ 设备验收通过 补记十四/二十
W43 超限告知入口中性 + 重试静默(阈值 3) 补记十五
W46 一行 CyreneRoute 诊断日志(DEBUG)+ 判据带原因 补记十七
W47 静默改为按事故复位(队列排空即清零) 补记十八
W48 账号指纹进 VM key(修 S1 换环境串味)+ 5 处错注释 + 代理挪进 devtools/ 补记二十五
W49 非激活会话节流静默重拉(修 S6 红点滞后,3 秒窗口 + 结构性 trailing) 补记二十五

5. 待办(按优先级)

  1. W50 合并 + 设备验收(在飞)
  2. ⚠️ d5d2fee 之后还没跑过全量?—— 不,d5d2fee 是纯文档提交;代码最后一次全量是合并 W48/W49 后的 1177 绿。W50 合并后必须再跑一次。
  3. W48/W49 的设备验收(换账号后屏幕是否干净 / 红点是否自己亮):都只有单测。
  4. 老现象 ③:已结案(机制正常、那次异常未复现)——见补记二十二,别再花时间重跑。
  5. S1 的第二步(回收旧 VM)与 S5(切会话不打断 / 一个能看见能停的入口):都属要主人拍板的设计取舍。
  6. S2/S3/S7/S9(次级会话的窄窗口问题):清单在补记十九,按需。
  7. 等后端:toolCalls[] 回看面(他们说比未读更重要,且要带输出/摘要)、unreadCount 增量(都已认领,契约先行)。
  8. 等主人:移除键位置那处观感、相册多选分享真机、推送(等自建)。
  9. ✅ 文档陈旧 —— 已更正(4c938c7):05 历史条目里提到的 app/build/e2e-shots/ws_block_proxy.py,一律指现在的 devtools/ws-block-proxy/ws_block_proxy.py(W48 已把它搬进 git,clean 再也扫不掉)。历史条目不改写,以本行为准。

6. 与后端的状态(不用重新问)

  • 他们 回执二十三:未读增量接(message_persisted.data.unreadCount,绝对未读 + 主人单一已读游标 ⇒ 多端共享;GET /sessions 列表项也带)、工具记录认且说比未读更重要、我们六条依赖逐条「保持现状 + 改前必登记」、/health 保留不删、采纳了我们那条取证约定(不从对端日志反推)。
  • 我们已答(766843f):P1 的真实含义是「接上消费」不是「去重」、tool_execution 选 (a) 并要 toolCalls[] 带输出、纠正「每屏一条 WS」这个过时前提(W28 早已收成进程级唯一连接)、推送暂不接且告知「应用内提示」在我们这边零实现。
  • 我们没在等他们的答复;在等两个已认领的面落地。

7. 一条方法论(今天最大的教训,写给下一个我)

我今天四次栽在自己的测量上(黑屏、前台不是本 App、复用坐标、不确认文字落点),三次拿「对端日志缺席」当「客户端没做」的证据。 ⇒ 先怀疑自己的测量,再怀疑代码;要判客户端行为,只看客户端自己的输出;一次只改一个变量。


★★ 锚点补记二十七 · W50 落地并设备验收通过(她主动说的话,现在实时到安卓)

这一段是已验证的状态,直接当事实用。操作细节与全部证据见 07-W50-主动消息-验收与避坑.md。

1. 结论

proactive_message 帧现在有消费点了:她主动说的话实时出现在主会话,刷新/重启后仍在、按 id 不重复; 无 id(后端落库失败那一档)退化成本地气泡、绝不静默丢弃。

  • 合并提交 afee5f6(android,已推);W50 分支与 worktree 已删、构建锁已放。
  • 全量(合并后的树,testDebugUnitTest --rerun 真跑):BUILD SUCCESSFUL / 91 suite · 1193 tests · 0 failed · 0 error(基线 1177 + 新增 16)。
  • APK:app/build/outputs/apk/debug/app-debug.apk(47,850,706 B)。

2. 四条硬要求逐条成立(合并前核过)

要求 实现处
按已落库的 assistant 行经 mergeMessages 并入,不造只活在 UI 的气泡 ChatViewModel.onProactiveMessage ⇒ 与 onPersisted 同一条 mergeMessages
自己钉死主会话(帧壳不带 sessionId,isForSession 会放行给每个 VM) ui/chat/ProactiveIngest.kt 的纯函数 proactiveIngestFor:sessionId != MAIN_SESSION_ID ⇒ Skip(等值比较,web ≠ web:xxx)
无 id 退化成本地气泡,不许静默丢弃 同上 Bubble(本地 id proactive-local: + 帧里已有的稳定键 ⇒ 重投也是 upsert);只有「无 id 且正文空白」才 Skip
顺带触发 W49 的节流静默重拉 SessionsViewModel.onProactiveMessage:复用 persistedRoute + requestSilentRefresh(没有新取数路径);另补「正看着主会话 ⇒ 看到即已读」那一档

红线核对:mergeMessages 语义未动(只被调用)、W49 节流判据未动、W48 的 VM key 未动、没有接成「收到就整表重拉」。

3. 顺带修掉一个不修就等于没做 W50 的缺陷

ProactiveData.timestamp 线上是 RFC3339 字符串(Go time.Time),旧声明 Long? ⇒ 解码抛异常被 WsEvent.parse 的 runCatching(WsEvent.kt:654)吞成 null ⇒ 整帧在成形之前就没了(只加 onEvent 分支碰不到它)。 现在按字面量收(字符串/数字都吃)。详见 07 §4。

4. 设备证据(假后端,四条判据全绿)

①主会话实时出现(delivered=1,5s 后 dump 到正文 + 角标「发送至 网页」);②次级会话 0 次; ③重启后 1 条、同 id 重投仍 1 条;④--without-id 档本地气泡 1 条、重启后 0 条而落库那条仍 1 条。

5. 这一次的环境坑(一句话版;完整版在 07 §2)

input text/keyevent 的标点会被 Gboard 转成全角(系统 locale 是 zh-CN)—— 绕法是「地址栏只填纯字母 localhost」(normalizeBaseUrl 会补 http://,ApiClientFactory.kt:109) + adb reverse tcp:80 tcp:<后端端口>(需 root,且 adb root 会清掉所有 reverse)。 另:点太快会 ANR 被强杀(冷启动等 25~35s 再动 + 三个动画开关置 0);黑屏挡不住(每次交互前 keyevent 224 + 确认前台); adb shell /sdcard/... 要 MSYS_NO_PATHCONV=1;Python 要 PYTHONIOENCODING=utf-8。

6. 副产品:真后端那条主动路径是活的

验收前 App 还连着真后端时,主会话历史里有一条 发送至 cyrene-backthink 的她的话(「早安呀,亲爱的♪ …」)—— 落地行、来源角标、主会话归属三者都对,与本次修的消费口径一致(那条来自历史拉取,不是实时帧)。

7. 待办变化

  • 上面第 3 项「W48/W49 设备验收」环境已就绪(模拟器已 root、reverse 指向真后端)⇒ 现在是可做状态。
  • W50 留了一条窄尾巴(只在「落库失败」档可达:降级气泡长按可删 ⇒ 会用本地 id 打服务端报错);收口做法见 07 §5。
  • 已更正「代理旧路径」那条陈旧项(见 5520d60)。

★★ 锚点补记二十八 · W48/W49 设备验收也通过(换环境串味 / 抽屉未读角标)

环境:模拟器 emulator-5554 + 假后端 python devtools/fake-backend/fake_backend.py --port 18090 (App 地址栏填 localhost,配 adb reverse tcp:80 tcp:18090)。操作细节与全部坑见 07-W50-主动消息-验收与避坑.md。

1. W48(账号指纹进 VM key,修 S1)——已验

判别法是关键:先把服务端主会话清空(POST /__control/clear?session=web)再换账号,否则两个账号拿到同一份历史、看不出串味。

  • 账号 A(e2ea)的主会话里造两条:一条只活在 A 的 VM 内存里的本地气泡(--without-id 注入)+ 一条持久行;
  • 换到账号 B(e2eb;同 baseUrl、不同用户名 ⇒ 指纹不同);
  • 结果:「A账号的消息」0 次、「A账号的持久消息」0 次,聊天页「还没有消息」,抽屉里 A 的残留 0 次 ⇒ VM 没被复用、快照也没串味。反证很硬:那条本地气泡服务端根本不存在,它若还在屏幕上就只能是旧 VM。
  • 假后端流水证明这是全新一轮(08:22:35 起一整串 login / enums / sessions / messages)。

2. W49(非激活会话落新消息 ⇒ 节流静默重拉,修 S6)——已验

  • 场景:App 停在主会话、抽屉关着;往次级会话 web:5eed…0001 灌一条(POST /__control/reply)。
  • 机制证据(客户端自己发的请求):08:20:03.018 服务端广播 message_persisted ⇒ 08:20:03.079 客户端自己发出 GET /api/v1/sessions(60ms 后,抽屉全程关着)——这正是 W49 那条节流静默重拉。
  • 可见结果:打开抽屉,次级会话行上是 未读 1([232,573][276,617]),而正在看的主会话没有角标。
  • ⚠️ 更正一处认知:这里的「红点」是抽屉里会话行上的未读角标(SessionDrawerContent.kt:403); 菜单图标本身没有点(ChatScreen.kt:468 是纯 Icon)——以后别再对着菜单图标找点。

3. 本次新踩的坑(已补进 07 §2 第 9、10 条)

  1. 登录页地址栏留空 ⇒ App 回落到上一次记住的地址(我为此白追一小时「请求怎么打到真后端去了」);
  2. 「登出」是设置页最底部的图标按钮(ui/settings/SettingsScreen.kt:160,图标是 MiuixIcons.Back、 只有 content-desc="登出")——截图上看着像「返回」,很难找。这是观感问题,等主人拍板要不要换成带文字的危险行条目。

4. 待办变化

  • ✅「W48/W49 设备验收」这条结清(原来标注「都只有单测」)。
  • 还在等/待决策的:等后端 toolCalls[] 回看面(要带输出)+ unreadCount 增量; 待主人:登出条目观感、移除键位置观感、相册多选分享真机、推送(等自建); 待主人拍板:S1 第二步(回收旧 VM)、S5(切会话打断/停止入口)、W50 那条窄尾巴(降级气泡按 proactive-local: 前缀屏蔽删除入口)。

★★ 锚点补记二十九 · W51(S2 + S7 次级会话补拉安全网)合并、S2 设备验收通过

合并提交 620a4e7(android 已推);分支/worktree Cyrene-W51 已清。全量真跑:93 suite · 1205 tests · 0 失败 · 0 error(基线 1193,+12)。

1. 做了什么(每条都写清「为什么」在代码 KDoc 与提交信息里)

  • S2:新增纯判据 ui/chat/ReconnectPull.kt(Forward / FirstPage / Nothing,四级,只看 会话 id + 首屏状态 + 掉线游标 + 已知它没了);ChatViewModel 把首屏 loadLatest 提成 loadFirstPage(),init 与补拉共用一份,并新增 FirstPageState。判据刻意用「首屏从没成功过」而不是「本端有没有数据」——服务端真的没消息时列表也是空的,拿空列表当判据 = 每个空次级会话每次重连整表重拉(S4/S7 的成因)。Loading 不算失败(S3 那条时序一个字节没动);主会话排除在外(它有 history 兜底,再补一条 REST 就是第二套口径)。
  • S7①:新增 core/session/GoneSessions.kt(进程级、不持久化、登出清空)+ AppContainer.goneSessions;写入口是 SessionsViewModel.deleteSession 成功那一刻,读入口是每个 ChatViewModel。判据第 1 条拦掉注定 404 的补拉:本地删过的零次,已 404 过的从「每次重连撞一次」收敛成恰好一次。
  • S7②:⚠️ 任务书的前提是错的 —— 消费者一直都在(见补记十九那行的更正)。真正缺的是「出路不能依赖网络」:原来只靠 refresh 成功后的回落 ⇒ 网络一抖,「已切回主会话」就是一句做不到的话。现在 ChatHomeScreen.onSessionGone 先在本端 select(主会话)(与用户点抽屉那一行同一条路),refresh 退回本职。

2. 设备验收(emulator-5554 + 假后端 18090,App 地址 localhost、adb reverse tcp:80)

  • S2 通过:先在线进次级会话(判据:客户端发 POST …/read)→ 撤 reverse 冷启动(离线,屏幕 还没有消息 + 无法连接到服务器 + 重连中…,注入的那条服务端消息在屏幕上 0 次)→ 装回 reverse → 20 秒处客户端自己发出 GET /ws/chat 紧跟 GET /api/v1/sessions/web:5eed…/messages(就是那条补拉),屏幕上的消息自己出现(1 次),全程没碰一下。
  • S7① 通过(否定式证据):在抽屉里删掉那个次级会话(服务端列表随即只剩 web)→ 用假后端 POST /__control/ws-close 真踢一次 WS(握手日志显示 App 在 09:20:01 重连回来)→ 重连窗口里没有任何该已删会话的 …/messages 请求(web:5eed 计数 0),而握手本身证明同一窗口通路是活的(可信的否定)。反事实(不加闸就会撞一次)由补记十九的登记证据 + ReconnectPullTest 的单元测试承担。
  • S3(冷启动闪「还没有消息」):未验。W51 刻意没动那条时序;dump 采样的粒度抓不到亚秒级闪烁,我没有证据,不写成「没问题」。
  • S7②(在别处被删 ⇒ 被带回主会话且不依赖网络):未验(要造「别处删除」的场景)。代码路径与副作用(给主会话发一次 POST …/read,与用户手点主会话逐字相同)已在提交信息里写明,等主人复看。

3. 这次我又一次踩在自己的测量上(第二次同类,写下来)

S2 我第一次测出「没生效」,差点去改代码——真因是设备上跑的还是上一版 APK(换图标那版,lastUpdateTime 00:29,而含 W51 的是 09:04)。规矩:凡是「真机上没生效」,第一件事先核 dumpsys package … | grep lastUpdateTime 与本地 APK 的时间,再怀疑代码。(同类教训还有:ws-close 那次我以为「48 秒没重连」,其实是假后端踢掉连接本身就延迟了 19 秒,实际重连发生在 09:20:01。) 另:为它白跑了好几轮还有一个原因——撤 adb reverse 不会掐断已建立的连接,要真抖 WS 就用假后端的 POST /__control/ws-close。

4. 待办变化

  • ✅ S2 结清(已实机验);S7① 结清(否定式实测 + 单测);S7② 与 S3 未验(见上)。
  • 「REST 首屏失败、但 WS 从头到尾没断」这一格仍是已知缺口(没有任何状态变化可挂,项目纪律不许按时间轮)。
  • S4/S5 仍需主人拍板;等后端两个面不变。

5. S7② 这次为什么没验成(下次照这个来)

  • 要造的场景:App 停在那个次级会话 → 服务端把它摘掉(假后端 POST /__control/forget?session=<id>,它的 /messages 随即 404)→ 真踢一次 WS(POST /__control/ws-close)逼出那条注定 404 的补拉/补洞 → 期望:如实告知 + 被带回主会话。
  • 判据(两条):① 客户端发出那条请求并拿到 404 ⇒ sessionGone 置位;② 界面上出现那句告知,且 POST /api/v1/sessions/web/read(select(主会话) 的副作用)在 GET /sessions 之前出现 —— 后者才是 W51 那句「出路不依赖网络」的证据。
  • 我卡在哪:登录表单被前几轮实验搞乱了(地址栏里还留着 W48 那次的坏值 http://loclocalhostalhost/,用户名/密码/展开态彼此错位),input text 落不进目标字段;且 uiautomator dump 会返回陈旧缓存(同一次交互前后两张 dump 的地址栏展开态互相矛盾)。这个会话里我为此烧了好几轮,按「别把预算烧在仪表上」停手。
  • 下次的干净起手:adb shell pm clear top.yeij.cyrene → 登录页只填纯字母的 localhost(normalizeBaseUrl 会补 http://,见 07 §2 第 1 条)+ 任意 ASCII 账号密码 → 每填一个字段都 dump 一次确认值真的进去了(别信「刚才填过」),再点登录。

6. S7②/S3 第二轮再次受阻(2026-10-06 下午,写给下一个我)

  • 模拟器跑了约 6 小时后劣化:screencap 返 0 字节、uiautomator dump 大面积失败或返陈旧缓存(同一次交互前后两张 dump 的地址栏展开态互相矛盾)。已重启模拟器(run10;boot=1 约 170 秒;重启后 adb root 与两条 adb reverse 都要重新加)。
  • 重启后仍不稳:设备侧 system_server 111% CPU、sensors HAL 73%、Google 全家桶在抢;而主机上当时也有负载(Get-Process 里能看到 cs2、msedgewebview2 等)⇒ 大概率是主机被占,不是 App 的问题。以后遇到「adb 很慢 / 截图 0 字节 / dump 失败」先看主机在跑什么,别先怀疑代码。
  • 这一轮走到哪:已成功登录(假后端收到 POST /auth/login + WS + 一串后续)、会话也已重建;但进次级会话那一步没成(dump 拿不到抽屉行坐标,盲点 (146,595) 也没落到行上)⇒ S7② 仍未验;S3 同样未动。
  • 一条不依赖 dump 的验证套路(下次直接用):盲点 + 用假后端的请求流水判结果 —— 进次级会话的判据是 POST /api/v1/sessions/<id>/read 出现在流水里;进没进一看便知,比 dump 可靠得多(这也是 W49/W51 那两轮用过的路子)。
  • 收工时状态:adb reverse 已指回真后端(tcp:80/tcp:10942 → 10942);假后端已停;App 停在聊天页(拿的是假后端 token,会在真后端 401 上登出)。

7. S7② 与 S3 的真机验收(2026-10-06 下午 · 补齐,两条都结清)

S7② —— 通过

  • 造场景:App 停在次级会话(进门判据 = POST /api/v1/sessions/<id>/read,比 dump 可靠)→ 灌一条让它本端有内容(否则断线游标为空、逼不出补洞)→ 服务端 POST /__control/forget → POST /__control/ws-close 真踢(⚠️ 撤 adb reverse 不算踢:已建立的连接不会断)。
  • 判据①(那条请求 404 ⇒ sessionGone):14:22:28.957 GET /api/v1/sessions/…0002/messages?after=1791267700478 —— 补洞请求打在一个已被摘掉的会话上 ⇒ 服务端 404 session_not_found(该响应本轮单独核过)。
  • 判据②的界面一半(告知):抓到了 —— [33,378][1047,480] | 这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话。⚠️ 它只显示 8 秒(SESSION_GONE_NOTICE_MILLIS = 8_000):前两次我都是事件后 ~50 秒才 dump,白跑两轮;要抓它必须卡在 8 秒窗口内(用「流水里出现补洞请求」当触发器,立即 dump)。
  • 判据②的「不依赖网络」一半:select(主会话) 的副作用 POST /api/v1/sessions/web/read 两轮都观测到(14:18:56.464 / 14:22:29.404)。 ⚠️ 但要修正我自己原来写的判据:我要求「read 出现在 GET /sessions 之前」——实测顺序不确定(第一轮 read 在前 .464→.480;第三轮在后 .040→.404,两者是各起一条协程)。那条顺序判据不成立,别再拿它当判据;真正该验的是「refresh 失败时也能回去」那一档,本轮没做。
  • 顺带又验了 W51 判据第 1 条:同一个 VM 里 sessionGone 一旦为 true,再重连也不再补拉(第三轮 kick 后没有任何 …0002/messages)——「已知它没了就别再撞」生效。

S3 —— 否证(这个场景下没有空态闪烁)

  • 手段(不改生产代码):adb shell screenrecord --time-limit 20 --bit-rate 6M /sdcard/s3.mp4 + 冷启动 → ffmpeg -vf fps=8,scale=540:1170 -f rawvideo -pix_fmt rgb24 → 用一段 Python 逐帧量两块区域的深色墨迹(阈值 <120):中央空态框(设备 x420-660 / y1180-1260)与第一条气泡框(x78-600 / y380-530)。
  • 时间线(8 fps,覆盖 0–11.4 s;抽帧确认过每一段的真身):
    时间 中央那块 气泡那块 抽帧确认是什么
    0.1–4.6 s 窄墨迹 x540-588 0 启动画面(就是新版 App 图标,顺带证明图标换上了)
    4.9–7.4 s 宽墨迹 x460-620 0 「加载中…」(不是「还没有消息」)
    7.6 s 起 0 352 内容出现
  • 结论:在线冷启动进「有内容的次级会话」时,屏幕是 启动画面 → 「加载中…」 → 内容,没有出现空态 ⇒ S3 在这个场景下否证。
  • 口径与局限(别读成「S3 不存在」):8 fps 采样(<125 ms 的闪烁会漏)、只测了这一个场景、只测了这一个 build。
  • 方法论一条:别用「墨迹的 x 宽度」去猜那是哪一句话——我一开始按宽度推断 4.9–7.4 s 是空态,抽帧一看是「加载中…」。量到异常就抽那一帧看,这才是收口。

8. ★ 纪律(主人 2026-10-06 当场纠正,写死)

  • 不准用 WaitFor 等子代理(哪怕动机是「本回合内把它收回来」):它会把整个回合锁住,主人在这期间无法插话——主人当场打断了我的 WaitFor 并明确说「纪律是不准用 WaitFor」。
  • 正确做法:派完就结束回合(子代理完成会自动唤醒我);等待期间只做与它们不冲突的并行工作,或者干脆停手。多支并行时,谁先回来就先收谁。
  • 备注:这条在后端仓库 Cyrene_v2/AGENTS.md §10 里原本就有(「等待子代理:不许用长 WaitFor 阻塞回合」),本项目文档此前没写、我因此重犯了一次,现补上。

★★ 锚点补记三十 · W52 五项落地(主人拍板的那五条)——已合并推送;真机验收部分通过、一项未通过

1. 代码(五支全部合并 + 真跑 + 已推)

合并到 579eccd(五次 --no-ff,其中 W52A 与 W52B 在 deleteSession 的同一锚点冲突一次,两侧都留)。主树全量 --rerun 真跑:101 suite / 1270 tests / 0 失败 / 0 error(基线 1205 ⇒ +65,全为新增)。五支的 worktree/分支已清、构建锁已放。

  • W52A(S4):core/session/ChatSessionSlots.kt + ChatSessionEviction.kt —— 一格会话一份 ViewModelStore(这样「交出一格」就是那份 store 自己的 clear() ⇒ 那一格的 onCleared + viewModelScope 取消,订阅/补拉/在飞请求一起停);「最近」口径 = 最后一次成为当前会话;判据四条:主会话永不回收 / 屏幕上那一格永不回收(它一死那一段就不再更新)/ 有草稿或未上路发送的格子永不回收(回收会抹掉用户唯一一份内容)/ 其余按最旧先走、keep=3,被豁免的那些占名额。删会话定点回收、登出全清。槽位挂在 Application 级容器上 ⇒ 存活期改由「≤3 格 + 回收/登出」界定(行为变化,已记在 KDoc)。
  • W52B(S5+S9):ui/sessions/SessionBusy.kt 纯账本(只认活帧、绝不外推;session_idle 无条件收、session_interrupted 仅在 activeTurns=0 时收、turn_end 只收认得的那一路、stream_end 刻意不参与;WS 掉线当场作废账本)+ 抽屉行内第三行「正在思考…」+ 「停止」(用既有 WsOutbound.Interrupt,恒发 scope=current,从不发 all)。⚠️ 「停止」顶掉了该行的置顶按钮(宽度硬上限),灯灭后置顶回来 —— 主人如需并列请说。
  • W52C(登出条目):BasicComponent(title="退出登录", error 色, startAction=Icon(MiuixIcons.Remove)) —— 图标是穷举 Miuix 图标集后选的(Basic 只有 7 个、extended 156 个里没有 exit/logout 字形)。
  • W52DE:ui/chat/MessageServerActions.kt 纯判据(proactive-local: 前缀 ⇒ 不做服务端动作,选字/复制不受影响)+ 空态拆两种真相(「还没有消息」/「消息没能加载出来」)+ retryFirstPage()(一行转调 loadFirstPage())。
  • W52K(保活):core/keepalive/{KeepAliveService,KeepAlivePolicy}.kt + 设置页 KeepAliveRow.kt —— 默认关、低优先级静默常驻通知、dataSync 类型、onTimeout 自停(不自己停会 ANR);判据 keepAliveAction(enabled, sessionKnown, loggedIn, notificationsAllowed) → Start/Stop/Leave(Leave 是「登录态未定,此刻不该有主张」)。

2. 真机验收(emulator-5554 + 假后端)

项 结果 证据
设置页两行(W52C/W52K) ✅ dump:后台保持连接(带如实代价文案)+ 退出登录(带文字);截图确认开关渲染正常
抽屉「谁在跑」(S9) ✅ POST /__control/busy?session=web ⇒ 抽屉里 与昔涟 行出现 正在思考…
「停止」入口(S5) ✅ 点它,客户端发出 {"type":"interrupt","data":{"sessionId":"web","scope":"current"}}(既有帧;out/upstream.jsonl 为证)
降级气泡不给删改入口(D) ✅(弱对照) 注入 --without-id 气泡后屏幕上该气泡没有「消息操作」入口;但同屏没有服务端行可对照(假后端主会话为空)⇒ 这条主要由那 6 条单测承担
首屏失败「重试」(E) ❌ 未通过 断网冷启动后:同屏有错误横幅「无法连接到服务器」+「重连中…」,但中央是「还没有消息」且没有重试按钮(dump + 截图双证)。⇒ 判据里的 hasError 在实际那一刻为假,而那条横幅确实来自 state.error?.let ⇒ 我的状态模型与现场不一致,根因未定位。下一步:在 ChatScreen 那一支加一条一次性诊断日志(打出 loading / hasError / items.size)再定;另一种可能是判据该直接问「首屏有没有失败过」而不是 error 这个代理(W52DE 自己也登记过这个洞:横幅上的 X 会清掉 error,按钮跟着消失)。
保活开关 → 前台服务 未验 只验到「开关渲染正确」;服务是否真起、通知观感、13+ 权限流都没验
S4 回收 未验 只有单测 + 走查(VM 生命周期在 JVM 上起不来)

3. 这一轮踩到 / 学到的

  1. mCurrentFocus 之外还要防「别的 App 抢前台」:Google Play 服务的账号核验页(gms PreAddAccountActivity)在我们操作期间抢了前台,我的 dump/截图全打在它身上,白跑好几轮。⇒ 判据第一步永远是 dumpsys window | grep mCurrentFocus;看到不是本 App 就先清掉它(keyevent 4 × N + keyevent 3,再 am start 拉回来)。
  2. INTERNET 权限的更正(我先前说「release 可能没网络权限、得单独验」):W52K 跑了 :app:processReleaseMainManifest —— release 合并清单里有 INTERNET,来自传递依赖 com.squareup.okhttp3:okhttp-android:5.5.0(不是 AGP 只给 debug 注入)。结论:release 现在能联网,但那是搭在传递依赖上的 —— 换掉/排除 okhttp-android 会静默失去网络权限。建议无条件显式写进源码清单(一行,还没做)。
  3. 构建锁协议有洞:这批有切片是整文件删除而不是只删自己那行 ⇒ 别人的占用行一起没了(W52B/W52K 都提到过)。下次按「一行一个占用者、只删自己那行、收工前先读一遍」写死。

4. 复验补记:W52/E 的「重试」已修(0054a3a,真机端到端通过)

  • 根因不是「我的状态模型不一致」,是接线极性:判据 shouldOfferRetryOnEmpty(loading, hasError, hasItems) 的本体是 !loading && hasError && !hasItems,参数语义是「列表里有没有东西」;而调用点传的是 state.items.isEmpty()(极性相反)⇒ 在「列表为空」这个唯一会求值的场景里结论永远为 false、按钮永远不出现。
  • 怎么抓到的:在 ChatScreen 那一支加一条一次性诊断日志,把 UI 实际看到的三个输入打出来 —— loading=false hasError=true errorIsQueueFull=false items=0 retry=false:三个输入全真而结论为假 ⇒ 只剩「极性」一种可能。核对判据本体后确认。定位完日志已删(grep CyreneRetry = 0)。
  • 修 + 复验:调用点改成 state.items.isNotEmpty()。真机复验:断网冷启动 ⇒「消息没能加载出来」+「重试」;恢复网络后点它 ⇒ 发出 GET /api/v1/sessions/web/messages,离线期间落库的那条出现在屏幕上;列表非空后按钮自己消失。(顺带一条自查:我第一遍用 grep 重试 数按钮,结果那条消息的正文里正好也有「重试」二字 ⇒ 计数 1 是误报;按 dump 的节点看,按钮确实没了。)
  • 教训(值得单独记):判据单测全绿 ≠ 接线正确。极性 / 参数语义这类错在纯函数测试里永远看不见(测试调的是正确语义),只有调用点会露 —— 而调用点在 Compose 里、项目又没有 UI 测试装置 ⇒ 这条只能靠真机验收。这是「设备验收抓住单测抓不到的错」的第二例(第一例是 W50 的 timestamp 类型不符)。

5. 待办变化(S4 / S5 / S9 移出)

  • ✅ S4(常驻 VM 不回收)→ W52A:LRU 保留 3 格 + 四条豁免(主会话 / 屏幕上那一格 / 有未发出内容的格 / 其余最旧先走)。真机未验(只有单测 + 走查),已在 §2 表里如实标注。
  • ✅ S5(切会话不打断)与 S9(「正在生成」看不见)→ W52B:抽屉里看得见「正在思考…」+ 能停(走既有 interrupt 帧)—— 真机验收通过。
  • ✅ W52 其余三项:登出条目(W52C)、降级气泡不给删改入口(W52DE/D)、首屏失败显式重试(W52DE/E,修完复验)—— 真机验收通过。
  • 仍在待办:保活开关 → 前台服务/通知/13+ 权限流 未验;INTERNET 显式声明未做;构建锁协议未写死;主人待拍板的「保活第 3 件(后台收到消息弹系统通知 + 接上被丢掉的 proactive 判据)」。

6. 两件小收尾(主人「不反对」后做的;同批的第 3 件事见锚点三十一)

  • INTERNET 显式声明(app/src/main/AndroidManifest.xml):原来 release 能联网是搭在 okhttp-android:5.5.0 的清单合并上的;现在显式写进源码,并在注释里写清「为什么这里必须显式」(换掉那个依赖就会静默断网)。验收(按判据核过)::app:processReleaseMainManifest 的 merger 报告里它已是 ADDED from app/src/main/AndroidManifest.xml:26 且 MERGED from [okhttp-android] ⇒ 来源是源码。
  • 构建锁协议写死(这批真踩到:有切片整文件删除,把别人的占用行一起删了)。三条约定:① 一行一个占用者(<分支名> <PID> <时间>);② 只删自己那一行,绝不整文件删除;③ 改/删之前先读一遍当前有谁在占。

7. 新登记:分享进来的内容不再直接发出,改成确认面板(主人 2026-10-06 提)

现状(已核实):分享一进来就在 ui/chat/ChatScreen.kt 的分享落地那一支被立刻发出 —— ShareLanding.Send -> { ShareIntake.take()?.let { view: sendShared(share, shareNoticeFor(share)) } }。 主人要的:像其他 IM 一样给一个确认面板 —— ① 能预览支持的文件(图片缩略图 / 文件名字与大小);② 下方一个输入框让用户附上文字描述;③ 由用户点「发送」才真的发出去(可取消)。 指派:W53X(与 W53N/W53S 并行;它只许在 ChatScreen.kt 的分享落地那一小段动刀,其余放新文件)。 边界:不动 shareLanding / shareNoticeFor / shareWaitingNotice 的语义,不动 sendShared 的「超长文本走文件」那条(主人当时的裁决),不发则不 take 之前的内容——take 只许发生一次(否则 effect 重跑会重复弹面板)。


★★ 锚点补记三十一 · W53 落地(主人拍板那批 + 分享确认面板)——均已合并推送,真机验收全过

1. 代码(53c7494 / 6505512 / 62f552f / 77fbedc,均已推)

主树全量 --rerun 真跑:110 suite / 1330 tests / 0 失败 / 0 error(基线 1270 ⇒ +60)。三支的 worktree/分支已清、锁已放。

  • W53N(后台通知,只做「她主动说的话」这一档):读回被丢掉的 proactive 判据(核过后端:它在帧的 data 里而不是帧壳顶层 —— broadcast.go:44-49;客户端两层都读,两种说法都吃得住);纯判据 shouldNotifyProactive(inForeground, viewingTargetSession) =「这条此刻会不会正好被看到」;去重账本(进程级、容量 64、键 = 落库行 id > IM 消息号 > 时间字面量 > 正文);点通知 ⇒ 打开 App 并切主会话;渠道 proactive(DEFAULT)、通知 id=2,与保活的 keep_alive(id=1) 分开。
  • W53S(设置页两件):退出登录加 Miuix OverlayDialog 确认框(⚠️ 更正我任务书里的错:Miuix 0.9.3 没有 extra.* 包,对话框只有 overlay.* / window.* 两族);保活那行补「24 小时内累计最多 6 小时」+ 权限被拒/被撤时给「去系统设置」(ACTION_APP_NOTIFICATION_SETTINGS + 兜底 ACTION_APPLICATION_DETAILS_SETTINGS)。
  • W53X(分享确认面板):分享进来不再直接发,弹面板(图片缩略图 / 文件 chip(复用 DocumentChipRow)/ 可附文字的输入框(预填分享自带的文本)/ 发送·取消);用 window.WindowDialog(⚠️ 硬理由已写进 KDoc:OverlayDialog 需要 Miuix Scaffold 提供的 composition local,而它的调用点在 ChatScreen 的 Scaffold 外面 ⇒ 会「不报错也不显示」);take() 只发生一次(effect 的键里加了 shareConfirm == null,面板关掉后自动接上下一条)。
  • 我自己补的一处(77fbedc):W53S 标出的「去系统设置授权后回来,UI 说已开而服务其实没起」——给 AppPresence 补一条与 appInForeground 同源的 StateFlow,让 CyreneApp 那个起停 effect 收它当 key(「回到前台」正是从系统设置返回必经、且必然变化的那一位)。没加任何依赖。

2. 真机验收(emulator-5554 + 假后端)

项 证据
后台通知 App 切后台 ⇒ 注入 proactive_message ⇒ NotificationRecord(pkg=top.yeij.cyrene, id=2, channel=proactive, category=msg, AUTO_CANCEL);通知栏显示「Cyrene / 昔涟 / 通知验收:她主动说了一句」
点通知 点它 ⇒ 回前台,且聊天页正是主会话、那条主动消息就在屏幕上(带「发送至 网页」)
退出确认框 点条目 ⇒ 退出登录? +「退出后本机会清空登录信息(服务器地址也要重填),需要重新登录」+ 取消/退出登录;取消后仍在设置页、没登出
保活文案与入口 开着时含「24 小时内累计最多 6 小时,用满会被停下」;撤掉权限后文案变「通知权限现在是关的,服务起不来…」+ 出现**「去系统设置」**按钮
保活服务(上批欠的) 开 ⇒ isForeground=true、types=dataSync、channel=keep_alive、静音常驻;关 ⇒ 服务停;13+ 权限弹窗真实
分享确认面板 外部 ACTION_SEND 分享一段文字 ⇒ 对端上行流水里没有任何 chat 帧(确实没被直接发出),而是弹出面板(标题「发送给昔涟」+「分享的文字」预览 + 预填的输入框 + 取消/发送)
INTERNET :app:processReleaseMainManifest 的 merger 报告:ADDED from app/src/main/AndroidManifest.xml + MERGED from [okhttp-android] ⇒ 来源是源码

3. 仍未验 / 已知缺口(如实)

  • 「两条路只弹一次」只有单测:真后端要走 IM 桥那条路由才会同时来两帧,本机造不出来。
  • 通知频率:恒用 id=2 ⇒ 她连说几句时后到的覆盖先到的(KDoc 已写明;要「一事一条」得按 id 生成通知 id)。
  • 通知挂在 SessionsViewModel(Activity 存活期内):Activity 被销毁而进程被前台服务保活那一档(例如从最近任务划掉)收不到帧 ⇒ 不弹(要覆盖得把订阅提到进程级)。
  • 转屏会静默丢掉「已取走」的那份分享(面板状态是 remember(sessionId),take() 不可逆)——W53X 列了两条更糟的修法,我没动;要收口得把状态挪进 VM。
  • 面板里正文出现两次(预览 3 行 + 输入框预填):W53X 问要不要删掉预览那块,我的建议是删(输入框已经显示且可编辑),一处小改即可 —— 等主人一句话。
  • S4 回收:正向证据有了(超 3 格后回到最早的次级 ⇒ 重新拉一次 messages);对照组没跑干净(我几次点击没落到达标的抽屉行上),如实标注。
  • 给后端的一条(不影响我们):他们的注释写 proactive 是「帧顶」,代码实际放在 data 里(broadcast.go:49)——我们两层都读,所以都能用,但他们的注释值得对一下。

★★ 锚点补记三十二 · 续接锚点(2026-10-06 深夜 · 主人两条新指令 + 环境状态)

1. 主人刚下的两条(接手第一件事)

  1. 复验「分享面板正文出现两次」这个说法 —— 主人怀疑它不存在(原话「你确定真的在吗 好好看一看」)。不许只信 dump 与子代理转述:模拟器还在,用 adb 自己看,或派子代理去看。两条判据:① dump 里那段文字出现几次;②截图上肉眼是不是真看着重复。若确认重复,主人此前授意「按你推荐的来」,我建议删掉预览里那块(输入框已显示且可编辑)。
  2. 「保活长时间后台」验证可以开跑 —— 开着「后台保持连接」把 App 扔后台,过一段时间回来看:进程还在不在、连接断没断、后台还能不能收到她主动说的话并弹通知。

2. 此刻的环境状态(动手前先核一遍)

  • 假后端:python devtools/fake-backend/fake_backend.py --port 18090(后台任务,会到点被系统收);adb reverse tcp:80 tcp:18090。
  • 真后端 10942 活着(没碰);模拟器 emulator-5554 在跑。
  • App 登录:假后端不校验凭据,地址栏记着 localhost;若已被真后端的 401 登出,就在登录页填 e2eb / e2e1234(纯 ASCII 好打)。

3. 两条各自的跑法(照抄即可)

分享面板复验

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 读到的是:分享的文字 标签 + 一段正文(预览)+ 输入框里同样那段 ⇒ 两次;但主人怀疑,以自己看/截图为。)

保活长时间后台

# 1) 设置页打开「后台保持连接」(权限不在就先 adb shell pm grant top.yeij.cyrene android.permission.POST_NOTIFICATIONS)
# 2) 记基线:date;pid;服务;对端连接
adb shell pidof top.yeij.cyrene
adb shell dumpsys activity services top.yeij.cyrene | grep -E "KeepAliveService|isForeground"
# 3) 扔后台(input keyevent 3)后放着
# 4) 至少 1~2 小时后回查同样三项 + 注入一条 proactive_message 看后台还弹不弹通知

⚠️ 期间别让别的操作把 App 长时间拉到前台(那就不是「后台存活」了);每次检查都记时间点。

4. 本轮进度(写这条时的实况)

铺完环境(假后端 18090 + tcp:80 → 18090 + 拉起 App);分享面板复验与保活长跑都还没开始 —— 接上后按 §3 逐条做,结果写进本锚点或下一个锚点。

5. 两件事的后续(同日深夜补记)

  1. 「正文出现两次」的真相 —— 根因不是「预览多余」,而是「输入框被预填了」:dump 与截图都确认同一句话排了两遍(预览的「分享的文字」块 + 那个输入框),但主人 2026-10-06 深夜澄清了语义:那个输入框应当是「用户附加的描述」输入框,不是分享文本内容的复刻。 ⇒ 正确形态:预览留着那段分享正文(它才是这次要发出去的内容);输入框默认留空 + 占位符(例如「加一句描述…」);发送时把「分享自带正文(若有)+ 用户描述(若有)」拼起来(两段都在时用一个空行分隔,只有一段就只用那一段),拼接规则要穷举钉住;「都空且无文件」这一档按钮不可点(与 sendShared 开头那条早退同一口径)。 ⚠️ 我先前那条「删掉预览里的正文」的指令方向反了,已当场更正(W53X2 完成即按更正执行;若它已删,要改回来)。
  2. 保活长跑已开跑:基线 20:05:14、App pid 24883、KeepAliveService 在跑、假后端侧 1 条 WS 连接、App 已切后台(前台是桌面)。挂了一个后台任务:sleep 7200 后自动复查四项 —— ① 进程还在吗(PID 应与基线一致)② 前台服务还在吗 ③ 对端 WS 连接还在吗 ④ 注入一条 proactive_message,看后台还弹不弹通知(这是「保活到底有没有用」的真正判据)。

6. 分享面板与保活长跑的结果(同日更晚补记)

  1. 分享面板语义已改对并上机验过(W53X3 514901c → merge 30c5345,主树 110 suite / 1332 tests 真跑绿):
    • 预览留着分享正文(分享的文字 + 那段);输入框默认空 + 占位符「加一句描述…」(截图确认);
    • 发送 = 分享正文 + 空行 + 描述:实测上行帧原文 "text":"要分享的正文:看这个\n\nMyCaption123。",落库同一条 —— 拼接规则端到端验过;
    • 「都空且无文件」⇒ 按钮不可点(与 sendShared 开头那条早退同源);那个「名字说 A、行为做 B」的 shareConfirmDraft 已删。
    • ⚠️ 一条待主人定的观感取舍:预览正文仍只给 3 行(SHARE_PREVIEW_TEXT_LINES = 3),而输入框不再承载全文 ⇒ 发送前读不到整段正文。两条路:让预览那块可滚动/展开,或接受只能看开头。
    • 附一条测量教训(我的锅):adb shell am start … --es android.intent.extra.TEXT "带空格的值" 里的值会被空格拆开(我那次 URL 就是这样丢的,不是 App 吞的);要传带空格/URL 的值得先转义,或换成 file:// + --eu。
  2. 保活长跑重新起跑(第 1 次被「装新包」打断 —— adb install -r 必然杀掉 App,这是必然的、不是 bug):新基线 20:13:35、pid 25584、对端 WS 1、App 已在后台;新的 2 小时复查已挂上(到点自动叫醒我)。

★★ 锚点补记三十三 · 续接锚点(2026-10-06 深夜 · 上下文将满前落地)

压缩或换会话后先读这一节:现状、在跑的东西、待办、环境、教训都在这。

1. 仓库现状

  • HEAD = 827f0e6(= origin/android);主仓只有 devdocs/02 一处脏 —— 那是后端会话的在途改动:别 checkout、别提交。
  • 主树全量 testDebugUnitTest --rerun 真跑:110 suite / 1332 tests / 0 失败 / 0 error。
  • 本批的 worktree / 分支 / 构建锁全清(git worktree list 只剩主树)。

2. 本轮做完的(全部已合并推送 + 真机验收)

项 提交 证据
① 后台通知(只她主动说的那档) 53c7494 切后台 ⇒ 注入 proactive_message ⇒ 通知栏「Cyrene / 昔涟 / 她的话」;点它回到 App 并切主会话,那条消息在屏幕上
② 退出登录确认框 6505512 退出登录? + 说明 + 取消/退出登录;取消后没登出
③ 保活文案 + 「去系统设置」 6505512 开着含「24 小时内累计最多 6 小时」;撤掉权限后出现「去系统设置」
④ INTERNET 显式声明 f3d8d28 merger 报告:ADDED from app/src/main/AndroidManifest.xml
⑤ 构建锁协议三条 ed0824f 文档
★ 分享确认面板(含语义更正) 62f552f → 30c5345 分享进来不再直接发(对端无 chat 帧);面板:预览留正文 / 输入框空 + 「加一句描述…」/ 取消·发送;拼接端到端验过:"text":"要分享的正文:看这个\n\nMyCaption123。"
保活服务本身 62f552f 开关开 ⇒ isForeground=true、types=dataSync、channel=keep_alive 静音常驻;关 ⇒ 停
我补的一处 77fbedc 「去系统设置授权后回来服务没起」→ 给 AppPresence 补同源 StateFlow 当 key

3. 在跑 / 待续

  • 保活长跑(第 2 次):基线 20:13:35、pid 25584、对端 WS 1、App 已后台。挂着后台任务(sleep 7200 ⇒ 约 22:13 自动复查):① 进程还在吗(PID 应与 25584 一致)② 前台服务还在吗 ③ 对端 WS 还在吗 ④ 注入一条 proactive_message,看后台还弹不弹通知。结果写进本节或下一节。

4. 待主人拍板 / 待办

  1. 分享面板预览正文只有 3 行(SHARE_PREVIEW_TEXT_LINES = 3):输入框不再承载全文后,发送前读不到整段正文 —— 让预览可滚动/展开,还是接受只看开头?等主人一句话。
  2. 通知恒用 id=2 ⇒ 她连说几句时后到的覆盖先到的(要「一事一条」得按 id 生成通知 id)。
  3. 通知挂在 SessionsViewModel(Activity 存活期内)⇒ 从最近任务划掉 Activity 后收不到帧、不弹(要覆盖得把订阅提到进程级)。
  4. 转屏会静默丢掉「已取走」的那份分享(面板状态是 remember(sessionId),take() 不可逆)。
  5. S4 回收:正向证据有了(超 3 格后回到最早的次级 ⇒ 重新拉一次 messages),对照组没跑干净。
  6. 给后端的:① 仍在等他们答「tool_execution.end 能不能带可选 resultSummary」;② 已去核对一条事实(proactive 在 data 里而非帧壳顶层,他们注释写「帧顶」);③ 新登记一条依赖(实时帧的 id 与落库帧的 message.id 必须是同一条落库行 —— 我们据此去重);④ 新问一句 channel_persisted 会不会也带 proactive。给对面的提示词我已交给主人(贴过去即可)。
  7. 推送:主人继续研究,等他。

5. 环境(接着干之前先核)

  • 模拟器 emulator-5554 在跑;App 已登录(假后端,地址栏 localhost);adb reverse tcp:80 → tcp:18090。
  • ⚠️ 假后端 18090 那个后台任务会到点被系统收 —— 长跑复查前先确认 18090 还在,不在就重启:python devtools/fake-backend/fake_backend.py --port 18090(重启后它的 store 清空、App 会重连,对「保活」这件事无影响)。
  • 真后端 10942 活着、没碰。

6. 本轮新增的两条教训

  1. adb … am start --es android.intent.extra.TEXT "带空格的值" 会被空格拆开 —— 我测试分享时那个 URL 就是这么丢的(不是 App 吞的)。要传带空格的值得转义,或改用 file:// + --eu。
  2. 用户说「这里有点奇怪」时,先想清那个控件的语义,别急着按「渲染重复」去改 —— 我把「输入框被预填了正文」误判成「预览多余」,白派了一支(已停)。语义问题优先问清楚再动手。

★★ 锚点补记三十四 · 压缩后续接:W54 三支在飞 + 主人的三条拍板(2026-10-06 21:05)

上一节(三十三)之后上下文压缩过一次。本节记「压缩后我做了什么」+ 主人刚拍的三条。

1. 压缩后排掉的三件不需要拍板的收尾活(三支并行,文件集互不重叠)

支 内容 落点(关键文件) worktree / 分支
W54A 通知「一事一条」——通知 id 与 PendingIntent requestCode 改成按消息稳定派生,修「她连说几句时后到的覆盖先到的」 core/notify/ProactiveNotification.kt(PROACTIVE_NOTIFICATION_ID = 2 / PROACTIVE_OPEN_REQUEST_CODE = 2 / notify(...))、core/notify/ProactiveNotifier.kt 的 post 签名、app/AppContainer.kt:71 装配点 ../Cyrene-For-Android-wt54a / wt54a/notify-per-message
W54C 转屏静默丢掉「已取走但未确认」的那份分享(take() 不可逆 + 面板状态是 remember(sessionId)) ui/chat/ChatScreen.kt 分享落地那段、ui/chat/ChatViewModel.kt(候选落点) ../Cyrene-For-Android-wt54c / wt54c/share-survives-rotation
W54D 分享确认面板预览区可滚动(3 行高度上限不变,用户发送前能读到整段正文) ui/chat/ShareConfirmPanel.kt(SHARE_PREVIEW_TEXT_LINES = 3)+ 分享测试 ../Cyrene-For-Android-wt54d / wt54d/share-preview-scroll

三支的公共约束:不引新依赖、禁 Material3、禁碰 devdocs/**、只本地 commit 不 push、Gradle 走 D:/Project/Code/Uni/.cyrene-build.lock(只删自己那行)。A 还禁碰 UI;C 禁碰 ShareConfirmPanel.kt(D 的地盘);D 禁碰 ChatScreen.kt/ChatViewModel.kt(C 的地盘)。

第 2 批(未开):订阅提到进程级(现在通知挂在 SessionsViewModel,Activity 一被划掉就收不到帧)——它要动 AppContainer.kt 与订阅装配,跟 A 同一片区域,等 A 合入后再开,避免同批冲突。

2. 主人 2026-10-06 21:0x 的三条拍板(原文要点)

  1. 分享面板预览:「可以滚动 可以展开」 —— 两个都要。所以 D 支做完滚动后,还要追加「展开/收起」:默认仍是 3 行 + 内部滚动,长正文时给一个展开控件,展开后能读全文,再点收起。(D 的 prompt 里只写了滚动,追加那一轮要另发,不要重做。)
  2. 给后端的提示词「贴过去了 等他回复」 —— 等的是:① tool_execution.end 能否带可选 resultSummary;② channel_persisted 会不会也带 proactive���③ 实时帧 payload.id 与落库帧 message.id 是否同一条落库行(我们据此去重)。收到回执前不要据此改工具卡 UI。
  3. 推送:「我还在研究」 —— 等他,不主动推进。

3. 此刻环境(动手前照核)

  • HEAD 曾=33c3202(= origin/android),主仓唯一脏是 devdocs/02 —— 后端会话的在途改动,别 checkout、别提交。
  • 模拟器 emulator-5554 在跑;App pid 25584、KeepAliveService 在、App 在后台(前台是 launcher)。
  • 假后端 127.0.0.1:18090 活着(store 已清空:sessions[].messages = 0,说明它重启过,App 会自愈重连,对保活判定无影响);真后端 10942 活着、没碰。
  • ⚠️ 22:13 长跑复查之前不要操作 App 前台(一操作就把「后台存活」的基线破坏了)。S4 对照组同理,排到长跑之后。

★★ 锚点补记三十五 · 切真后端 + 标签解析 bug(已修已验)+ W54/W55/W56 五支落地(2026-10-06 21:45)

本节是三十四之后的实况:模拟器现在连的是真后端,主人报的「标签解析小问题」已定位到根因并修完验完。

1. 五支全部合并推送(a60e3ab = origin/android)

支 内容 提交
W54A 通知「一事一条」:通知 id 与 PendingIntent requestCode 按消息稳定派生(proactiveNotificationId(key),先展宽 Long 再 % 落到 [1000, 2e9+999],避开保活的 1 / 0) 53339b3
W54C 转屏不再静默丢掉「已取走的分享」:面板状态与描述移进 ChatUiState.shareConfirm(ShareConfirmDraft + 纯函数 shareConfirmClaim),take() 仍只发生一次 1142bf5
W54D 分享面板正文预览:3 行高度上限 + 内部滚动(cap 由 TextLayoutResult 量出,非写死 dp) ec89d82
W55 解析器剥掉落单标签(本节的 bug①) 464e916
W56 分享面板预览加展开/收起(主人拍板「可以滚动 可以展开」的后半) 14955ef
  • 合并后主树全量真跑:112 suites / 1361 tests / 0 failures / 0 errors。
  • worktree / 分支 / 构建锁全清(git worktree list 只剩主树)。
  • ⚠️ worktree 目录删除在 Windows 上会因长路径失败(git worktree remove 报 Filename too long,cmd rmdir、robocopy /MIR、Remove-Item 都不行)。可用的招:find <dir> -depth -delete。注册表用 git worktree prune 清。

2. 模拟器已切到真后端(主人 2026-10-06 晚要求)

怎么切的(App 设置页里没有「服务器地址」栏,改地址只能走登出):

  1. 抽屉 →「App 设置」→ 滚到底 →「退出登录」→ 确认框点「退出登录」;
  2. 登录页填地址 / 用户名 / 密码 → 登录。

⚠️ 踩到的坑(下次照抄结论):adb shell input text "localhost:10942" 里的 : 会被中文输入法转成全角 :(U+FF1A),于是地址成了 localhost:10942 → URL 非法 → Retrofit 抛 IllegalArgumentException → mapException 的 else 分支 → 界面显示**「发生未知错误」**(CyreneError.LocalKind.Unknown)。 排查凭证:uiautomator dump 读出 EditText 的真实值 = localhost\uff1a10942(转义后一眼看到全角)。 绕法(已采用):把 tcp:80 直接转发到真后端,地址只填 localhost(纯 ASCII,无冒号):

adb reverse --remove tcp:80 && adb reverse tcp:80 tcp:10942

当前转发:tcp:80 → tcp:10942(真后端)、tcp:10942 → tcp:10942。⚠️ 假后端 18090 因此失去了 tcp:80 映射,要回去用假后端得把这条改回来。

当前登录态:真后端、账号 yeij0942(role=owner),主会话 web。宿主机直连验证:POST http://127.0.0.1:10942/api/v1/auth/login → 200 {token,expiresAt,role:owner,refreshToken}。

3. bug① 「标签解析」——根因、证据、修复、验收(这是主人报的那条)

现象:气泡正文里字面显示 <p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?。

根因(服务端产出的 markup 畸形):从真后端拉到的那条消息 content 逐字节是

<p><action>满意地点点头</action></p>\n<p>真乖♪ …放心的一句。</p>\n<p><p>那现在——…发会儿呆?</p>

第三段 = <p><p> 两个开标签、一个 </p>。客户端 ChatContentParser.kt:70 的 P_BLOCK_RE 是非贪婪的,必然把内层裸 <p> 收进本段正文,于是标签被当正文渲染。

修(客户端容错,464e916):只剥完整匹配标签正则的落单 <p> / <p 属性> / </p>(有完整块时也剥块外未认领的旁白标签);1 < 2、<3、a<b、<p2>、<P>(大写,有既有反契约)、残缺 <p 一律原样保留。17 条新单测。

  • 流式那条:stablePrefixEnd() 一行未改,理由写进了 KDoc(前缀里每个开标签都已配对 ⇒ 中间态不存在「还没定死的落单标签」;而 <p><p>… 这种扣留点是 0,中间态压根不产帧,谈不上跳动)。子代理评估并否决了「放宽扣留让正文更早流出」的方案(会引入字面标签闪现)。

真机验收(emulator-5554 + 真后端):装新 APK 后打开主会话,那条消息现在渲染成 那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?(干净无标签),旁边那条旁白/两段仍各就各位。

已登记给后端(devdocs/02,本节末尾:「客户端登记(2026-10-06 晚 · 在途消息出现 markup 畸形…)」,含逐字节凭证、为什么「任何按契约实现的消费方都会踩」、我们已容错不需要他们动作、以及问他们两件事:① 是拼接 bug 还是有意形状?② 若是 bug 能否修在产出侧?)。 ⚠️ 这一节我没有提交:devdocs/02 工作区里压着后端会话的在途改动(8 个 hunk,最后一支延伸到文件末尾),提交会跟它打架。内容已写入磁盘文件,提交留给掌握该文件的会话。

4. bug②(顺带诊断)模拟器为什么卡 —— 不是分配少了

证据(宿主 Get-Process 按累计 CPU 排序):同时跑着 cs2(游戏,34837 s)、llama-server(本地模型,53186 s)、com.docker.backend、GameViewer、加速器,并且当前是 RDP 会话(query session → rdp-tcp#1 为当前;emulator.exe / qemu-system-x86_64.exe 都跑在 RDP 会话里)。

  • AVD 配置本身不寒酸:hw.ramSize=8192、hw.cpu.ncore=5、hw.lcd=1080x2340@440;模拟器内 top 显示 idle 仍有 398%(CPU 不是瓶颈)。
  • emulator -accel-check → WHPX 可用(硬件加速正常)。
  • 主因判断:RDP 会话下 GPU 加速路径退化 + 宿主的游戏/模型/Docker 抢资源;我这边并行跑的 Gradle 构建也在抢。
  • 已做(无损):settings put global {window,transition,animator}_animation_scale 0(关动画,UI 立刻跟手,不改分辨率所以坐标不变)。
  • 没做(要重启模拟器,得主人点头):换 -gpu angle_indirect、或降 hw.lcd 分辨率/密度。⚠️ 重启模拟器必然杀 App。

5. 环境现状(动手前照核)

  • HEAD = a60e3ab = origin/android;主仓唯一脏 = devdocs/02(后端在途 + 我这一节的登记)。
  • 模拟器 emulator-5554 前台是 App;App 已装合并后的 APK(adb install -r 会杀进程,不是 bug)。
  • ⚠️ 保活长跑基线(20:13:35 / pid 25584)已被打断两次:主人自己把 App 切到前台;以及我 adb install。那个 22:13 的复查任务结果只能当「装包后重启的 App 能不能重新连上」看,不能再当「后台存活」的证据。
  • 假后端 18090 进程仍在,但已不在 App 的路径上。

6. 待办(更新后)

  1. 等主人真机验收(他自己看,我不截图代替):分享面板「3 行窗口 + 内部滚动 + 展开/收起」(W54D + W56);退出登录确认框;保活文案。
  2. 等后端回执:.end 的 resultSummary、channel_persisted 是否带 proactive、实时帧 id 与落库 id 同源、以及本节 3 的两问(markup 畸形)。
  3. W56 留下的已知取舍:分享面板的展开态不跨转屏(状态在 remember(preview.text);跨转屏要么进 ChatUiState(红线)要么 rememberSaveable(本仓零先例)→ 待主人定要不要做);展开态上限 240dp 没在真机上量过。
  4. 批 2(未开):订阅提到进程级(Activity 被划掉后收不到帧)。W54A 已合入,AppContainer.kt 的冲突面解除,可以开了。
  5. S4 回收对照组(设备侧,随时可做,长跑已无基线可言)。
  6. 推送:主人继续研究,等他。

7. 保活长跑第 2 次的复查结果(作废,不构成回归证据)

22:13:39 到点自动跑了那四条,原始输出:

1) 进程还在吗: (空)
2) 前台服务条数: 0
3) 对端 WS 连接: 0
4) 注入一条 proactive: delivered=0
5) channel=proactive 的通知条数: 0

为什么作废(三条各自独立成立的原因,都不是功能回归):

  1. 我在长跑期间 adb install -r 装了新 APK —— 装包必然杀 App 进程,这是已知事实(锚点三十三已写)。
  2. tcp:80 现在指向真后端 10942(切服务器时改的),App 已不在假后端 18090 的路径上 ⇒ 往 18090 注入 proactive 帧 delivered=0、wsClients=0 是必然,与客户端行为无关。
  3. 复查那一刻(及现在)App 进程根本不在(pidof 空、前台是 launcher、KeepAliveService 0 条)——最可能的解释是保活开关当时没开(没开 ⇒ 没有前台服务 ⇒ App 在后台待了几十分钟被系统正常回收,这正是「不保活」的预期)。⚠️ 这一条我没有实证(App 已不在,读不到开关状态),不要当结论。

要重做才有效:得先把 tcp:80 切回假后端 18090(adb reverse --remove tcp:80 && adb reverse tcp:80 tcp:18090),再在真机/模拟器上开保活开关、建立新基线。⚠️ 但切回假后端会打断主人此刻在模拟器上用真后端看真实会话,所以没做,等主人定。

★★ 锚点补记三十六 · W57(通知消费点提到进程级)+ 保活长跑第 3 次基线(2026-10-06 22:25)

1. W57:Activity 被划掉后也能弹通知 —— 根因与修法(0e03eba → 合并 900d191 = origin/android)

结论先行:帧到得了连接,是到了没人消费。改动面因此很小(没搬连接所有权、没新起连接)。

根因链(子代理逐条给了行号证据,改前状态):

  1. 连接是进程级的:AppContainer.chatSocket(AppContainer.kt:282,by lazy)在 CyreneApplication.onCreate 建;ChatSocket.connect() 把连接循环跑在它自己的 scope 上(ChatSocket.kt:141,358-359,CoroutineScope(SupervisorJob() + Dispatchers.IO)),只有 close() 会取消它(ChatSocket.kt:165-175)。
  2. 没有 ViewModel 会关它:SessionsViewModel.onCleared() 是空的,KDoc 明写「刻意不关连接」。
  3. ⇒ 划掉 Activity 后连接照旧活着、onMessage 照旧 _events.tryEmit(...)。
  4. ⚠️ 但 _events 是 MutableSharedFlow(replay = 0, extraBufferCapacity = 512, DROP_OLDEST)(ChatSocket.kt:101-105)——replay = 0 意味着不为将来的订阅者留任何东西,没有订阅者时 emit 就是当场丢。
  5. 而 proactive 帧当时的订阅者是 SessionsViewModel.kt:232 的 viewModelScope.launch { socket.events.collect(::onEvent) },viewModelScope 随 VM onCleared 取消,该 VM 由 Activity 的 ViewModelStore 持有 ⇒ 划掉 Activity = 订阅被取消 = 帧被丢。

修法(app/ProactiveFrameIntake.kt 新增):

  • 进程级帧消费点:订 ChatSocket.events,把两类帧(实时 proactive_message / 带 proactive 的 message_persisted)交给 ProactiveNotifier;纯函数 proactiveFrameOf(event) 决定「哪些帧算她主动说的话、四个入参各取哪个键」。
  • 构造即订阅,刻意不提供 start/stop(理由写进 KDoc:唯一会想调 stop 的调用方是 VM,那正好把通路又绑回 Activity;留一个能忘调的 start() 只是多一种「通知整个哑掉且不报错」的坏法)。
  • AppContainer 新增 proactiveFrameIntake;SessionsViewModel.onEvent 里那两处转交删掉;ProactiveNotifier / AppPresence 只改 KDoc(判据、去重、post 签名零改动)。
  • 登出不需要取消订阅:帧只有连接活着才可能到,而连接在 LoggedOut 与令牌失效两处都会断;去重账本刻意不清(换账号后同一条再推也不该弹第二遍)。

验收:全量真跑 113 suites / 1370 tests / 0 failures / 0 errors(基线 112/1361,±0 其它形状变化)。新增 ProactiveFrameIntakeTest 9 条,其中 her words still notify when no activity is alive 就是本条的现场。

⚠️ 一个需要主人知道的副作用:AppContainer 现在会在 Application.onCreate 时提前把 chatSocket 建出来(改前 by lazy,第一次被 VM 取用才建)。代价是进程启动多造一个 OkHttpClient 对象(线程池仍懒建)。若不接受,改法是把 intake 的参数换成 () -> SharedFlow<WsEvent> 保留懒性——没做,因为那会让「订阅时刻」与「连接建出时刻」分开,多一处要解释。 另一处必要项:deliver 里接住了落地异常(改前异常会打死 VM 那条 collect,但 VM 重建会自愈;现在它是唯一消费点,异常逃出去就是进程崩溃 + 通知永久哑掉,所以必须接住,只 DEBUG 打日志)。

设备验收步骤(等长跑结束再做;⚠️ 装包必然杀进程):

adb root && adb reverse tcp:80 tcp:18090   # 注意顺序:adb root 会冲掉 reverse,要放在前面
adb install -r app/build/outputs/apk/debug/app-debug.apk
# 打开 App → localhost 登录 → 授通知权限 → 设置页开「后台保持连接」
# 进主会话 → Home 退后台 → 最近任务里把卡片划掉(等 2~3s)
pidof top.yeij.cyrene                       # 记下 PID
dumpsys notification --noredact | grep keep_alive   # 保活通知还在 = 进程确实被撑着
python devtools/fake-backend/proactive_inject.py --port 18090 --text "划掉之后也要弹"
dumpsys notification --noredact | grep 划掉之后        # 期望:有「昔涟 / 划掉之后也要弹」
pidof top.yeij.cyrene                       # 期望:与前面同一个 PID(证明是同一进程,不是重启后补弹)

判据前提(不满足则本次无效):注入之前 PID 未变 且 保活通知还在;通知权限已授予。

2. 保活长跑第 3 次(假后端,基线 22:16:08)

主人 2026-10-06 晚授权「允许你切」,于是把 tcp:80 切回假后端 18090 重做。基线:前台=launcher(App 在后台)、pid 31092、KeepAliveService 3 条(isForeground=true / foregroundId=1 / channel=keep_alive / ONGOING_EVENT|NO_CLEAR|SILENT)、假后端 wsClients=1。挂了后台任务:30 分钟抽查(22:46)+ 2 小时复查(00:16,含注入一条 proactive 看后台弹不弹)。

★ 顺带推翻了一条猜想:锚点三十五 §7 猜「上次复查时进程不在」可能是保活开关没开。这次 App 一启动我就查了——服务立刻就在跑(开关是开的)。所以那条猜想是错的,上次进程消失是别的原因(可能是系统低内存强杀,也可能是被手动划掉——仍未定,别当结论)。

当前转发:tcp:80 → tcp:18090(假后端)、tcp:10942 → tcp:10942(真后端)。要回真后端改这一条即可。

★★ 锚点补记三十七 · 长跑第 3 次定案(模拟器崩,不是 App 的问题)+ W57 端到端验收通过(2026-10-07 00:30)

1. 长跑第 3 次结果:30 分钟有效通过;2 小时那次失败是模拟器自己崩了

原始输出:

=== 30min 抽查 @ 22:46:34 ===        === 2h 复查 @ 00:16:34 ===
pid: 31092                             pid: (空)
服务: 3                                服务: 0
前台: launcher(仍在后台)              前台: Window{4f748ad …launcher}
ws: 1                                  ws: 0
                                       注入 delivered=0 / proactive 通知数 0

30 分钟那一条是有效证据:pid 与基线一致(31092)、服务 3 条、仍在后台、WS 还连着 ⇒ 半小时后台存活成立。

2 小时那一条的失败与 App 无关,证据链(五条独立):

  1. logcat 在模拟器时间 16:02(≈宿主 00:08,正卡在两次抽查之间)出现整段开机序列(sys.boot_completed=1、lmkd 启动、apexd…)。
  2. 但 /proc/uptime = 10.19 小时,没变 ⇒ 不是整机重启,是 system_server(framework)崩溃后重启。
  3. launcher 的 window id 从 2cbfcc8 → 4f748ad ⇒ launcher 也重启过(对上)。
  4. dumpsys activity exit-info top.yeij.cyrene(共 16 条)里没有 pid 31092 的退出记录,最新一条还是宿主 21:29 我装包那次(reason=16 PACKAGE UPDATED)⇒ 走的不是正常进程退出路径。
  5. 通知栏里那条 keep_alive 通知还残留着 ⇒ 进程非正常死亡(系统没来得及清)。

结论:模拟器 framework 崩 ⇒ 连带杀掉所有 App ⇒ 与客户端保活能力无关。但也不能反过来当「保活能撑 2 小时」的证据(那次没跑到)。模拟器这台机器(摘要里就记过「极不稳、launcher ANR 过、system_server 会崩」)不适合做小时级长跑,要拿这个结论得上真机。

2. ⚠️ 一条要紧的平台发现:Android 14 起「从最近任务划掉」会终止 dataSync 前台服务

原本 W57 的验收步骤是照「把卡片划掉」写的 —— 那测不出 W57:Android 14 改变了行为,用户划掉 App 时它的前台服务会被一并终止(我们的服务类型是 dataSync),进程随之死掉,于是「Activity 没了但进程还在」这个场景根本造不出来。

改用正确造法(本次采用,成功):

adb shell am start -n top.yeij.cyrene/.app.MainActivity   # 确保 Activity 在前台
adb shell input keyevent 4 ×4                              # 逐层返回直到 Activity finish

判据:前台变成 launcher(Activity 已 finish)+ pid 不变 + 服务条数不变 + WS 仍连着 ⇒ 场景成立。

3. W57 端到端验收:通过(emulator-5554 + 假后端 18090,含 W57 的 APK = 900d191)

步骤 实测值
通知权限 POST_NOTIFICATIONS: granted=true
装包含 W57 的 APK 后 pid=3180、KeepAliveService 3 条(开关本来就是开的,服务随启动自起)
造场景(返回键 ×4) 前台 = launcher(Activity finish)、pid 仍 3180、服务 3 条、假后端 wsClients=1
注入前 channel=proactive 0
注入 proactive_message(content「W57验收:Activity 没了也要弹」) delivered=1
注入后 channel=proactive 1
通知内容 android.title=昔涟、android.text=W57验收:Activity 没了也要弹(bigText 同)
注入后 pid 3180(与场景建立前同一个 ⇒ 同一进程,不是重启后补弹)

⇒ Activity 已销毁、进程靠前台服务活着时,「她主动说的话」照常弹通知。 这正是 W57 要修的那一档。

⚠️ 如实说明我们没做的:没有做「改前不弹」的对照实验(要另出 W57 之前的 APK、再走一遍,会占构建锁和一次装包)。所以「改前确实不弹」这一半靠的是代码级确定性证据(订阅挂在 viewModelScope、replay=0 的 SharedFlow 丢弃无订阅者的 emit)与 9 条单测,不是实验证据。别把它说成「实验证明改前是坏的」。

4. 环境现状(收工前)

  • HEAD = 900d191 = origin/android;worktree/分支/构建锁全清;主仓唯一脏 = devdocs/02(后端在途 + 我那条 markup 登记)。
  • 模拟器上装的是含 W57 的 APK;App 现在进程活着但 Activity 已 finish(前台 launcher),登录的是假后端 localhost。
  • 转发:tcp:80 → tcp:18090(假后端)、tcp:10942 → tcp:10942(真后端)。要回真后端只改 tcp:80 一条。
  • 假后端 18090 进程仍在。

5. 收尾(2026-10-07 01:00):假后端任务到点,环境已切回真后端

  • 假后端 18090 那个长寿后台任务到点被系统收掉(18090 已不在)。长跑也已用完,于是收尾。
  • 转发切回真后端:adb reverse --remove tcp:80 && adb reverse tcp:80 tcp:10942;现在 tcp:80 → 10942(真后端)+ tcp:10942 → 10942。真后端 health=200。
  • App 已重连真后端并拉到真实会话(截图确认:「已连接」+ 主人和昔涟的真实对话 + 旁白灰斜体 + 发送至微信/安卓/网页的来源角标)。pid=3180、KeepAliveService 3 条。

一条如实记录的观察(不是缺口,但值得知道):切后端后,屏幕上混进了验收期间留在本地的消息(那条「W57验:Activity 没了也要弹」)。

  • 我核实了:真后端主会话最近 12 条里没有它 —— 它只活在 App 本地。
  • 原因:这次切换只改了 adb reverse 的目标,App 看到的服务器地址字符串始终是 localhost、账号也还是同一个 ⇒ 按 W48 的口径(VM key 用账号指纹)key 没变 ⇒ 数据保留。客户端无从感知,所以这不是它的 bug。
  • 正常路径上不会发生:换服务器地址必须走登录页(地址栏只在登录页),而登出会 clearAll ⇒ 数据会被清掉。所以不登记为缺口,只记这里。
  • 那条残留会在 App 冷启动(重新从服务端拉历史)后消失;我没有强杀进程去清它(不想打断当时正开着的保活服务状态)。

★★ 锚点补记三十八 · 分享面板崩溃(W58,两次)—— 真机验收发现并修掉(2026-10-07 01:55)

主人这一轮说「允许你截图验收,1. 不用(不换前台服务类型)」,于是我开始自己上机验收 UI —— 第一次动分享面板就验出一个 P0。

1. 现象与根因(同一个异常,踩了两次)

真机崩溃栈(两次完全一致):

java.lang.IllegalStateException: Vertically scrollable component was measured with an
infinity maximum height constraints, which is disallowed.
  at CheckScrollableContainerConstraintsKt.checkScrollableContainerConstraints
  at androidx.compose.foundation.ScrollNode.measure
  ...
  at androidx.compose.foundation.layout.SizeNode.measure      ← heightIn(max = …)
  at androidx.compose.ui.window.DialogLayout.internalOnMeasure

第一次(01:37:30 pid 17630,分享进来、面板刚打开):ShareConfirmBody 里 var cap by remember(text) { mutableStateOf(Dp.Infinity) } —— 拿 Dp.Infinity 当「还没量到」的哨兵值,于是首帧 heightIn(max = Dp.Infinity) = 不施加约束,同一串 modifier 上的 verticalScroll 就在无界高度下被测量。

第二次(01:48:53 pid 2077,点「展开」的那一下):我第一笔修复把展开态的 heightIn 去掉了(改对了方向),但把 verticalScroll 留着 —— 而外层 ShareConfirmPreviews 自己就是一个 verticalScroll,它给内容的是无界高度 ⇒ 这一层的 scroll 又在无界高度下被测量。

修法(两笔合起来才完整):

  1. ShareConfirmPanel.kt(df7b504):这一层的高度上限只经纯函数 shareConfirmWindowCap(measured) 一个出口(返回 measured 或 SHARE_PREVIEW_MAX_HEIGHT,永远有限),哨兵值改用 null;展开态把这一层整个去掉(上限 + 滚动一起),只留外层那一层滚 —— 这也正是 W56 原本写在注释里的设计意图。
  2. 测试:ShareConfirmWindowCapTest 4 条,钉住「任何输入都不会产出非有限上限」+「两种状态在链上都还有有限约束」。
  3. 全量真跑:114 suites / 1374 tests / 0 failures / 0 errors。

2. 真机验收(emulator-5554 + 真后端,改后重装)

用 uiautomator dump 的 bounds 当判据(比截图准,也能避开「抓到首帧」的坑):

状态 正文节点高度 控件 pid
收起 [127,1614][953,1767] = 153px(3 行) 「展开」 4063
点「展开」后 [127,1512][953,1767] = 255px,面板整体上移 「收起」 4063
再点「收起」 回到 153px — 4063

三次操作同一个 pid,exit-info 里没有新崩溃记录 ⇒ 崩溃修掉、展开/收起功能都正常。

3. ★ 三条教训(都值得写死)

  1. 单测抓不到渲染层崩溃。W54D / W56 都只有纯 JVM 单测(本模块没有 Compose UI 测试依赖),两笔改动都「测试全绿」——而真机上一打开面板就闪退、一点展开又闪退。动渲染层必须上机,这条从今天起按硬约束对待。
  2. 「论证很详细」不等于「真机正确」。W54D 的交付说明里专门论证过「roundToPx 不会退化成 0、不会触发 checkScrollableContainerConstraints」——方向全对,但结论反了:触发那个检查的恰恰是 Constraints.Infinity 本身。子代理的反编译论证值得读,但不能替代一次真机点按。
  3. verticalScroll 的约束来源要看「谁在外面」。同一个 verticalScroll,被 heightIn(max = 有限值) 包着就安全;一旦外面是另一个 verticalScroll(哪怕是隔了一层的 Column),它拿到的就是无界高度 ⇒ 崩。「去掉上限」和「去掉滚动」是两件事,必须一起做。

4. 顺带纠正一个我自己的误判(W56 的结论被推翻)

我第一次看截图时以为「3 行窗口和展开控件都没生效」(截图上正文显示了 5 行、也没有「展开」)。是错的:uiautomator dump 显示「展开」节点在、正文节点高 153px(3 行)。 真相是我抓到了首帧(此时 cap 还是 fallback 150dp,还没收敛),而 dump 是稍后采的(已收敛到 3 行)。 ⇒ 这反过来证明:W56 当初判定「首帧那次回流看不见」是错的,它在真机上确实可见(5 行 → 3 行跳一下)。 待办:要不要消掉它(W56 给的备选是组合期 rememberTextMeasurer)——等主人定,不是回归、不影响可用性。

5. 环境(本轮变化)

  • 模拟器已重启(主人建议):adb emu kill → 冷启动 -avd Pixel_10_Pro_XL -no-boot-anim -no-snapshot-load,约 35 秒 boot_completed=1。重启会丢 adb reverse,已重设:tcp:80 → 10942(真后端)、tcp:10942 → 10942。
  • ⚠️ 重启前的旧模拟器上,system_server 崩过至少三次(com.android.phone 反复报 DeadSystemException: The system died,时间 00:03 / 00:48 / 01:19)——这也是长跑第 3 次 2 小时失败的原因(见上一节)。
  • HEAD = df7b504 = origin/android;主仓唯一脏 = devdocs/02(后端在途 + 我的 markup 登记)。

★★ 锚点补记三十九 · 后端回执二十五/二十六 + W59 工具卡结果区(2026-10-07 上午)

1. 后端说了什么(两条回执,都在 devdocs/02)

回执二十五(四条 + 一问 + 告知):

  1. .end 加可选 resultSummary ⇒ 做。「与回看面 toolCalls[].resultSummary 同一份预算」:截断 ≤500 字符、走同一条脱敏路径。缺席语义:只有结果真的拿到了才出现;失败/取消/超时/未落图 ⇒ 整键缺席,绝不用空串顶位、也别把缺席读成「没有输出」。原话:「你们现在就可以把工具卡定死」(在场渲染结果区 / 缺席只显示工具名与状态,同一套 UI)。排期=新切片、下一个契约版本;.update 仍不补。
  2. 事实核对:我们对、他们注释错了 —— proactive 确实在帧的 data 里(不是帧壳顶层),他们已把 4 处注释 + 2 处测试注释改掉。
  3. 新登记的依赖 ⇒ 确认:proactive_message.data.id 与 message_persisted{proactive}.data.message.id 同源(都是主会话落库行的 id)⇒ 登记成成对契约;⚠️ proactive_message.messageId 是 IM 侧号,不能拿去对齐落库行。
  4. channel_persisted 会不会带 proactive:结构上会(帧型由会话 id 决定、proactive 是独立入参),但当前所有主动推送都落主会话 ⇒ 实际发的是 message_persisted。我们两层都读是正确做法,继续保持。
  5. 告知:主动帧的 sessionId 恒为主会话 web(所以「切主会话」与他们的口径一致)。

回执二十六 + ·补(回我们的 markup 畸形登记):

  • 问 1 ⇒ 是 bug,不是有意形状(契约里 <p> 就是「一段」,没有嵌套语义)。
  • 问 2 ⇒ 认下,已修完推库 8051906(fix(turnreply): 产出侧不再产出未配对的段落标签)。根因两半且在同一个文件:① paragraph.go 的段块正则非贪婪 ⇒ 把 <p><p>X</p> 当成「已完整包裹」逐字保留,连护栏都用同一个非贪婪正则(护栏是盲的);② normalizePiece 对「未闭合的段首 <p>」无条件再套一层 —— 他们用我们上报的那条串逐字节复现了。③ 顺带挖出幂等漏洞(落单 </p> 越跑越多)。
  • ⚠️ 未部署:跑着的后端是旧产物 ⇒ 畸形仍会产出,要我们继续保留容错(我们已保留)。历史畸形只 1 行,不做回填脚本。
  • 契约面会补一条产出侧硬约束(落库/对外文本不得出现未配对的段落标签)。
  • ⚠️ 一条与归因有关的诚实说明(他们给的):那条具体是 (a) 还是 (b) 产生的无法判定(两条路径产出的串逐字节相同、原始模型输出没副本)。

2. 我们的动作

① 去重口径复核 ⇒ 没踩坑(无需改动)。他们提醒「messageId 别拿去重」,我核了 proactiveNotifyKey:它不是拼接,而是 firstOrNull { !isNullOrBlank() },顺序 id → messageId → timestamp → 文本兜底。⇒ 实时帧(id = 落库行 id)与落库帧(message.id = 同一行 id)算出同一个键,去重正确;messageId 只在 id 缺席时才可能被用到,而那档(落库失败)根本没有对应的落库帧。这条已写进给他们的答复。

② W59 工具卡结果区(316cd27 → 合并 e0bdaa3 = origin/android):

  • WsEvent.ToolExecutionEnd 加可选 resultSummary(共用载荷 ToolExecutionData 跟着加,只有 .end 的 dispatch 读它)。
  • 判据做成纯函数 toolResultTextOf(tool) 三档:有值 ⇒ 结果区显示它;空串/纯空白 ⇒ 视同缺席(不画,防出空块);缺席 ⇒ 连「结果」标签与它上面那道 Spacer 都不画(与今天逐字节一致),绝不画「没有输出」这类替作者下结论的文案;缺席但有历史 output ⇒ 回落 output(老口径不变)。
  • ⚠️ 没有引入任何 verticalScroll / 限高(沿用参数块那套 FoldCardBody)⇒ 结构上不可能重复锚点三十八那次崩溃。
  • 14 条新单测(解码 5 + 判据/落卡 9);全量真跑 114 suites / 1388 tests / 0 failures。

③ 02 里写了「客户端答复」(收到 + 我们怎么做 + 去重复核结论 + 请他们别为历史那 1 行动手),未提交 —— 02 仍压着后端的在途改动。

3. 真机验收的边界(如实,别夸大)

W59 这一批实际上只有单测证据,两个独立原因:① 后端 未部署 ⇒ 真机上 resultSummary 恒缺席,「有值」那档验不了;② 后端当前没配工具 ⇒ 连工具卡本身都触发不出来,「缺席时与老版一致」这一半也没有可点的入口。 ⇒ 等后端部署 + 配了工具,再做真机验收(要造一次真的工具调用)。在那之前不要把这一批报成「已真机验证」。

★★ 锚点补记四十 · 后端回执二十八 + W60/W61/W62 三批落地(2026-10-07 中午)

1. 后端回执二十八(回我们的四问)—— 逐条

  1. resultSummary 的 500 是 rune(码点)不是字节 ⇒ 我们不需要宽容裁切(不会出现半个中文字)。顺带登记一处口径差异:实时帧 500 rune / 回看面 80 rune。
  2. 8051906 已部署(10:37:44 新产物 / PID 630540);同一批还上了 2c56bc9(修「智谱无链接结果被当无效丢光」)。他们纠了我一个前提:「没配工具」不成立(工具全注册),他们给的解释是「那一轮 tool_calls=0、模型自己没调」。
    • ⚠️ 但我们截图里见过「工具调用失败 · web-search」 ⇒ 工具确实会调、只是失败过。2c56bc9 已随这次重启带上 ⇒ 值得再试一次搜索。
  3. 未读面:没有独立「未读增量端点」,但消息前向增量 ?after=(毫秒 createdAt)早就在册 ⇒ 我们 W49 那次整表拉取可以直接换掉(他们明确批了)。
  4. 语音:POST /api/v1/audio/transcribe(U5,owner-only + 审计、同步、base64、32 MiB)早就有;没有的是「上传音频变成一个带 voice 的消息」。还逐条答了 6 个子问(时长上限未定义 / 格式白名单未定义 / voice_input 那条转写文本只进 user content / 失败两套口径别混用 / 转写同步但回合异步)。

2. 畸形回归:通过(拿他们部署后的产物验的)

拉主会话最近 60 条(覆盖 10-06 13:52 → 10-07 10:39,含部署后的消息):畸形只有 1 条,id = b88e55d6…,createdAt = 10-06 21:05:11 —— 正是我最初上报的那条历史行(他们说「历史只有 1 行」,对上了)。⇒ 部署后没有产生新的畸形 ✓

3. W60:后端重启后自愈(83a2a88 → 合并 55612a3)

根因(子代理推翻了我的一个前提):allClosed() 不是孤儿(SessionIdle / SessionInterrupted 会调它)—— 真正的缺口是那两帧在后端重启时永远不会到。而且这 bug 有两半:

  • 回合绑定:TurnBinding.closed 是唯一收口闸,只由 turn_end 类帧驱动;
  • 流式卡(第二半):turns 的两个写点都不碰 streams ⇒「思考中…」「执行 X…」底栏「正在思考…」三条闸全都不收。

「拉不到新消息」的真相(这条最精彩):补拉跑了(reconnectPull 命中 Forward),但那条永远 active 的流式路永远是列表最后一项 ⇒ 「跟随底部」的触发键(items.lastOrNull()?.key)不再变化 ⇒ 新消息进得来,屏幕不跟着滚。

修法:新增 ui/chat/ReconnectReset.kt —— isReconnect(previous, current, everConnected)(用只置不翻的 everConnected,因为 ws.state 是 StateFlow,Connected → Reconnecting → Connected 的中间跳会被合流吞掉);触发「连接重建」时把未收尾的回合 / 流式卡 / 工具卡一次性收口(不删任何内容,只把 active/thinkingActive/phase 收掉);第一次连接不算重建。17 条新单测。全量 115 suites / 1405 tests。

  • ⚠️ 设备复现验证还没做(子代理给了可照抄的假后端步骤)。别把这一批报成已真机验证。
  • 一条如实登记的代价:busyMode = null 会让「重建后第一次发送」不再先发 interrupt(服务端下次报 session_busy 即恢复)。

4. W61:分享路径审查(22d655b → 合并 3d8bc61)—— 主人报的「网易云分享文本闪退」归因

结论:当前 HEAD 这条路径没找到残留崩溃点 ⇒ 与已知 P0(6d4f2cd/df7b504:Dp.Infinity 当哨兵值 + 展开态没去掉 verticalScroll)完全吻合,证据指向主人手机上的包早于那个修复。

逐项(6 项全查过,证据都在回报里):

  1. Intent 多样性:EXTRA_SUBJECT/EXTRA_TITLE 本端不读;非 CharSequence 的 EXTRA_TEXT 由 SDK 的 getCharSequence catch ClassCastException 兜住(不抛);content:// 在接收那刻就拷进 cache(runCatching)。
  2. 空/边界输入:空串/空白/</<p><p>/半个代理对/\u0000/10 万字符 —— 全不抛。
  3. 面板渲染:面板里只有两个 verticalScroll,各自前面都有一层有限 heightIn;反编译 miuix-ui-0.9.3 全部 545 个 class,WindowDialog/DialogLayout/TextField 一个 scroll 节点都没有 ⇒ 「无界高度 + 滚动」在面板里不可能成立。
  4. 出队闸门与并发:闸在 take() 之前,认领落进 VM ⇒ 重建不会再取。
  5. 发送后渲染:含 <p>/<action>/星号时按既有口径走;新增 12 条跨层串测(ShareTextPipelineTest.kt)——此前跨层接缝完全空缺,而事故恰恰出在接缝上。
  6. 通知/服务:与 ProactiveFrameIntake/KeepAliveService 无任何调用关系。

顺带加固一处:shareConfirmWindowCap 加 takeIf { value.isFinite() } —— 把 W58 崩过两次的那条不变量从「靠调用方好习惯」变成由唯一出口自己保证(今天不可触发,是防御性收紧)。

三处如实登记、未改的缺口(都留给主人定):

  • ⚠️ domain/parse 有一处 O(N²):每个落单 <p> 都调 hasContentAfter 扫到串尾 ⇒ 实测 1000→189ms、4000→1107ms、16000 个 → 17.9 秒(跑在主线程的 MessageBubble remember 里)。触发需要「上千个落单 <p>」的文本(粘贴 HTML 残骸可能),网易云不是这个形状。修它要动 domain/parse 既有口径。
  • ⚠️ 只有 EXTRA_SUBJECT/EXTRA_TITLE 而 EXTRA_TEXT 为空的分享会静默(「点分享什么都没发生」)。可达性未证实,故只报不改。
  • ⚠️ 面板预览不走解析器、气泡走 ⇒ "<p><p>正文</p>" 预览里是一串标签、气泡里只剩「正文」(不一致,改它会动已定稿的预览口径)。

5. W62:语音消息接收侧(2fbf24a → 合并 9ced744)—— 已真机验收通过

契约三条硬纪律(Docs/api-client.md §4.4 / §6):判据 = voice 键存在(绝不从 content 认)、按 status 分支(transcribed/empty/failed)、name/mime/size/url 四者同生共死;durationMs 只有渠道如实给出时才有(绝不估算)。🔴 voice 只在 4 个 REST 读取面填充 ⇒ 实时帧上不要等 voice(落库帧在「落库那一刻」发,而 voice 列是落库之后回填的)。

实现:VoiceDto + MediaApi(@Streaming @GET @Url)+ MediaRepository/VoiceStore(Bearer + 缓存到 <cacheDir>/media/<name>、原子落盘、刻意不落 documents/ 以免扩大 FileProvider 暴露面)+ domain/chat/VoiceMessage.kt(判据唯一落点)+ VoicePlayback.kt(纯状态机)+ VoicePlayer.kt(android.media.MediaPlayer,零新依赖)+ VoiceBubble.kt。播放音源是本地缓存文件(不放网络流,因此不必把 Bearer 塞进播放器)。生命周期三处都收(点另一条 / 换会话 / 组件销毁)。55 条新单测(含 MediaRepositoryTest 的线级用例:真 OkHttp + 真 JDK HttpServer,断言 Authorization: Bearer 与 404/416 行为)。

真机验收(假后端夹具,emulator-5554):

夹具 屏幕
transcribed + 副本 + 2s ▶ 语音 0:02 + 转写正文
empty + 副本 ▶ 语音 0:02 + 正文(非空不补话)
failed + 副本 ▶ 语音 0:02 + 服务端那行提示原样
无副本 「语音 0:02」,没有播放键 ✓
无 durationMs ▶ 语音,不显示时长 ✓

播放出声(logcat 硬证据):NuPlayerDriver created, clientPid(9122) → MediaCodec async mode → onAudioDeviceUpdate deviceIds: 2 → AudioTrackShared: releaseBuffer,无 error;点击后按钮变 || ✓。

⚠️ 一条流程教训(我自己犯的):第一次验收时语音气泡没出现,原因是我只合并推送、忘了重新装包 —— 模拟器上还是 W58 时期的 APK。「合并」不等于「设备上是新包」:验收前先确认装的 APK 是哪次构建的。

6. 环境(收工状态)

  • HEAD = 3d8bc61 = origin/android;worktree / 分支 / 构建锁全清;主仓唯一脏 = devdocs/02(后端在途 + 我的登记与四问)。
  • 模拟器装的是最新包(含 W58~W62);App 已重启并连回真后端;adb reverse:tcp:80 → 10942(真后端)、tcp:10942 → 10942。
  • 假后端 18080 进程仍在跑(带语音夹具;在后台任务里,到点会被系统收)。