102 KiB
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 属敏感,不读不记),问主人 |
⚠️ 四个坑:
- 不要用 8080。
.env.example的PORT默认值是 8080,但实际配置是 10942,而且 这台机器的 8080 被一个 PHP 应用占着(X-Powered-By: PHP/8.4.1,对/api/v1/*回 HTML 404)。 往 8080 打会得到一个「看起来像后端」的 HTML 404,很容易误判成后端起不来。 - 后端不一定在跑。2026-10-03 两次实测都是 down(无 cyrene 进程 / 10942 无人监听 /
curl 127.0.0.1:10942连接被拒),2026-10-04 主人说「开了」之后确实在线。 用之前先scripts\ctrl.bat status确认,别默认它在跑。 - 另一个会话正在实时开发后端(主人 2026-10-03 说明),且后端仓库开发期间只读、不得修改。
因此上表的端口与
02里记的端点形状都可能随时漂移——联调前重新核对一次Cyrene_v2/Docs/api-client.md的契约版本号,别拿旧版本号当依据。 .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 可读(
- 未知角色按 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,主人:「按你推荐的来」)
- 概览页「按路径」呈现 —— 保持 W4 的实现,不再改:分组键 = 剥掉
/api/v1后的首段;条数 >4 的组默认收起、≤4 的默认展开;只有一条路径的组不出组头;路径等宽字体 + 段级中间省略。 - 新建会话选角色的交互 —— 保持 W5 的实现,不再改:抽屉「+」→ 弹层(点行选中 → 底部「创建」);角色名挂在会话行摘要前(未绑 = 不显示;角色表里查不到该 id = 原样显示 id,不猜、无硬编码映射)。
- 文档 / 「超限走文件」的两个产品决定(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) |
这一轮的两条关键事实(复用价值高)
- 可编辑的只有
user消息,而 user 消息存的是用户原文、从不带标记 —— 我扫过线上主会话 64 条 user 消息,零条含标记;所有<p>/<action>都在 assistant 消息里(assistant 不可编辑)。所以 W24 的「剥标记」在今天是一条防御性口径,对真实数据是恒等变换;hasMarkup的判据因此取的是「text != content」(= 保存真的会替换掉什么),而不是「出现过<」。 after是升序、before是倒序,且after方向的下一条游标是本页最新那条(不是最旧的)。服务端按limit从最旧一端给,所以取最旧当游标等于原地不动。同毫秒并列超过一页时纯createdAt游标推不动(契约 §A5 明写的边界)⇒ 循环里必须有「游标严格前进」+「轮数上限」两道闸,否则无限请求。
待办(按优先级)
- W19 剩下的那一处:删除后抽屉里的「最后消息预览」不刷新(本端只改了消息列表,没触发
GET /sessions)。① 编辑口径已由主人拍板并落地(W24)。 遗留 ①:连接所有权✅ 已闭环(W28,7b97440):现在进程内共享一条ChatSocket(AppContainer唯一持有、SessionsViewModel是唯一 owner),连接数从 N+2 降到 1。装机验收见「补记四」。ChatSocket.sendInterrupt零调用方(「切会话时打断」这个出口从未接线,是死 API)。observability_audit帧(后端未做,审计仍是 REST);POST /admin/users仍是501⇒ 铸不出viewer账号,viewer 降级只能单测背书。- 子会话流程(主人已确认搁置)。
本轮未验证的(别当成已验证)
after的设备侧验收:真机上确实打出过真请求 ——GET /sessions/web/messages?limit=50&after=1791121966668→200(说明「重连补洞」那条路真的在跑)。但**「断线超过 50 条」那个洞没造出来**(要造得让后端不可达再灌消息,而后端当时正被另一个会话实时开发,我实测到一次HTTP 000)⇒ 补洞的端到端仍是未验证。逻辑侧由ChatRecoveryTest(循环两道闸)与MessageAfterRepositoryTest(真 OkHttp 断言线上 URI)钉住。404 session_not_found兜底:已装机端到端验通(b5c0b13)。证据:logcatGET /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验过已并入, 留着方便回退)。 - 清理手法留在下面的「构建与环境」一节:这两个动作都是不可逆的,删之前先跑一次 「每个分支是否已并入」的检查。
与本轮配套的两条新纪律(已进上面的教训段,这里再点一次)
- 动代码之前先用像素确认症状;
uiautomator dump只用来取坐标。 - 症状与代码结构矛盾时,先怀疑自己的测量。
还有一条:那个「未响应弹窗」的真相(主人问过两次)
- 现象:模拟器隔一阵弹
Cyrene isn't responding/Process system isn't responding。 - 证据:我在 40 秒内连跑 3 次
uiautomator dump,紧接着一次adb shell input tap48 秒才返回; 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,其中两条直接冲我们:
documents[].reason(可选) —— 只在status:"unreadable"时下发;文案是他们注入模型清单里的同一份字符串(10 条,例如「无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」)。⚠️ 别做整句相等判断(真实字符串比登记里给我看的更完整),要分支就用稳定子串(如「需要 OCR」),默认原样显示。 ⇒ 新待办:客户端接上它(现在我们只能显示通用的「读不到」,W18 那条缺口就是它)。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)。
after正向增量已接(28c3142):掉线记游标 / 重连正向追 / 循环抽成纯函数loadForwardPages(两道闸:游标严格前进 + 20 轮上限)。真机打出过真请求,但「断线超 50 条」的洞没造出来。documents[].reason已接(W23 /2d382b1):弹层里原样显示他们那句,芯片仍是「读不到」。 ⚠️ 一处我们主动选的口径:unreadable且mime是text/时让reason压过正文渲染 (理由与代价都写进了02的登记,等他们确认)。404 session_not_found兜底已装机验通(28c3142+ 修复b5c0b13)。- W24 编辑弹层只显示纯文本(
1796e53→66c2291合并为1794e53):真有标记才剥;hasMarkup取「text != content」(= 保存真的会替换掉什么),弹层明说会替换掉旁白/动作。- 家务已办完(3 条验收会话 + 18 个 worktree,见上一节)。
- 后端那边与我们有关的两条:
knowledge的修法已经落地(历史里会落一条诚实说明,我们在89776fe3…那条会话里亲眼看到);并行tool_use配对的 P0 在修,客户端不需要适配。.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三个「假开关 / 静默」的真相(这批最值钱的东西)
- 设置页的「她正忙时按发送的行为」是个假开关:
InterruptMode能读能写能显示, 全仓没有任何发送路径读它(连ChatSocket.sendInterrupt都是零调用方的死 API)。 用户切成「打断」,行为与「追加队列」一模一样。⇒ 现已接上:默认档零行为变化, 「打断」档且会话正忙时先发interrupt{scope:"current"}(保留待处理队列)。 判据抽成纯函数interruptScopeForSend(mode, busy),8 条测试钉住两档 × 忙闲穷举。stream_chunk被静默丢掉:契约明写它是非流式回退路径(「未映射/无法确定的事件」), 而onEvent的兜底是else -> Unit⇒ 只要服务端走那条路,整段回复一个字都不显示、且没有任何信号。 现已把它当delta喂进同一个流式解析器。(它与结构化帧互斥,依据是契约那句「回退路径」。)- 分享多图会自相打断:W25 接线后暴露 —— 分享 N 张图 = N 次投递,第 2 张起各发一条
interrupt把前一张刚起的回合取消掉。⇒ 分享路径一律allowInterrupt = false(批量动作自相残杀一定是错的)。两条「查完发现不是 bug」的(别再返工)
- 「删除后抽屉里的最后消息预览不刷新」——不是 bug(W19 留下的待办)。 证据两面:① 服务端实时重算 —— 我用 REST 实测:删掉某会话最后一条 assistant 消息后,
GET /sessions的lastMessage立刻退回前一条 user 消息;② 客户端每次打开抽屉都会refresh(silent = true)(ChatHomeScreen的onOpenDrawer)⇒ 用户看到的是新预览。 唯一剩下的极端场景是「抽屉正开着时别的端删了消息」——那属于「抽屉不订阅message_deleted」, 是另一个(更小的)话题,别和这条混起来。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为唯一 owner7b97440(276b011)FB 可编程假后端测试台( devtools/fake-backend/,纯 Python 标准库、零依赖)27d5680— 02三处登记:回应回执十三 / 置顶「微信文件看不到」P0 / IM 语音消息的新需求691b073ae49853a008580W28 的关键取舍(别退回)
- 共享的 socket 谁都不许 close:
onCleared里的close()全删了(否则切个会话就把全 App 的连接掐断)。 现在全仓connect()/close()各只剩一处,都在SessionsViewModel(它由ChatHomeScreen在进登录态时创建、活在 Activity 级 store 里,存活期正好覆盖「登录→登出→重登」)。_events的replay必须恒为 0:共享后新建的ChatViewModel会立刻订阅同一个 SharedFlow,replay > 0 就会把历史帧重放给它。- 两个已知的固有代价(都在 W28 的 commit 里写明):① 晚于连接建立才创建的 VM 不再收到
history帧 —— 实测无影响,新 VM 的正文由 RESTloadLatest给(设备上验证过:次级会话首次访问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)。它解锁三件真后端做不了的验证:
after补洞:POST /__control/messages?count=120免费灌 120 条,握手只回放 50 条 ⇒ 精确造出那个洞。 (这解释了为什么之前说「验不了」是错的:我当时只想着用真后端灌 51 条 = 51 次模型回合。该用假后端。)- 打断帧:
out/upstream.jsonl记录所有上行帧 —— 这是「W25 那帧到底发没发」的唯一直接证据。已实跑抓到{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}。stream_chunk回退路径:能指定走旧形状,正好用来验 W29 要修的那条分支。
- App 指过来:
adb reverse tcp:18080 tcp:18080+ 登录页服务器地址填http://127.0.0.1:18080(账号随便填)。- ⚠️ 它的事实来源是客户端代码与协作文档,不是真后端 —— 别拿它当后端行为的依据。
本批的一条硬教训:问一句,比自己猜一整天都值
stream_chunk是我昨夜「按增量」接的(把它当MessageDelta追加)。今早问后端,他们回复:delta是整段res.Text(而且生产里已无触发源)。 ⇒ 真到达会追加成重影。我昨夜那版是错的,而且如果没有问,它会以「已修好」的样子留在仓库里。 同类:viewer凭据我一直挂着当红验收项,问了才知全仓没有任何铸造路径,应当直接摘掉。待办(更新)
- W29(在跑):
stream_chunk改成「整段替换 + 有结构化帧则忽略」/ 空壳消息如实说一句 / 分享超长内容走文件 /F6①图片 URL 兜 host。- 等后端:把 IM 入站的文件挂到消息的
documents上(P0,02已置顶);IM 渠道语音消息的入站 ASR。- 用假后端补三条设备验收:
after补洞 / 打断帧 /stream_chunk(等 W29 合完)。- 子会话流程(主人说搁置);图片/工具卡实机验证(后端未配工具)。
- 已结案(别再返工):登录页输入框被 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);装机验过「连第二个会话连接数不涨」7b97440FB 可编程假后端( devtools/fake-backend/,纯 Python 标准库)27d5680W29 stream_chunk改整段语义 / 空壳消息如实说一句 / 分享超长走文件 / 图片 URL 兜 hostac3f63fW30 忙时并行 UI 半边:六态 + 灰态 + (sessionId, turnId)两级分桶,帧侧全部可降级0057205W31 设置栏「她忙时发消息」追加第三档「并行」(忙时上行 chat.parallel=true,绝不发 interrupt)4847d74W32 忙时被拒那条横幅调柔和(只那一档,真错误仍红)+ 并行额度落回排队如实说一句 79fcd80W33 parallel_limit提示加「本端确在并行档」的闸 + 查证横幅(结论:代码无缺)1f6c16cFB2 假后端加 --hold-replies(收到消息不自动落库)⇒「待发气泡还挂着」可稳定复现c3cfef7装机验过的(有设备证据)
after正向增量补洞:重连后她自己发出messages?limit=50&after=…(这是之前两天判定「验不了」的那条 —— 错在我想窄了,该用假后端)- 打断帧:上行帧原文
{"type":"interrupt","data":{"sessionId":"web","scope":"current"}},先于chat帧stream_chunk:假后端发一帧整段 → 渲染CHUNKONE一次,无重影- W28 连接数、错误横幅关闭叉、登录页 IME(不成立)
⚠️ 思考流(主人 2026-10-05 报的)—— 根因在后端/模型侧,已查到一半
- 证据:真后端最近 5 条 assistant 消息全都没有
thinking键(GET /messages是 owner 可见的唯一历史思维链来源)⇒ 后端手里根本没有思考内容,不是客户端不显示。- 配置(
Cyrene_v2/data/models.json):provider: "anthropic"、baseUrl: https://api.deepseek.com/anthropic、model: deepseek-flash、没有reasoning键。reasoning键加不了(别再试):models.go:180-191的validateReasoning明确规定 —— anthropic 组上除auto/空之外任何 kind 都非法,写了会让后端下次启动校验失败。- 原因在代码里写着:
anthropic.go:195-201「anthropic 协议自带思考形状,因此这里声明 openai 形状是无效的」⇒reasoning.kind管的是 OpenAI 兼容那套(thinking:{type}+reasoning_effort),anthropic 适配器不走这条路。- 所以开关在别处:在 anthropic 适配器自己的请求构造里(协议原生的
thinking块)。 ⚠️ 待查(我读到anthropic.go:195就停了):它到底发不发thinking、什么条件下发; 以及 DeepSeek 的 anthropic 兼容端点支不支持 anthropic 的thinking形状。 ⇒ 最省的办法:直接问后端会话(他们写的,一句话就够)。待办(按优先级)
- 用假后端把「忙时被拒」那条横幅端到端验一次(上次没验成:当时待发气泡是 0 条 ⇒ 落
Ignore⇒ 刻意什么都不写,那是设计不是 bug)。 做法已在devtools/fake-backend/README.md第 6 节(--hold-replies→ 发 chat 挂住 →persist补落库 → 注入pending_full),逐条实跑过。- 思考流那条的后续(见上):问后端 anthropic 适配器的 thinking 下发条件。
- 等后端 v0.65:
chat.parallel/turn_begin/turn_id的精确位置登记后接帧(我们形状已备好,全可降级)。- 旧的:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。
- 作废的说法(别再引用):曾记「假后端
--stream-chunk建模与契约不符」——错,它的chunks=1就是契约的「一帧整段」。环境与纪律(这次踩到的)
- 模拟器今天很脆:连 launcher 都 ANR 过,App 进程被系统重建。新会话开工前先重启模拟器。
- 假后端进程还占着 18080 / 18081 / 18083(
taskkill对它们不太好使,重启机器最干净)。- 我这次又犯了「先怀疑代码、而不是先怀疑自己的测量」:截图上没看到横幅时,我先去查代码,其实当时只是待发气泡为 0(设计如此)。这条纪律写在上一节,要一直记着。
⚠️ 纪律补记(2026-10-05 上午 · 主人问「为什么项目文件夹里那么多本项目的文件夹」)
那些是 git worktree —— 给并行子代理各开一个独立工作区用(避免两个代理同时改同一棵树)。 问题不是它们存在,而是我合并完忘了清:这一批(W28–W33 + FB/FB2)8 个全都残留下来了, 主人先在文件管理器里发现的。上一次(W4–W24)我做过一轮「家务清理」,之后就再没做。
⇒ 从今往后写成硬规则:
- 每合并一个 worktree,就
git worktree remove/rm -rf它(别攒着)——同一次操作里做完, 不要留到「以后一起清」。- 清理用 Git Bash 的
rm -rf(Windows 直接用 git 自带的删会撞MAX_PATH),然后git worktree prune。- 删之前先验:
git merge-base --is-ancestor <branch> android—— 只有已并入的才删。- 分支引用保留(方便回退),只删目录。
- 收尾自检:
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 错误。- 本批只动文档,没有代码改动。
本批发生的事
- 拆分过长文档(W34,两轮):
022143 → 472 行主 + 1681 归档;051035 → 624 行主 + 418 归档。 零丢失有两层证明:代表性字符串grep -c前后相等 + 逐行多重集diff(差异只有新增说明行)。 第二轮按主人要求把A~E/G/H那批已闭环的登记(约 430 行)也归档,并修了状态表那处会断的指针。- 撞上一次协作竞态(值得记):后端会话在我拆的期间往
02追加了内容(未提交)⇒ mergeAborting。 处理:先把他们的补丁git diff > /tmp,git checkout --清工作区,合并拆分,再把他们的内容原样追加回新主文件末尾。 ⇒ 两边都没丢。教训:动02之前先git status。<p>规整已落地并上线(后端5de708a):照我们的裁定走 —— 写入侧收口、幂等守门断言、 覆盖 Web 与 IM 两条落库路径(渲染口径不分叉)。我方零改动。✅ 已拍板(主人 2026-10-05):历史回填 做
决定:做(主人「按最优解」)——三条约束见
02-前后端协同事项.md末尾「历史回填」一节: ① 先 dry-run 只统计不改(量大异常先对齐)② 留回滚点 ③ 一次性、不留常驻逻辑。 范围严格限定为「只补<p>」,不碰 role/时间戳/documents/thinking/记忆派生数据。原问题描述(保留备查):历史消息要不要一并回填重写? 后端的原话:本次是写入侧收口 ⇒ 已落库的旧「半包」消息不会自动重写(按既有解析规则仍会塌)。 要重写就是一次性数据回填(涉及
data/**,需主人点头)。不做的话:新消息从此正常、老消息照旧塌。待办(按优先级)
- 真机验证「并行」两路:后端已确认
turn_begin/steer_ack.turnId/interrupt.turnId/chat_messages.turn_id都已进 HEAD 并已上线(契约 v0.66 待升)⇒ 「等登记」这个阻塞已解除。我方形状早备好且全部可降级 (W30),入口也在(W31:设置页「她忙时发消息」第三档)。装机步骤:先重启模拟器(见下)→ 装 APK → 设置切「并行」→ 在她正忙时发一条 → 看是否真开第二路 + 灰态 + 两级分桶。- 用假后端验「忙时被拒」横幅(上次没验成的那笔):
devtools/fake-backend/README.md第 6 节有逐条跑过的命令序列 (--hold-replies→ 发 chat 挂住 →persist补落库 → 注入pending_full)。关键:必须让待发气泡 ≥1 条 (0 条会落Ignore,刻意什么都不显示,那是设计不是 bug——我上次就栽在这)。- 等后端补「思考开关在哪开」:已确认端点支持(
anthropic_reasoning.go:76的anthropicDeepSeekReasoning, 走output_config.effort,thinking.budget_tokens会被忽略并 WARN),且开关是请求级的、来自角色/组的思考设置 ⇒ 不是改models.json。⚠️ 绝对不要再试reasoning.kind:validateReasoning(models.go:180-191) 在 anthropic 组上会拒绝它,加了会让后端下次启动校验失败。- 旧的:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。
环境与纪律
- 模拟器今天极不稳(连 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(分享来源角标)、W3639a63f8(并行路角标改以kind为准)。- ⚠️ 工作区有
02的在途改动 —— 那是后端会话的追加(回执十八等),不是我们的,别提交它。 处理手法见本文末。✅ ①「并行」两路:实机验通(这次是真证据,不是看角标猜的)
三条互相独立的判据:
- 后端日志(
%USERPROFILE%\cy.log):msg="parallel leg started" session=web turn=turn-4743035881e410af leg=2 active_turns=2—— 这行只在并行启动路径打(session_parallel.go:175),active_turns=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 真重叠。- 客户端画面:底栏显示「正在思考… 并行 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 最贵;现在让它存快照,下次热启动。⚠️ 新踩的两个坑
settings put global hide_error_dialogs 1会让 ANR 直接杀掉 App(日志reason=6 (ANR)+Killing … user request after error)。 它对自动化友好,但代价是丢状态 —— 配合「不在负载高峰驱动 UI」一起用。- 别一边跑 Gradle 一边驱动模拟器 UI(今天就这么把 App 饿到 ANR)。 判断「是不是 App 的 bug」的正确做法:拉 ANR trace 看主线程栈 —— 在
/data/system/procexitstore/,adb root后可 pull。 那次主线程是__epoll_pwait(空闲),且 launcher 同时段以同样理由 ANR ⇒ 模拟器被饿死,不是 App 的问题。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 刚在附近重连过一次。未定位,先记着。待办(按优先级)
- 分享路径剩下三条实机验证(超长正文走文件 4000 rune /
SEND_MULTIPLE多图 / 冷启动)—— 命令见 README 与子代理报告。- W36 的真机目视:单测钉住了,但「第二条普通消息不再冒角标」没在设备上目视过。
- 旧账:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。
环境与纪律(更新)
- 假后端用完即关(本次已关,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 修了什么(都是今天实机暴露的)
- 字符串形态的
EXTRA_STREAM现在认了(String[]/ArrayList<String>,历史遗留与跨壳应用常见),只接受content://(file:///http(s)://一律拒,理由写在stringStreamUri的 KDoc);非法项逐条跳过,不整体失败。- 消灭静默失败:
EXTRA_STREAM/ClipData里确实有内容迹象却一条都读不进来 ⇒ 给一条柔和告知「这次分享的内容没能读进来,没有发送任何内容…」(刻意不写「发送失败」)。 边界:EXTRA_TEXT与EXTRA_STREAM都为空 ⇒ 仍然静默(两条判据同源)。⚠️ 今天新踩的三条环境坑(比代码更耽误时间)
- 模拟器会自己黑屏,而
svc power stayon true不一定挡得住。后果极坏:黑屏下的input tap全部落空 ⇒ 我会误判成「点了没反应 / 长按不生效 / 焦点乱跑」。开测前先input keyevent 224+82唤醒并截图确认屏幕是亮的,另加settings put system screen_off_timeout 1800000。adb root会重启 adbd,把adb reverse全部冲掉。之后 App 就「无法连接到服务器」。adb root用完必须补回adb reverse tcp:10942 tcp:10942。- 模拟器的
system_server会崩(症状:Can't find service: window、am报Failure calling service activity: Broken pipe、负载飙到 11)。此时除了重启模拟器没有别的办法;别把这类现象当 App 的问题。📌 主人问的「开了并行之后连串行消息都成了并行处理?」—— 结论:不会,今天的数据里也没有发生过
三条证据:
- 服务端有硬门:
session_orchestrator.go:300的if st.mode != ModeIdle—— 会话空闲时根本不进并行分支,无论客户端声明什么都不可能开第二路(空闲只有一条串行路)。- 客户端只在忙时声明:
parallelForSend(mode, busy) = busy && mode.wireParallel(InterruptScope.kt:65),空闲时三档逐字节相同。- 今天全部日志(含轮转备份)里
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 明写不是发送时限。- 新增
ShareLandingTest7 例(穷举WsState.entries× 两个标志);未动普通发送路径(输入框/图片/附件)、未动PendingShareHolder.take()的原子语义。✅ 真机回归已做(2026-10-05 夜,设备:模拟器 Pixel_10_Pro_XL / 包
f15f0bf构建):
- 断线时分享 ⇒ 不再报「发送失败」:
adb kill-server真断线(reverse 清空)后投分享 —— 顶栏「重连中…」, 横幅是**「还没连上,分享的内容会留着,连上后自动发出」**(柔和),没有 error 横幅;库里查不到那条消息 ⇒ 内容没丢、也没假装发出去。- 连接恢复 ⇒ 自动补发,且只发一次:
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)。别拿「移除转发」当断线。待办(更新)
- W38 的三条真机回归(冷启动分享 / 断网分享后恢复 / 横幅不是 error)—— 真机或下次环境稳定时做。
- 文案瑕疵(
failed* > 0时不再追加「内容是空的」)—— 小改动,与下面两条同区,建议合成一批。- W38 子代理提的三条后续(未做,待主人定):①
deliver在 WS 未就绪时对输入栏那条路仍一律rollback(…, "发送失败,请重试")(「未就绪」与「发失败」仍是两件事);②WsState.Offline(不可恢复)目前也走「等 + 连上后自动发出」,会一直等一句永不到来的「连上」,可改成「需要重新登录 / 检查服务器地址」;③ 待发内容只在进程内存里,一直连不上且进程被杀就丢(既有语义)。02的 §四/回执十九 仍以「修法方向」口吻描述这处缺陷 —— 下次动02时补一句「已修aeb3745」(本次没动它,避免与后端会话的在途追加打架)。- 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、相册多选→分享(图片路径仍需真机)。
★★ 锚点补记十(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)。属独立改动。待办(更新)
- 主人定案项:普通发送在「未连接」时该不该也自动排队重发(现在是留在输入栏 + 诚实文案)。
- 未纳入新判据的一处字面量:
ChatViewModel.kt:1552(REST 回执被拒那条路)仍是rejected.message.ifBlank { "发送失败,请重试" },刻意未动。Offline立刻告知(不等 3 秒)。- W38/W39 的真机回归:W38 已验(补记九);W39 这三条只在真机上扫一眼即可(触发条件都少见)。
- 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、相册多选→分享(图片路径仍需真机;App 一个权限都没声明 ⇒ adb 造不出可读 URI,这条只能走真机或驱动相册 UI)。
★★ 锚点补记十一(2026-10-05 · W40:普通发送「未连接 ⇒ 进队列」,不再回滚)
状态
- worktree 分支
feat/W40-send-queue-offline(基线e60ae7a),分支提交df9fc85;未合并、未推送(主树与origin/android都没动)。- 本批证据:
assembleDebug成功;testDebugUnitTest1118 测试 / 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 授权可能已失效)。影响面仅这一条通道。
待办(更新)
- W40 的真机回归:断网发普通消息 ⇒ 应看到「排队中 · 还没连上」+ 柔和告知(没有红色横幅、输入栏不退回)⇒ 恢复连接 ⇒ 自动补发且只落库一条。
- REST 通道(长文 / 图片 / 附件)未连接时要不要也排队——需主人定案(代价见 ③)。
Offline立刻告知(不等 3 秒)——W39 遗留,未动。- 未纳入新判据的一处字面量:REST 回执被拒那条路仍是
rejected.message.ifBlank { "发送失败,请重试" },刻意未动。- 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、相册多选→分享(只能走真机或驱动相册 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;CPUVirtualizationFirmwareEnabled=True。 ⇒ 慢不是「没吃到加速」,别往那个方向修。本轮改了什么(
Pixel_10_Pro_XL.avd/config.ini)
项 改前 改后 理由 hw.lcd.width/height/density1344×2992 @480 1080×2340 @440 像素少 ~37%,Compose 每帧重绘直接变轻 hw.cpu.ncore6 5 宿主只有 6 个物理核;给满会与宿主机互相抢(WHPX 下超额分配会互相等) skin.name/skin.path/skin.dynamic指向 pixel_10_pro_xl皮肤整三行删除 皮肤会把屏幕尺寸钉死, hw.lcd.*完全不生效(实测wm size仍是 1344×2992);删掉后wm size才是 1080×2340showDeviceFrameyes no 无皮肤时没必要画外框 备份留在 app/build/e2e-shots/config.ini.bak(改坏了可原样还回去)。hw.ramSize=8192/vm.heapSize=512沿用上一轮(那一轮把 iowait 196%→14%、swap 457MB→0,是本项目至今最大的一次性能收益)。❌
-no-window不要用(这条与直觉相反,实测证据)无窗口启动时模拟器把渲染降级成软件 GL:日志
emuglConfig_init: vulkan_mode_selected:lapvaipe gles_mode_selected:swiftshader⇒ 省了宿主窗口开销,却丢了宿主 GPU(原先是有窗口时的vulkan:host / gles:host,用的是 AMD RX 6600 XT)。 对以 Compose 重绘为主的负载,这是倒退。⇒ 保留窗口跑。结论:真机做验证、模拟器做冒烟与看日志
全系统模拟本来就比真机慢 5
10 倍,宿主是 6 核 Zen3,再压也压不出真机的手感。其余可做的:测试期间别同时跑 Gradle / 别开 Docker 与浏览器(今天就有一次构建把 App 饿到 ANR);保持快照启动(已开)。 「换别的虚拟机」这条路对这个项目基本走不通:WSA 微软 2025 已停服;MuMu/LDPlayer/BlueStacks 多是 Android 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恢复。
阶段 结果 断线时 顶栏「重连中…」;柔和横幅 「还没连上,这条排队等着,连上后自动发出」;没有红横幅、输入栏没有退回;库里 q10 条恢复后(等 ~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)
- WS 已发出 ⇒
Ws(连接状态与探测都不再参与;成功路径零改动)WsState.Offline⇒Queue,连探测都不做。理由:该档语义是「握手 401 且续期也失败」,会话已知是死的;而/api/v1/health不鉴权(routes.go:51、rest.go:219-221只回{"status":"ok"})⇒ 探它必然 200 ⇒ 会发一次注定 401 的 REST,把「已排队不会丢」换成一次回滚 + 一句误导人的鉴权错误。- 未就绪(Idle/Connecting/Reconnecting)+ 探测通 ⇒
Rest:回落POST /api/v1/chat把消息当场发出去,不进队列。- 其余一切(不通 / 超时 / 抛异常 / 还没探过)⇒
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 的「同步无挂起点」改成单飞协程 + 串行出队(阶梯要挂起,必然)。待办(本节的)
- W41 的真机回归:用假后端
--hold-replies+ 断 WS 但留着 HTTP,看是否真走 REST 出去、重连后回复是否补齐。- 「任何 REST 失败都进队列」若要做,先设计图片/附件的安全重放。
gapCursor == null且本端为空那条残留洞:可加「重连时loadLatest一次」兜底。- 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)(逐字沿用既有那句),分享路径与图片入口共用。⚠️ 两处要主人过目(子代理主动交代的)
- N≥2 时「她忙时发消息」偏好只套用在第一条(
applySendPreference && index == 0)。理由:第 2 条起声明打断=打断自己刚起的上一轮。 替代方案是像分享路径那样整批不声明(改一行)。N=1 时与今天逐字节相同,所以这是个只影响新形态的选择。- 文案措辞:那句超限告知原文以「分享的图片」开头,现在也会出现在图片入口(那里并没有"分享")。任务书要求逐字沿用,所以照做;要改成入口中性的措辞属新文案,需主人拍板。
待办(本节的)
- W42 真机:多选器交互、输入栏那排缩略图观感、N 张落成 N 条的落库形态(模拟器或真机都行)。
- 上一条那两个决定等主人一句话。
★★ 锚点补记十五(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 条的黑屏坑是同一类)。