Files
Cyrene-For-Android/devdocs/05-开发进度与交接.md
T
AskaEth cb5fc0c605 docs(devdocs): 拆分 02/05 两份过长协作文档,历史部分归档,内容零丢失
02-前后端协同事项.md:2126 行 → 860 行
- 历史回执(回执一~十六及同时段已闭环的登记)整体搬到新建 devdocs/02-archive-历史回执.md(1274 行)
- 主文件保留:标题与用途、顶部「待后端处理的 P0」置顶块、登记规范、A~I 全部登记条目、附:状态一览表、仍未闭环的登记(2026-10-04 深夜续那条待主人拍板的口径冲突)、最近 5 条往来
- 置顶块之后新增 5 行归档说明:历史回执去向 / 新回执照旧写在本文末尾 / 已闭环的老登记会定期挪进归档 / 本文旧指针去归档里找对应小节
- 不动置顶块、不动状态一览表、不动未闭环的登记,后端会话仍可照旧在本文末尾追加回执

05-开发进度与交接.md:1035 行 → 601 行
- 历史批次细节与已过时的验收现场记录搬到新建 devdocs/05-archive-历史批次.md(437 行):本轮追加(W4–W7)、W8–W10、W11–W12、W13–W14、W15–W16、装机验收轮、分享到 App 验收轮、W27
- 主文件保留全部「★ 恢复锚点」性质的节(当前状态与待办、锚点补记一~五、纪律补记)与当下要用的节(当前状态、构建、验证方式、凭据处理红线、环境要点、后端、模拟器、易忘的实现约定、管理区约定、C-2 验证结论、坑与勘误、后端协同、实机验收方法、三条待拍板项定案)

零丢失的证据
- 全部搬运用 sed 按行区间原样切割,未改写任何既有文字;新增内容只有 02 主文件那 5 行归档说明与两份归档文件开头的 2 行说明
- 逐行多重集比对(awk 计数 + diff):除上述新增行与个别空行外,原文件与新「主文件+归档」的行集合完全一致,无增无删
- 8 个代表字符串的 grep -c 拆分前后相等:679cee0 3=3、18381f6 1=1、guardrail_input 3=3、internal/config/runtimeconfig/models.go:300-311 1=1、回执十七标题 1=1、devtools/fake-backend/ 4=4、W15-retry-2-EDITED 3=3、ChatImageBudget.SERVER_MAX_CHARS 1=1
- 行尾统一为 CRLF(与 devdocs 下其余文件一致)

未跑 Gradle(本次纯文档搬运),未 push。
2026-10-05 11:10:03 +08:00

53 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)

后端协同

见 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 深夜)

压缩/换会话后先读这一节,它是对「此刻到底在哪」的一句话答案;细节在上面各批次里。

状态

  • 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 对它们不太好使,重启机器最干净。以后用完就该关。