Răsfoiți Sursa

文档对齐:心跳与三段停止;补一条真机调试坑

上一轮代码改完了,但有几处面向人的说明还停在改造前,会误导后面接手的人:

- 根 README:通道图补 engine-beat / run-log,文件数按实际列;
  「已知能力边界」整段过时(说 ID提取/私信/养号没引擎,三个引擎早做完了),
  改成当前真实边界:四任务都有引擎,唯一做不了的是评论发布(发送键是自绘控件,树下没节点)
- tiktok-app/README:文件表补 engine-beat / run-log;「两步停止」改三段
  (正在通知引擎 → 正在收尾 → 终态),超时 8s 改 ack 3s + 收尾 10s;
  补一句心跳 ack 的定位(防御性保险,正常路径主流程 750ms 就自己发现停止了,更快);
  验收清单同步,并把「未接入脚本被拦下」标注为已无天然样本
- bridge.js 头注释:四个约定文件 → 五个,说明 run-state 与 engine-beat 为什么要分开
- PROTOCOL.md §7.1:写清心跳 ack 是保险不是主路,以及"别在真实流程里抢观察窗口"(抢不到)
- AI_CONTEXT §6.B 新增 B0:运行中 uiautomator dump 会静默返回上一次的文件
  ("could not get idle state" 但 rc=0,旧文件还在;`>/dev/null 2>&1` 正好把错误吞掉)。
  正解是 run 中用 screencap 截图。这次真机上据此误判过"界面没进运行态"

真机补验(Pixel 5a):
- D5':阻塞桩(主线程死睡不看 flag,心跳独立跳)→ 点停止后 1.56s 心跳记下 stopSeenAt,
  之后每一拍都带着(sticky 生效),10s 后界面收口并如实报「已停止(引擎未确认收尾)」
  —— 证明心跳 ack 这根保险丝端到端是通的,且两段超时的文案能区分是哪一段没等到
- D6:dd 写 800MB 制造 IO 压力,养号跑 34s,心跳间隔始终 2002~2005ms,无一次接近 8s
- 设备已还原:产物 md5 与仓库一致,桩/截图/临时入口全部清掉

自检仍全绿:smoke 526 / audit CLEAN / protocol-check 35 / pc-selftest-id 114 / pc-selftest-bridge 45
梦境 1 zi în urmă
părinte
comite
d10b7f1d6a
6 a modificat fișierele cu 87 adăugiri și 30 ștergeri
  1. 16 8
      README.md
  2. 12 0
      docs/AI_CONTEXT.md
  3. 14 0
      docs/PROTOCOL.md
  4. 30 15
      tiktok-app/README.md
  5. 8 4
      tiktok-auto/dist/tiktok-auto.js
  6. 7 3
      tiktok-auto/src/bridge.js

+ 16 - 8
README.md

@@ -69,25 +69,33 @@ adb shell "am start -n org.autojs.autoxjs.v7/org.autojs.autojs.external.open.Run
 界面点「开始运行」做三件事,然后由**执行引擎**接手:
 
 ```
-界面 ──写──▶ /sdcard/tiktok_app/engine-config.json   (映射后的引擎配置)
+界面 ──写──▶ /sdcard/tiktok_app/engine-config.json   (映射后的引擎配置,含本轮 runId)
      ──起──▶ engines.execScriptFile(tiktok-auto 产物) (另一个 ScriptEngine)
      ──读──◀ /sdcard/tiktok_app/run-state.json       (进度 / 终态)
+     ──读──◀ /sdcard/tiktok_app/engine-beat.json     (心跳,改动前没有,判活用)
      ──写──▶ /sdcard/tiktok_app/stop.flag            (停止请求,内容是 runId)
+     ──读──◀ /sdcard/tiktok_app/run-log.jsonl        (执行日志)
 ```
 
 外加一份 `run-config.json`:界面完整配置 + 本次生效的引擎配置 + 「哪些勾了但不会生效」,
-**给人看、给事后追溯用**。
+**给人看、给事后追溯用**(没有程序读它)。
 
-四个文件都在 `/sdcard/tiktok_app/` 下,都是纯文本 JSON —— 出问题直接 `cat` 就能复盘。
+这六个文件都在 `/sdcard/tiktok_app/` 下,除了日志是逐行 JSON(jsonl)以外都是整份 JSON
+—— 出问题直接 `cat` 就能复盘。
+
+**心跳是这次新加的**:引擎除了按条目上报进度,还有一个独立线程每 2 秒写一次心跳。
+界面靠它区分「活着但正卡在一个 25 秒的观看里」和「已经死了」——只靠状态文件做不到这件事。
+协议细节见 [`docs/PROTOCOL.md`](docs/PROTOCOL.md)。
 
 > 为什么走**文件**而不是内存:界面与引擎是两个独立的 ScriptEngine,各有各的全局作用域,
 > 对象传不过去(实测确认)。详见 [`tiktok-app/README.md`](tiktok-app/README.md) 第 6 节。
 
-**已知能力边界**:引擎目前实现的是「搜索 → 逐个看 → 评论」。
-界面上 ID提取 / UID私信 / 养号三页的脚本**没有对应引擎** —— 点「开始运行」会被明确拦下
-(弹窗说明哪个脚本没接入),不会静默跑一个不相关的东西。
-视频功能页「执行操作」里的**关注 / 私信 / 点赞也尚未接入**:勾了会在启动时提示一次,
-并记进 `run-config.json` 的 `engine.notes`。
+**已知能力边界**:四个任务(关键词视频 / ID提取 / 养号 / UID私信)**都有引擎**。
+
+唯一还做不了的界面动作是**评论发布**:评论面板的发送键是**画在输入框右端内部的自绘控件**,
+无障碍树里没有可点的节点(输入框右边界 1028,到屏宽只剩 52px,装不下 72×72 的按钮)。
+私信能发是因为它的输入框右侧留了 192px,有独立节点。视频页勾了「评论」会在启动时如实提示,
+日志里报 `send-button-not-found`,**不会**假装成功。
 
 ## 改什么改哪里
 

+ 12 - 0
docs/AI_CONTEXT.md

@@ -331,6 +331,18 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
 
 ### 6.B AutoX 运行时
 
+**B0|运行中 `uiautomator dump` 会静默返回**上一次**的文件(★ 2026-11 栽过一次)**
+
+- 现象:脚本明明在跑、状态条在走,`uiautomator dump` 返回 `rc=0`,读出来的界面却是上一帧 ——
+  按钮还写着「开始运行」,实际早就是「停止运行」了
+- 根因:dump 要等无障碍**空闲**,而运行中的界面每秒刷新一次文字、悬浮球还有 900ms 呼吸环 →
+  等不到 idle,抛 `ERROR: could not get idle state`。**但命令仍然返回 0,旧文件也仍然在**
+- 最坑的地方:习惯性写的 `>/dev/null 2>&1` 正好把那条错误吞掉,于是你以为读到了当前界面,
+  据此做的判断全是错的(我据此误判过"界面没进运行态",其实是读的旧文件)
+- 正解:**运行中看界面用 `screencap -p` 截图**(截图不依赖 idle),别用 dump;
+  确实需要 dump 就先结束运行
+- 判据:dump 出来的文本里还是「开始运行」、而顶部运行条明明在计时 → 就是读了旧文件
+
 **B1|`app.launchPackage()` 会重置页面(★ 最容易误判)**
 
 - 现象:用户手动停在视频评论区,一跑脚本就回到首页,然后报"你不在播放页"

+ 14 - 0
docs/PROTOCOL.md

@@ -146,6 +146,20 @@ accel  = capabilities.heartbeat ? beatFreshMs : legacyChildGraceMs
 - 超时:ack 3 秒;ack 之后 10 秒;两者都没等到就按 13 秒总时长收口,并如实说明是哪一段没等到
 - v1 引擎没有心跳,沿用旧的单段 8 秒
 
+### 7.1 心跳 ack 的定位:防御性保险,不是主路径
+
+业务代码里**所有等待都走 `wait.tick()`**(`tickMs` 在 200~250ms 量级),而 `checkStop` 节流 500ms
+—— 主流程最长约 **750ms** 就会自己发现停止请求,比心跳的 2 秒更快。
+所以正常路径上先到的**总是** `run-state.status === 'stopping'` 那条通道。
+
+心跳 ack 只在主线程被系统层面卡住(GC、IO 抖动、页面假死)时才可能先到。
+它的价值是"保险丝",不是"主路"。
+
+★ 因此**不要指望在真实流程里抢到观察窗口**(抢不到:`uiautomator dump` 一次就要 700ms,
+而窗口只有几百毫秒)。要验这根线通不通,用一个**阻塞桩**:
+主线程 `sleep(30000)` 完全不看 flag,另起独立线程每 2s 写 beat 并读 flag 写 `stopSeenAt`。
+这样"界面从「正在通知引擎」翻到「正在收尾」"就一定是心跳促成的。
+
 ## 8. 改了协议要跑什么
 
 ```bash

+ 30 - 15
tiktok-app/README.md

@@ -521,40 +521,54 @@ tiktok-app/
  └─ 终态(finished / stopped / failed)→ 复位 + 切回前台 + toast
 ```
 
-### 两侧怎么说话:四个文件
+### 两侧怎么说话:六个文件
 
 界面与引擎是**两个独立的 ScriptEngine**:各有各的全局作用域,对象传不过去
 (实测:父脚本读不到子引擎里的任何东西)。所以通道只能是文件。
 
 | 文件(都在 `/sdcard/tiktok_app/`) | 方向 | 内容 |
 |---|---|---|
-| `engine-config.json` | 界面 → 引擎 | 映射后的引擎配置(由 `--config` 交给 `config.resolve`) |
-| `run-state.json` | 引擎 → 界面 | `status` / `phase` / `done` / `total` / `message` / `summary` |
+| `engine-config.json` | 界面 → 引擎 | 映射后的引擎配置(由 `--config` 交给 `config.resolve`),含本轮 `runtime.runId` |
+| `run-state.json` | 引擎 → 界面 | `v` / `status` / `phase` / `done` / `total` / `message` / `summary` / `engine.capabilities` |
+| `engine-beat.json` | 引擎 → 界面 | 心跳:`beatAt` + `stopSeenAt`,**每 2 秒整份重写**,唯一写者是引擎的心跳线程 |
 | `stop.flag` | 界面 → 引擎 | 停止请求,内容是**本次运行的 runId** |
-| `run-config.json` | 界面 → 人 | 完整快照,供追溯 |
+| `run-log.jsonl` | 引擎 → 界面 | 逐行 JSON,D3 的「执行日志」读它 |
+| `run-config.json` | 界面 → 人 | 完整快照,供追溯(**没有程序读它**) |
 
-全是纯文本 JSON,出问题 `cat` 一下就能复盘。
+除日志是逐行 JSON 外都是整份 JSON,出问题 `cat` 一下就能复盘。
 
-### 停止:两步,且以引擎的回报为准
+**为什么心跳要单独一个文件**:`run-state` 由主流程按条目写(可能几十秒一次),
+心跳 2 秒一次。两个写者写同一个文件必然互相覆盖,而且频率差一个数量级。
+有了它,界面才能区分「活着但正卡在一个 25 秒的观看里」和「已经死了」——
+这是只靠状态文件永远做不到的事。
 
-点「停止运行」不是"一按就当停了":
+### 停止:三段,且以引擎的回报为准
 
-1. 界面写 `stop.flag`(内容 = runId),状态条立刻变 **「正在收尾」**(琥珀色);
-2. 引擎在**唯一的等待原语** `wait.tick()` 里轮询到它 → 抛 `StopSignal` →
-   退出当前等待 → 收尾 → 写 `status: "stopped"`;
-3. 界面读到终态 → 复位 + 提示。实测**从点击到复位约 1 秒**。
+点「停止运行」不是"一按就当停了",中间有两段"我说了 / 它听见了"要分清:
+
+1. 界面写 `stop.flag`(内容 = runId),状态条变 **「正在通知引擎」**(琥珀色)
+   —— 此刻只是"我发出了",引擎还没回话;
+2. 引擎承认收到 → 状态条变 **「正在收尾」**。承认有两条通道:
+   心跳里的 `stopSeenAt`(快,独立线程,不受主流程阻塞影响),
+   或它自己报了 `status: "stopping"`(走 `wait.tick()` → `checkStop`);
+3. 引擎写出终态 → 界面复位 + 提示。实测**从点击到复位约 1 秒**。
 
 为什么不做成点一下就复位:引擎收到请求后还要退出当前等待、把 TikTok 带回信息流,
 它确实还在动。这时界面说「已停止」,用户看到 TikTok 仍在操作,只能怀疑"到底停没停"。
 
-两个细节:
+四个细节:
 
 - **停止检查放在 `wait.tick()` 里**,那是全流程唯一的等待出口(等待应用启动、等待播放、
   等待结果卡片……几十处都经过它)。于是"能被打断"这件事**只实现了一次**,
   业务代码一行都没改。`bridge` 未启用时是空操作,独立运行零开销。
 - **`stop.flag` 里存 runId 而不是空文件**:于是不必删除它(删除既有竞态,
   `files.remove` 又命中 MCP 危险调用黑名单),上一轮的残留标志也不会误杀本轮。
-- 界面还有**超时兜底**(8 秒):引擎不确认也不能一直卡在"正在收尾"上。
+- **超时兜底是两段**:ack 等 3 秒、收尾再等 10 秒,合计 13 秒;
+  哪一段没等到,终态文案就如实说是哪一段(「引擎未确认收到请求」/「引擎未确认收尾」)。
+  老引擎(没有心跳的 v1)沿用改造前的单段 8 秒。
+- **心跳 ack 是防御性保险,不是主路径**:业务代码里所有等待都走 `wait.tick()`
+  (tick 在 200~250ms 量级),所以主流程最长 ~750ms 就会自己发现停止请求,比心跳的 2 秒还快。
+  这条通道只在主线程被系统层面卡住(GC、IO 抖动、页面假死)时才可能先到。
 
 ### 引擎侧的能力边界,如实告知
 
@@ -768,8 +782,9 @@ ID提取的四个数量门槛)。每敲一个数字都会跑一遍 `syncCrossR
 | **真引擎端到端** | 启动 TikTok → 搜索 fashion → 切 Top → 打开视频 → 观看 2095ms → 返回;终态 `finished`,界面接住并提示 |
 | **纯浏览模式** | `comment: {ok:true, skipped:true, reason:"本次未启用评论动作"}`——没勾评论就真的没发 |
 | **跑完自动切回前台** | 界面自己回到前台,顶部显示「上次运行:完成:处理 1 个视频 / 发出 0 条评论」 |
-| **两步停止** | 状态条「正在收尾」(琥珀色)→ 约 1 秒后「已停止(假引擎)」;引擎日志 `收到停止请求(runId 匹配)` |
-| 未接入的脚本被拦下 | 在 UID私信页点「开始运行」→ 弹「…的执行引擎尚未接入」,引擎调用数为 0(ID提取已接入,改用它验这条闸) |
+| **三段停止** | 状态条「正在通知引擎」→(引擎承认)→「正在收尾」(琥珀色)→ 约 1 秒后「已停止(假引擎)」;引擎日志 `收到停止请求(runId 匹配)` |
+| **心跳** | 运行期间 `engine-beat.json` 的 `beatAt` 每 2 秒前进一次;引擎被强杀后 ≤15 秒界面报「引擎已退出(未收到结束信号)」 |
+| 未接入的脚本被拦下 | **已无天然样本**(四个任务都接了引擎)→ 临时摘掉某个任务卡的 `engine` 标记再点运行,应弹「…的执行引擎尚未接入」,且引擎调用数为 0 |
 | **ID提取端到端** | 界面点运行 → 引擎读到 `task=id` + 完整取材清单 → 抓 3 个 uid → 产物文件 3 行 3 唯一 → 记录页「完成:提取 3 个 ID → 文件路径」 |
 
 **联调时自己绊自己两次**,都记下来免得重演:

+ 8 - 4
tiktok-auto/dist/tiktok-auto.js

@@ -1,5 +1,5 @@
 /*! tiktok-auto v1.0.0
- *  built 2026-11-01T06:48:56.247Z
+ *  built 2026-11-01T07:56:06.930Z
  *  modules: logger, protocol, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, nurture, parse, uidset, fingerprint, checkpoint, filters, profile, idsource, resolve, idflow, dm, dmflow, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -308,11 +308,15 @@ TikTokAuto.protocol = (function () {
  * 脚本级全局变量互不可见(实测:父脚本读不到子引擎里的任何对象)。
  * 文件是这里唯一可靠的通道。
  *
- * 四个约定文件(默认都在 /sdcard/tiktok_app/ 下):
+ * 五个约定文件(默认都在 /sdcard/tiktok_app/ 下):
  *   run-config.json    宿主 → 引擎:界面收集的完整配置快照(人可读,非本模块读取)
  *   engine-config.json 宿主 → 引擎:映射后的引擎配置(由 --config 交给 config.resolve)
- *   run-state.json     引擎 → 宿主:进度与终态(本模块写)
- *   stop.flag          宿主 → 引擎:停止请求(本模块只读)
+ *   run-state.json     引擎 → 宿主:进度与终态(本模块主流程写)
+ *   engine-beat.json   引擎 → 宿主:心跳(本模块的**心跳线程**写,每 2 秒整份重写)
+ *   stop.flag          宿主 → 引擎:停止请求(本模块只读,主流程与心跳线程都读)
+ *
+ * ★ run-state 与 engine-beat 必须分开:前者按条目写(可能几十秒一次),
+ *   后者每 2 秒一次。两个写者写同一个文件必然互相覆盖,而且频率差一个数量级。
  *
  * ★ stop.flag 里放的是**本次运行的 runId**,不是空文件、也不是布尔。
  *   好处一:不必删除它(`files.remove` 命中 MCP 危险调用黑名单;删除本身也有竞态)。

+ 7 - 3
tiktok-auto/src/bridge.js

@@ -14,11 +14,15 @@
  * 脚本级全局变量互不可见(实测:父脚本读不到子引擎里的任何对象)。
  * 文件是这里唯一可靠的通道。
  *
- * 四个约定文件(默认都在 /sdcard/tiktok_app/ 下):
+ * 五个约定文件(默认都在 /sdcard/tiktok_app/ 下):
  *   run-config.json    宿主 → 引擎:界面收集的完整配置快照(人可读,非本模块读取)
  *   engine-config.json 宿主 → 引擎:映射后的引擎配置(由 --config 交给 config.resolve)
- *   run-state.json     引擎 → 宿主:进度与终态(本模块写)
- *   stop.flag          宿主 → 引擎:停止请求(本模块只读)
+ *   run-state.json     引擎 → 宿主:进度与终态(本模块主流程写)
+ *   engine-beat.json   引擎 → 宿主:心跳(本模块的**心跳线程**写,每 2 秒整份重写)
+ *   stop.flag          宿主 → 引擎:停止请求(本模块只读,主流程与心跳线程都读)
+ *
+ * ★ run-state 与 engine-beat 必须分开:前者按条目写(可能几十秒一次),
+ *   后者每 2 秒一次。两个写者写同一个文件必然互相覆盖,而且频率差一个数量级。
  *
  * ★ stop.flag 里放的是**本次运行的 runId**,不是空文件、也不是布尔。
  *   好处一:不必删除它(`files.remove` 命中 MCP 危险调用黑名单;删除本身也有竞态)。