Files
dsh_ai1net_server/docs/交接单/回执_棒3_AndroidMVP完成_平台线收_20260928.md
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

23 KiB
Raw Permalink Blame History

回报 · 手机接入线|棒 3(Android MVP · 按 sessions 实现)· 2026-09-28

本轮:执行外来派活件 docs/交接单/派活_棒3_Android按sessions实现_20260928.md(平台/宿主线 21:1x 投递)。 性质:实现棒。⛔ 未扩大范围(不动平台、不动 dsh-server、不动 WorkBuddy 配置、不重启客户端)。 一句话:MVP 五件事全部实现并在「真机(模拟器)+ 真 WebView」上跑通 7/7 判据;两条勘误已照做, 并额外勘误一处(派活件留的待核实项:尾随通道不是 replay);同时发现一条真网关侧的准入事实(§5)。


0 执行锁与边界自证

项 读数
抢锁 --claim-exec "session-android-b3" --domains E:/github/dsh-client E:/ProgramData/AIProject/ai1net-dsh-anywhere ⇒ ✓ 已持域锁(域键 github/dsh-client · aiproject/ai1net-dsh-anywhere)。
⚠️ 后来为投递回执,并入第三个域 ai1net-dsh-server/(脚本支持合并,⛔ 不是重建锁)⇒ 释放时一并放掉。
收尾 --release-exec "session-android-b3" ⇒ ✓ 已释放域锁;复核 .locks/ 空目录(无残留)、.exec-lock 不存在(无全局锁)。
越界动作 0 处。⛔ 未动 ai1net-dsh-server、aliyun-dsh-server、ai1net-dsh-desktop;⛔ 未改 WorkBuddy 配置、⛔ 未重启客户端
真网关 只做只读订阅(ACP 长连观察)。⛔ 未发 prompt、⛔ 未发 reply ⇒ 用户桌面那份会话的内容一个字符都没被本轮的自动化改动

1 ① 接口 = sessions 一套(已确认,且有出入)

1.1 勘误一照做

原单 §3.1 主推的 jobs 一套未使用;MVP 五件事全部落在 sessions: GET /sessions?cwd=* · GET /sessions/live · GET /sessions/{id}/history · POST /sessions/{id}/reply。 (原单 §3.1 的 jobs 表在本轮代码里零出现,已 grep 自证。)

1.2 契约字段级核实(本轮重跑,⛔ 非引用投递方原文)

契约来源 = WorkBuddy 自带 gateway 的 OpenAPI 定义,从 CLI bundle 实读抽出(可复跑脚本见 §7): …/WorkBuddy/resources/app.asar.unpacked/cli/dist/codebuddy-lite-wb.mjs → 共 143 条 /api/v1 路由,其中 sessions 族 10 条。

关键:SessionList 的 schema 逐字段如下(原文抽出,非推测)——

SessionList: { type:"object",
  properties:{ sessions:{ type:"array", items:{ type:"object", properties:{
      id, name, createdAt, updatedAt, messageCount, isCurrent,
      projectId, cwd, status, isPlayground, isUserDefinedTitle },
    required:["id","name","createdAt","updatedAt","messageCount","isCurrent"] } } },
  required:["sessions"] }

⇒ 三点被这条 schema 纠正/确认(都影响客户端解析,故逐条列出):

# 事实 影响
a 数组键 = sessions(不是 items/list) 客户端按 data.sessions 解析 ✅
b createdAt/updatedAt = epoch 毫秒整数(⛔ 不是 ISO 字符串) 时间列须先 new Date(n);本轮原先按字符串截取 ⇒ 已修
c 有 isCurrent 布尔字段(契约内) 「桌面当前」不必只靠 /sessions/live;两条独立来源互相验真 ⇒ 已用
d 外层统一 { data: … } 包一层(wrap(...)) 客户端 unwrap() ✅

/sessions/live 的 schema 亦逐字核实:{ sessionId: string\|null, writerOccupied: boolean }(required 两者都在)。

1.3 🔴 本轮额外勘误:尾随通道不是 replay(派活件 §1 留的待核实项)

派活件留了一条待核实:「实时尾随该用 /sessions/{id}/replay 还是另找 SSE 端点」。 已探明,结论是「都不用 replay」,依据分两层:

(一)服务端实现层面(从 bundle 实读 getReplay 处理器原文):

async getReplay(ei,ea,es){ … (0,o_.Vb)(ec,{ sessionId: eh.id, cwd: this.extractCwd(eh),
                           events: ef, snapshot: (0,og.ow)(eh), replayTiming: eu, … }) }

⇒ 它走 Vb(res, {...}) 一次性回包,没有 setSkipAutoEnd、没有 SSE 头、没有长驻 write —— 与同文件里真正的 SSE 处理器(如 streamOutput:response.setHeader("Content-Type","text/event-stream") + setSkipAutoEnd(!0) + setInterval keepalive)形态完全不同。 ⇒ /replay 是一次性 JSON 快照,用它做「实时」只能轮询 ⇒ 与硬约束 ① 直接冲突。

(二)实跑层面(回环实测,端口由发现脚本给出):

node acp-sse-probe.mjs --port <网关端口> … --seconds 16
# ⇒ GET /api/v1/acp -> status=200 content-type=text/event-stream transfer=chunked
#   长连建立后 16s:帧=548 字节=422066 服务端主动关闭=false
#   帧类型: keepalive=1 · tool_call=3 · tool_call_update=3 · agent_thought_chunk=485 · agent_message_chunk=57

⇒ 正解 = ACP 一族(/api/v1/acp),形态如下(均为实读 + 实测):

步 方法/路径 头 回什么
① POST /api/v1/acp/connect — { connectionId, sessionToken }
② POST /api/v1/acp acp-connection-id JSON-RPC initialize → result.agentCapabilities…
③ GET /api/v1/acp acp-connection-id text/event-stream,长驻;收 session/update 广播
④ POST /api/v1/acp acp-connection-id session/cancel(通知:无 id、无回包要求)= 中断
— POST /api/v1/acp acp-connection-id session/load 只回放:params 必须带 mcpServers:[](缺则 -32602),回完即结束流 ⇒ ⛔ 不能当尾随

⇒ MVP ③ 的实现 = ③ 那条长连;session/load 只用于一次性回放(本轮未依赖它做尾随)。


2 ② MVP 五件事 = 5/5 实现 · 端上 7/7 判据全绿

⚠️ 取证口径先说清(避免"读数是真的但不是你以为的那台"): 端上取证跑在契约仿真网关上(tmp/b3-android-20260928/mock-gateway.mjs,字段严格照 §1.2 实读的 schema)。 为什么必须用 mock:真网关的 REST 即使来自回环也要求凭据(实测 401),而令牌只有用户本人敲 /gateway token 才拿得到(派活件 §2-③ 明文「模型不能代敲」)⇒ 真令牌到位前无法端上跑 REST。 派活件 §3 已明确允许「你可以先用 mock 打通 UI」。

# 判据 读数(端上真跑)
① 会话列表 ✅ 读 3 条 · 按 cwd 分 1 组 · 「桌面当前」标由 isCurrent + /sessions/live 双源命中
② 历史 ✅ 渲染 6 条气泡(requests = 3 轮 × userInput/finalReply)
③ 实时尾随 ✅ 气泡 6 → 11;首帧渲染 0.5–0.8 ms;帧间隔 233 ms ×3(= 连续推送,非攒批);长连状态「实时(长连)」
④ 发一句被采纳 ✅ 回执 已投递 delivered=true(reply 返回 {data:{delivered:true}},与契约一致)
⑤ 中断 / 重连 ✅ 中断:末帧出现 本轮结束(stopReason=cancelled);重连:触发 offline/online 后状态回到「实时(长连)」

派活件 §5「加一条」也做了(用 sessions?cwd=* 与 /sessions/{id}/history 复跑 §7-1/§7-2): ① ③ 两条判据本身就是用这两个端点跑出来的(列表走 sessions?cwd=*、历史走 sessions/{id}/history), 且读到的就是桌面那份——会话名与「桌面当前」标记对得上(见 §6 截图),⛔ 不是 gateway 自起的。

2.1 开发过程中被端上取证实测揪出的三个真 bug(都已修)

# 现象 根因(实测钉死) 修法
1 长连「建着但一条推送都收不到」 我最初把清理挂在 req.on('close');GET 无 body ⇒ 该事件在请求体读完时立刻触发,把刚建的订阅置空(服务端日志里那条 GET 明明是 200) 改挂 res.on('close')(连接真断才触发)
2 端上 fetch 长连永不 resolve SSE 只 writeHead 不写 body ⇒ Chromium 当"响应还没开始"。服务端日志有 200,端上却卡住——典型静默错误 头一行先写 SSE 注释帧 : ready\n\n 做 flush
3 「距上帧」算出 −1.79e12 ms 一处在 Date.now()、一处在 performance.now() 上取时间,混用 全程统一 performance.now();并让思考/工具/回答分桶成不同气泡(否则三股流灌成一坨)

这三条都不是"环境问题",是只有端上真跑才暴露的:桌面 curl 全都正常。已写进交付代码注释。


3 ③ 实时延迟 = 见下(分两段给,⛔ 不含糊)

口径 读数 端点
端到端 ≤2s 判据 ⚠️ 无法由客户端单独证明,如实说明 —
长连建立耗时 21 ms(进程起 → GET /api/v1/acp 200) GET /api/v1/acp
长连首帧延迟 建连 21ms → 首帧;空闲期只有 keepalive 同上
真网关吞吐 16 s 内 548 帧 / 422 KB,服务端不主动断开 同上
端上渲染耗时 0.5–0.8 ms / 帧 端上 WebView
端上帧间隔 233 ms(连续推送;逐帧到、非攒批) 端上 WebView

🔴 为什么"≤2 秒"我给不了硬读数:那要发端(桌面 agent 产生输出的那一刻)打时间戳,客户端才可能算出 "从产生到显示"的差。客户端能测量到的只有"帧到达 → 渲染完成"这一段,以及"帧与帧之间是否连续"。 把本地渲染耗时(0.5ms)冒充端到端延迟,是假绿,本轮不做。 ⇒ 若投递方要真端到端数,需在发端埋点;判据请写成「发端打点 → 端上收帧」的差,客户端这边已具备可比对的时间基准。


4 ④ 限流读数 = 连续 10 分钟无 429,且结构上不可能打满

结论:静置 10 分钟,网关侧新增请求数 0(600 秒 / 0 条),0 次 429(读数原文见 §8.1)。

结构性依据(比"没观察到"更硬):会话页静置时,客户端没有任何周期性请求——

检查 读数
全文 setInterval 0 次
requestAnimationFrame 0 次
setTimeout 2 处,均一次性:① toast 自动消失(用户触发)② 长连断线退避重连
长连接续期 靠服务端 keepalive 推帧,客户端不主动重发

🔴 本轮自己纠了一处"会偷偷变成轮询"的设计:断线退避若不封顶(1s→2s→4s→8s→15s 封顶后无限重试), 长连一旦永久失败就会每 15 秒重试一次=4 次/分钟,那就是事实上的轮询,会踩硬约束 ①。 ⇒ 已改为 退避最多 5 次,之后停手并把状态置成「长连已停 · 点「重连」」,⛔ 不再有后台周期请求。

顺带校正一处口径(投递方与工单的描述容易被读成"所有请求都限流"):从 bundle 实读, recordFailedAttempt() 只在鉴权失败时被调用(checkAuth 的 401/403 分支、login 失败分支)—— ⇒ 2 次/分钟 + 12 次/小时 限的是失败尝试(防爆破),不是"请求总数"。 这不改变"不许轮询"的结论(那是明确的硬约束),但改变"为什么":轮询的风险主要是噪音与被判定为异常流量, 不是必然撞限流。故本轮仍按最严口径做到零周期请求。

另:凭证的正确姿势按工单 §5-1=「令牌只换一次 Cookie,之后复用」。端上实测的准确形态是—— ?password= 换 Cookie 会成功,但 WebView 的 origin 是 https://localhost,与网关跨源, SameSite 让后续请求带不上那枚 Cookie ⇒ 客户端按 401 自动降级到 x-access-token 头 (同一份令牌、仍不带 query、语义等价、不产生失败尝试)。这条降级是实测驱动的,不是猜的。


5 🔴 真网关侧新事实:跨源请求按 origin 白名单 放行(本条属投递方/宿主侧)

端上探测真网关时实测到一个机器可验的准入事实,逐条给出(这解释了我为什么会看到"端上连不上真网关"):

请求 结果
GET /(带 Origin: https://localhost) 200 + Access-Control-Allow-Origin: *
GET /api/v1/health(不带 Origin) 401 {"error":{"code":"AUTH_REQUIRED"…}}
GET /api/v1/health(带 Origin: https://localhost) 🔴 403 + 无 CORS 头
GET /api/v1/health(带 Origin: http://localhost) 🔴 403
GET /api/v1/health(带 Origin: http://127.0.0.1:<网关端口>,即自身) 401(放行到鉴权层)
POST /api/v1/acp/connect(带 Origin: https://localhost) 🔴 403

403 的响应体把修法直接写出来了(原文):

{"error":"Origin not allowed",
 "hint":"Add this origin to CODEBUDDY_CODE_CORS_ORIGINS or settings gateway.corsOrigins"}

5.1 影响面(说清"谁受影响、谁不受")

  • ✅ 正式路径不受影响:设备入口 https://<门户域名>/u/<uid>/desk/<hostId>/… 是同源(页面与接口同域), 不经跨源判定 ⇒ ⛔ 无需给正式路径加白名单。
  • ⚠️ 只影响:把 App 的 WebView(origin = https://localhost)跨源指向电脑上的网关做本地联调。 本轮的 MVP 端上取证因此走"契约仿真网关"(同源/自设 CORS),真网关只做了主机侧只读长连取证。
  • 🔧 若要打通"端上 × 真网关"联调,需在网关侧加白名单(CODEBUDDY_CODE_CORS_ORIGINS 或 settings gateway.corsOrigins 加 https://localhost)。 本轮⛔ 没做 —— 那要改 WorkBuddy 配置并重启客户端,属宿主侧动作,超出本棒范围(且上一棒明确列过"不改 WorkBuddy 配置、不重启客户端")。

附带修正一条我自己先前的误判,留档以免误导:我一度判断"https://localhost 去 fetch http://127.0.0.1 会被 混合内容拦",并据此加了个"明文回归开关"。独立复核否定了该判断 —— 回环属 potentially trustworthy, Chromium 豁免混合内容;当时 Failed to fetch 的真因是我 mock 缺 CORS 头。 ⇒ 已撤回那个开关,allowMixedContent 保持 false(配置里留了实测记录)。这是本轮第二处"先怀疑后核清"。


6 ⑤ 真机 = 模拟器(无真手机)

项 读数
机型 sdk_gphone64_x86_64(AVD dsh_root,google_apis 镜像,可 adb root)
Android 16(ro.build.version.release=16,SDK 36,x86_64)
APK 4,158,555 B(app-debug.apk)· com.dsh.client · minSdk 24 / target 36
APK 内容自证 assets/public/index.html 34283 B · md5 620c00428653530fcc93013ec3390d75 · 与源 www/index.html md5 逐字节相同
装机 adb install -r → Success(迭代式重装多次,末次为终版;末次 APK 的 assets/public/index.html 与源 md5 相同 ⇒ 端上跑的确实是终版代码)
端上环境细节 WebView origin = https://localhost(Capacitor androidScheme 默认 https);adb reverse tcp:18080 把手机回环转到电脑回环

⚠️ 不是真手机:另一台 a11b24b6 处于 unauthorized(USB 调试未授权,需用户在本机点"允许"), 故本轮用可 root 的模拟器 dsh_root 完成全部端上取证。真机腿仍未验(§7 末条)。

端上截图(真实 WebView 抓屏,非桌面浏览器模拟): tmp/b3-android-20260928/shots/ → A-会话列表.png · B-会话实时.png · C-会话实时-完整一轮.png · D-设置与自检.png


7 ⑥ 未完成项(逐条,⛔ 不藏)

# 未完成 卡在哪(具体、可验) 谁来解
1 真机腿(§7-5 完整判据) 手上没有真手机;桌上那台 a11b24b6 是 unauthorized,需用户在设备上点"允许 USB 调试" 用户(一条动作)→ 然后本线可复跑
2 端上 × 真网关 联调 🔴 真网关对 Origin: https://localhost 回 403(§5),需加 origin 白名单;加白名单要改 WorkBuddy 配置 + 重启客户端 投递方/宿主侧(本线⛔ 不越界)
3 真令牌下跑 REST 端上取证 令牌只能用户本人敲 /gateway token(派活件 §2-③);本轮的 REST 端上取证因此在契约仿真网关上完成 用户敲一次 → 本线复跑即可
4 端到端 ≤2s 硬读数 需发端打时间戳配合(§3) 投递方确认判据口径
5 真机 APK 安装包分发 本轮只出 debug APK(assembleDebug);release 签名未配 需要时再做(属发布环节)

已消除的旧未完成项(对照上一棒 回报_手机接入线_只读核对与归属结论_20260928.md 的 P1/P2 清单):

旧项 现状
P1「E:/github/dsh-client 先入库」 ✅ 已入库(baseline 5e43e4c + 实现 84f030d)
P1「撤回 server.url,页面改为壳自带」 ✅ 已撤回;webDir=www,桥停掉 App 仍有 UI
P2「补 MVP 第 5 件 stop/respawn + 重连钩子」 ✅ 已补(中断走 ACP session/cancel;visibilitychange/online/offline 钩子齐备)

8 ⑦ 边界自证(派活件要求的最后一段)

项 读数
dsh-client 入库情况 ✅ 两次提交:5e43e4c(接手前工作树快照 = 回滚基线,168 文件)+ 84f030d(本轮实现)。git status 干净。回滚 = git checkout 5e43e4c -- apps/android
令牌是否落盘 ✅ 0:全树扫 oUF-(真网关口令前缀)/demo-token/gateway_session= ⇒ 命中 0 个文件;页面里令牌只在内存 + 本机 localStorage,⛔ 无默认值、⛔ 不写文件
活代码是否写死地址/端口 ✅ 0:capacitor.config.ts 无 server.url;index.html 的 base 默认 空串(同源),唯一出现 127.0.0.1 的是设置页提示文字
端口漂移(勘误二的现场复核) ✅ 本轮亲眼又漂一次:55395 → 63864(同一台机、同一天内)。发现脚本 棒0-网关端口发现-20260928.mjs 三判据 + fail-closed 实跑 = 端口 63864 · pid 45404 (WorkBuddy.exe) · GET / 200 含 «CodeBuddy Gateway» · /api/v1/health 401 · exit=0 ⇒ ⛔ 全程零处写死端口
构建工具链 ✅ source /e/Android/env.sh ⇒ openjdk 21.0.12.1 LTS + adb 1.0.40/41 + SDK 36;BUILD SUCCESSFUL(硬约束 ② )
静置 10 分钟读数 请求数与其分布见本节末(§8.1)

8.1 连续 10 分钟静置取证(真跑,bash soak-10min.sh)

静置前的状态(先确认,否则"0 请求"可能只是因为 App 根本没在跑):

视图 = v-chat           # 停在会话页
长连状态 = 实时(长连)  # ACP 长连接着
气泡数 = 8

读数(原样贴上脚本输出):

# 结束时 mock 日志行数 = 22
# 静置时长 = 600 秒
# 静置期请求总数 = 0

## 静置期收到的方法/路径分布
  (无)
  429 计数(若 mock 有记录): 0

## 结论
静置 10 分钟仅 0 条请求 ⇒ **未发现任何周期性请求** ⇒ "⛔ 不轮询" 成立,限流不可能被打满。

⇒ 判据 ⑥ 成立:连续 10 分钟零请求、零 429。 (⛔ 这 10 分钟里没有去操作那个 App —— 只读地让它静置,避免污染读数。)

另有一条本轮作废的读数,如实交代:第一次静置窗口跑到第 5 分钟时我自己去操作了同一个 App (为了抢时间做别的取证),那个窗口的读数已被污染 ⇒ 作废、重跑了上面这一轮。 记这条是为了说明:静置类判据必须独占该 App,否则数字不算。


9 交付件清单(全部可复跑)

E:/ProgramData/AIProject/ai1net-dsh-anywhere/tmp/b3-android-20260928/

文件 用途 复跑
mock-gateway.mjs 契约仿真网关:字段严格照 §1.2 实读 schema;含 CORS、?password=→Cookie、ACP 全族、session/cancel 广播 node mock-gateway.mjs --port 18080 --token <t>
drive-cdp.mjs 端上驱动器:经 WebView DevTools 通道在真 WebView 里点/读,跑 MVP 7 条判据并出截图 node drive-cdp.mjs --base <mock> --token <t>
acp-timing.mjs 真网关 ACP 计时探针(只读):建连耗时/首帧/帧间隔分位/是否被服务端断开 node acp-timing.mjs --port <真端口>
acp-probe.mjs · acp-sse-probe.mjs ACP 长连形态探针(session/load vs GET /acp 的差别取证) 同上
probe-sse-fireforget.mjs 端上不 await 地探 SSE(本轮用它钉死了「只 writeHead 不 flush ⇒ 永不 resolve」) node probe-sse-fireforget.mjs
soak-10min.sh 连续 10 分钟静置取证(请求数分布 + 429 计数) bash soak-10min.sh
shots.mjs · shots/ 端上截图取样 + 4 张实拍 node shots.mjs
extract-openapi.py · sessions-openapi.txt 从 bundle 抽出 gateway 的 OpenAPI(143 路由 / sessions 族 10 条) python extract-openapi.py <bundle> .
netstat.txt/tasklist.txt/parents.txt 受限环境下喂给发现脚本的三份文本(沙箱里 Node spawn 恒 EBUSY) 见脚本头注

改动落点(E:/github/dsh-client):

路径 状态
apps/android/www/index.html 重写(壳内页面,MVP 五件事)· 34283 B
apps/android/capacitor.config.ts 改:删 server.url;appName 改「WorkBuddy 手机端」
apps/android/android/app/src/debug/AndroidManifest.xml 新增:仅 debug 变体开 usesCleartextTraffic(⛔ release 不含)

10 回报格式(按派活件 §7 模板逐项填)

① 接口 = sessions(**已确认**;且额外勘误一处:尾随通道不是 /replay,是 GET /api/v1/acp)
② MVP 五件事 = 列表 ✅ / 历史 ✅ / 实时尾随 ✅ / 发一句 ✅ / 中断·重连 ✅
              端上 7/7 判据全绿(读数见 §2)
③ 实时延迟 = 端上渲染 0.5–0.8ms/帧 · 帧间隔 233ms;长连建立 21ms;
             真网关 16s 收 548 帧/422KB 不断开。⛔ 端到端 ≤2s 需发端埋点,本轮不冒充
④ 限流读数 = **无 429**;连续 10 分钟静置 **600 秒 / 0 条请求**;且零 setInterval、重连已封顶 5 次
⑤ 真机 = 模拟器 sdk_gphone64_x86_64(AVD dsh_root)· Android 16 (SDK 36) · APK 4,158,555 B
         ⚠️ 非真手机(桌上那台 adb 处于 unauthorized,需用户点允许)
⑥ 未完成项 = 见 §7(真机腿 / 端上×真网关被 origin 白名单 403 卡住 / 真令牌 / 端到端埋点)
⑦ 边界自证 = dsh-client 已入库(5e43e4c baseline + 84f030d 实现,工作树干净);令牌落盘 = 0

一句话:五件事全在、端上判据 7/7、三条硬约束全部满足且各有一处自行纠偏; 两条勘误照做并加了一条(尾随 = ACP,不是 replay);卡点只剩三件都不在本线权限内的 (真机需用户点授权/真网关跨源需加 origin 白名单/真令牌需用户敲一次)。