Преглед на файлове

运行期协议 v2:心跳判活 / 启动握手 / 停止两段

界面与引擎之间原来只有"文件 + 超时猜",这次补上真正的运行时协议:

引擎侧
- bridge 新增独立心跳线程,每 2s 整份重写 engine-beat.json(与 run-state 分开写,
  两个写者写同一个文件必然互相覆盖)。心跳不调 checkStop / report —— 前者会把
  StopSignal 抛进心跳线程,收尾期就再没人写心跳;后者会覆盖主流程的 phase。
  stopSeenAt 是 sticky 的,否则下一拍就把停止确认抹掉。
- run-state 增 v / seq / startedAt / engine.task / engine.capabilities.heartbeat
- enable 改成"先起心跳、拿结果、再写第一次状态"
- main 的 finish 用 try/finally 包住,异常也不会漏掉 heartbeatStop
- config 增 runtime.runId 绑定:双方都非空且不一致才拒绝(独立运行必须放行)

界面侧
- pollEngine 重写成四段:终态 → 启动握手 → 停止两段 → 常态判活
- 判活以心跳为主判据,engines.all() 只用于加速判死;心跳新鲜时它说没有也不判死
- 状态条四档:启动中(较慢)/ 运行中 / 正在通知引擎 / 正在收尾
- childAlive 优先按 launch 拿到的子引擎 id 精确匹配,取不到才退回旧宽口径
- 所有超时改从 Protocol.SPEC.thresholds 取,不再有魔法数字

配套
- 两侧 protocol.js(除挂载点外逐字节相同)+ protocol-check.js 强制一致
- 新增 pc-selftest-bridge.js(自建"只登记不执行"的线程桩)
- fake-engine 补 v 与心跳,否则最常用的 UI 联调手段会被判成引擎失联
- docs/PROTOCOL.md,AI_CONTEXT 的 §2.3 / §2.4 / §3.x / §4.1 / §11.3

自检:smoke 526 项、audit CLEAN、protocol-check 35 项、
pc-selftest-id 114 项、pc-selftest-bridge 45 项。
真机(Pixel 5a):心跳 2s 稳定、停止 ≤1s 收口、启动超时 15s 触发、
引擎消失 ≤11s 判出、v1 旧产物正常降级(旧心跳被正确忽略)。
梦境 преди 1 ден
родител
ревизия
345182d475

+ 6 - 2
.github/copilot-instructions.md

@@ -19,11 +19,15 @@
 ```bash
 NODE="C:/Users/61554/.workbuddy/binaries/node/versions/22.22.2-6/node.exe"  # 或直接用 node
 
-cd tiktok-auto && $NODE tools/build.js && $NODE tools/pc-selftest-id.js   # 期望 68 项通过
+cd tiktok-auto && $NODE tools/build.js && $NODE tools/pc-selftest-id.js && $NODE tools/pc-selftest-bridge.js
+                                                                          # 期望 114 项 + BRIDGE_SELFTEST_PASS
 cd tiktok-app  && $NODE tools/build.js && $NODE tools/smoke.js && $NODE tools/audit-values.js
-                                                                          # 期望 414 项通过 + AUDIT_CLEAN
+                                                                          # 期望 526 项通过 + AUDIT_CLEAN
+cd tiktok-app  && $NODE tools/protocol-check.js                            # 期望 PROTOCOL_CLEAN(动过 protocol.js 就跑)
 ```
 
+★ 顺序不能反:`smoke.js` / `audit-values.js` require 的是 **dist**,改了 `src/` 不 build 照样全绿。
+
 ## 硬规则(违反会静默出问题)
 
 1. **新增模块前查重名函数**:`for f in src/*.js; do grep -o '^function [a-zA-Z_]*' "$f"; done | sort | uniq -d`

+ 55 - 17
docs/AI_CONTEXT.md

@@ -76,29 +76,42 @@ main.js 只做两件事:加载 tiktok-app/dist/tiktok-app.js + 可操作的报
 
 > ★ `"ui";` 只对**入口文件**有效。写在被 `require` 的模块里只是一句字符串。
 
-### 2.3 界面 ↔ 引擎 的通信(4 个文件)
+### 2.3 界面 ↔ 引擎 的通信(6 个文件)
 
 | 文件 | 方向 | 内容 |
 |---|---|---|
-| `/sdcard/tiktok_app/engine-config.json` | 界面 → 引擎 | 完整配置 |
-| `/sdcard/tiktok_app/run-state.json` | 引擎 → 界面 | `status` / `phase` / `done` / `summary` |
+| `/sdcard/tiktok_app/engine-config.json` | 界面 → 引擎 | 完整配置(含 `runtime.runId`,引擎拿它跟 `--run-id` 比对) |
+| `/sdcard/tiktok_app/run-state.json` | 引擎 → 界面 | `v` / `status` / `phase` / `done` / `summary` / `engine.capabilities` |
+| `/sdcard/tiktok_app/engine-beat.json` | 引擎 → 界面 | 心跳:每 2 秒整份重写,`beatAt` + `stopSeenAt` |
 | `/sdcard/tiktok_app/stop.flag` | 界面 → 引擎 | **内容 = runId**(不是空文件) |
 | `/sdcard/tiktok_app/run-log.jsonl` | 引擎 → 界面 | 逐行 JSON,界面读回做日志页 |
 | `/sdcard/tiktok_app/ball-rect.json` | 悬浮球 → 引擎 | 避让矩形 |
 
+(另有 `run-config.json`:界面写给**人**看的配置快照,没有任何程序读它。)
+
 **为什么走文件而不是进程内通信**:子脚本看不到父脚本的任何全局变量(实测)。
 
-**三条容易踩的**:
+**四条容易踩的**:
 
 - `stop.flag` 用「内容 = runId」而不是「文件存在」——删除有竞态,而且 `files.remove` 命中 MCP 的危险调用黑名单
 - 读 `run-state.json` **必须比对 runId**,否则"刚点开始就显示上一轮进度"
+- `engine-beat.json` 同理要比对 runId;★ 但"**没有本轮心跳**"≠"**引擎不支持心跳**"——后者只看 `run-state.engine.capabilities.heartbeat`。这两件事混在一起,每次启动的头两秒都会被误降成 v1
 - `run-log.jsonl` 而不是 `.json`——半截行只丢一行
 
+协议细节(状态迁移表、全部阈值、兼容规则):`docs/PROTOCOL.md`。
+
 ### 2.4 停止机制
 
 `StopSignal` 从**唯一的等待原语** `wait.tick()` 抛出 → 全流程可中断、业务代码零改动。
 
-界面两步:写 flag → 显示「正在收尾」→ 等引擎上报 `stopped` 才复位(约 1 秒,8 秒超时兜底)。
+界面三段(超时值全部来自 `Protocol.SPEC.thresholds`):
+
+1. 写 `stop.flag` → 状态条「**正在通知引擎**」(引擎还没回话)
+2. 引擎承认收到(心跳里的 `stopSeenAt`,或它自己报了 `stopping`)→「**正在收尾**」
+3. 引擎写出终态 → 复位
+
+兜底:ack 等 3 秒、收尾再等 10 秒,合计 13 秒;v1(无心跳)引擎沿用旧的单段 8 秒。
+**心跳线程的独立 ack 是关键**——主流程可能正卡在一个 25 秒的 watch 里,只有独立线程能立刻回话。
 
 ---
 
@@ -110,12 +123,13 @@ tiktok-scripts/
 ├── main.js                  ★ 入口:加载界面产物 + 报错兜底
 │
 ├── tiktok-app/              界面工程
-│   ├── src/                 19 个模块(见下表)
+│   ├── src/                 22 个模块(见下表)
 │   ├── dist/tiktok-app.js   构建产物
-│   ├── tools/build.js       构建 + 两道自检(ui 位置 / 语法 / 未完成标记)
-│   ├── tools/smoke.js       ★ 414 项结构自检(PC 侧,stub 环境)
+│   ├── tools/build.js       构建 + 自检(ui 位置 / 语法 / 未完成标记 / Protocol 已进产物)
+│   ├── tools/smoke.js       ★ 526 项结构自检(PC 侧,stub 环境)
 │   ├── tools/audit-values.js 六场景取值审计
-│   └── tools/fake-engine.js 假引擎(验证界面而不真跑 TikTok)
+│   ├── tools/protocol-check.js 两侧 protocol.js 的一致性 / 自洽性检查
+│   └── tools/fake-engine.js 假引擎(验证界面而不真跑 TikTok;会跳心跳)
 │
 ├── tiktok-auto/             引擎
 │   ├── src/                 19 个模块(见下表)
@@ -132,7 +146,7 @@ tiktok-scripts/
 └── _probe/                  真机探测脚本与截图(可清理)
 ```
 
-### 3.1 界面模块(19)
+### 3.1 界面模块(22)
 
 | 模块 | 职责 |
 |---|---|
@@ -142,18 +156,21 @@ tiktok-scripts/
 | `records.js` | 运行记录(同上,独立 key) |
 | `device.js` | 设备能力探测(包名、权限、无障碍) |
 | `service.js` | 卡密 / 版本 / 更新检查 |
-| `engine.js` | ★ 界面配置 → 引擎配置的映射;起引擎 |
-| `session.js` | 运行态(**纯内存**,不落盘) |
+| `fingerprint.js` | 配置指纹(与引擎侧同算法,断点续跑用) |
+| `protocol.js` | ★ 运行期协议:状态枚举 / 迁移表 / 阈值(引擎侧有一份同样的拷贝) |
+| `engine.js` | ★ 界面配置 → 引擎配置的映射;起引擎;读状态与心跳 |
+| `session.js` | 运行态(**纯内存**,不落盘),含握手四档文案 |
+| `resume.js` | 断点续跑(只读引擎写的进度文件) |
 | `widgets.js` | ★ 控件工厂(`W.*`),所有输入框的唯一入口 |
 | `dialog.js` | 自绘弹窗(UI 模式禁止阻塞式 dialogs) |
 | `floatball.js` | ★ 悬浮球(两个 overlay 窗口,见 6.B) |
 | `page-*.js` | 6 个页面 |
 | `main.js` | App 装配、路由、生命周期 |
 
-### 3.2 引擎模块(18 个业务模块 + `main` 入口,构建输出 19)
+### 3.2 引擎模块(25 个业务模块 + `main` 入口,构建输出 26)
 
 ```
-logger → bridge → config → wait → layout → ui → popups → tiktok → player → comment → flow
+logger → protocol → bridge → config → wait → layout → ui → popups → tiktok → player → comment → flow
                                                                     ↓
                           parse → uidset → filters → profile → idsource → resolve → idflow
                           └──────────── ID 提取(task=id)────────────┘
@@ -193,14 +210,21 @@ NODE="C:/Users/61554/.workbuddy/binaries/node/versions/22.22.2-6/node.exe"
 
 # 改完引擎
 cd tiktok-auto && $NODE tools/build.js
-$NODE tools/pc-selftest-id.js        # 期望:通过 68 项
+$NODE tools/pc-selftest-id.js        # 期望:通过 114 项
+$NODE tools/pc-selftest-bridge.js    # 期望:BRIDGE_SELFTEST_PASS(45 项)
 
 # 改完界面
 cd tiktok-app && $NODE tools/build.js
-$NODE tools/smoke.js                 # 期望:通过 414 项
+$NODE tools/smoke.js                 # 期望:通过 526 项
 $NODE tools/audit-values.js          # 期望:AUDIT_CLEAN
+
+# 动过 protocol.js(两侧任意一份)
+$NODE tools/protocol-check.js        # 期望:PROTOCOL_CLEAN(35 项)
 ```
 
+**★ 顺序不能反**:`smoke.js` / `audit-values.js` require 的是 **dist**,
+改了 `src/` 不 build 照样全绿 —— 那是假绿。
+
 **`build.js` 自带的两道检查**(写盘前跑):
 
 - 产物语法检查(`new Function(src)`)
@@ -500,6 +524,17 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
 
 ### 6.D 流程与状态机
 
+**D0|心跳必须是**独立线程**,而且不能借道 `checkStop` / `report`(★ 2026-11)**
+
+- 现象:界面在引擎做 25 秒 watch 时无法判断"它还活着",只能靠 4 秒超时猜,误判成"引擎已退出"
+- 根因:状态只在主流程调 `report()` 时才写,长等待期间一片空白
+- 正解:`bridge.heartbeatStart()` 起独立 `threads.start`,每 2 秒**整份重写** `engine-beat.json`
+- ★ 三条不能破的规矩:
+  1. **不许调 `checkStop()`** —— 命中时它会 `report(stopping)` + 抛 `StopSignal`;抛进心跳线程,收尾那 10 秒就再没人写心跳(那正是最需要它的时候)
+  2. **不许调 `report()`** —— `last` 是跨线程共享的可变对象、整份覆盖写,借道会把 `phase` 写回旧值
+  3. **用裸 `sleep`** —— `wait.tick` 同理会抛 `StopSignal`。这是 `wait.js` 那条"裸 sleep 只能出现在本文件"的唯一受控例外
+- ★ `stopSeenAt` 必须 **sticky**:心跳是整份覆盖写,每拍都从 0 开始的话,2 秒后就把"看见了停止请求"抹掉,而界面是 1 Hz 轮询 —— 有概率整整错过一拍
+
 **D1|阶段性的"停止"不能共用(★ 踩了两次)**
 
 - 现象 ①:设了「提取满 3 个」,抓满后 uid 解析一个都换不到
@@ -858,10 +893,13 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
 
 ```bash
 # 引擎改动
-cd tiktok-auto && $NODE tools/build.js && $NODE tools/pc-selftest-id.js
+cd tiktok-auto && $NODE tools/build.js && $NODE tools/pc-selftest-id.js && $NODE tools/pc-selftest-bridge.js
 
 # 界面改动
 cd tiktok-app && $NODE tools/build.js && $NODE tools/smoke.js && $NODE tools/audit-values.js
+
+# 协议改动(protocol.js 任一侧)
+$NODE tools/protocol-check.js
 ```
 
 - 断言失败时**先判断是回归还是断言需要更新**(例如新增一个基础设施计时器会让"计时器数量"断言从 1 变 2——那是断言要改,不是功能坏了)

+ 158 - 0
docs/PROTOCOL.md

@@ -0,0 +1,158 @@
+# 界面 ↔ 引擎 运行期协议(v2)
+
+> 代码里的**唯一事实来源**是 `tiktok-app/src/protocol.js` 与 `tiktok-auto/src/protocol.js`
+> (两份实体文件,除挂载点一行外**逐字节相同**,由 `tiktok-app/tools/protocol-check.js` 强制)。
+> 本文档是给人读的解释,任何数字与代码冲突时**以 `Protocol.SPEC` 为准**。
+
+## 1. 为什么要协议
+
+两条进程(界面主脚本 + `engines.execScriptFile` 起的子引擎)之间只有文件通道,
+没有任何共享内存。在此之前,两边各自写死判断:
+
+| 症状 | 原因 |
+|---|---|
+| 引擎在做 25 秒的 watch,界面却报"引擎已退出" | 没有心跳,只能靠 `engines.all()` + 4 秒宽限期猜 |
+| 点了开始、界面说"运行中",其实引擎连文件都还没读 | 启动间隙是模糊态 |
+| 点了停止、界面说"正在收尾",其实引擎还没听见 | 停止只有一段超时,说不清是没听见还是没做完 |
+| 状态枚举散在三处(`engine.js` / `main.js` / `records.js`) | 漏改一处就变成零报错的悬挂 |
+
+## 2. 文件分工
+
+| 文件 | 写方 | 读方 | 频率 |
+|---|---|---|---|
+| `run-state.json` | 引擎主流程 `bridge.report` | 界面 `App.pollEngine` | ~1 次/条目 + 首尾 |
+| `engine-beat.json` | 引擎**心跳线程**(唯一写者) | 界面 | 每 2 秒 |
+| `engine-config.json` | 界面 | 引擎 | 1 次/run |
+| `stop.flag` | 界面 | 引擎主流程 + 心跳线程 | 1 次/run |
+| `run-log.jsonl` | 引擎 logger sink | 界面(执行日志页) | 流式 |
+| `run-config.json` | 界面 | **人**(没有程序读者) | 1 次/run |
+
+心跳单独成文件的理由:`run-state` 由主流程按条目写,心跳每 2 秒一次,
+两个写者写同一个文件必然互相覆盖,而且频率差一个数量级。
+
+## 3. 数据结构
+
+### 3.1 `run-state.json`(v2 只增不改)
+
+```json
+{
+  "v": 2,
+  "runId": "run-1783000000000-12345",
+  "status": "running",
+  "phase": "搜索 fashion",
+  "done": 2,
+  "total": 5,
+  "seq": 17,
+  "startedAt": 1783000001234,
+  "updatedAt": 1783000045678,
+  "engine": { "task": "video", "capabilities": { "heartbeat": true } },
+  "message": "",
+  "summary": null
+}
+```
+
+- `seq` 单调递增(每次 `report` +1),用来判断"同一毫秒内的两次写谁更新"
+- `engine.capabilities.heartbeat` 是**正向证据**:只有引擎真的把心跳线程起起来才会是 true
+- `engine.task` 由 `bridge.setTask()` 在 `config.resolve` 之后补,用它排障"跑的是哪条链路"
+
+### 3.2 `engine-beat.json`
+
+```json
+{ "v": 2, "runId": "run-…", "seq": 42, "beatAt": 1783000045000, "stopSeenAt": 0 }
+```
+
+- `stopSeenAt != 0` = 心跳线程确认"看见了本轮停止请求",是停止 ack 的快速通道
+- **sticky**:一旦写上就不再归零,直到进程结束
+
+## 4. 状态迁移
+
+| from → | starting | running | stopping | finished/stopped | failed |
+|---|---|---|---|---|---|
+| (无) | ✅ | | | | |
+| starting | 幂等 | ✅ | ✅ | ✅(极短任务) | ✅(配置无效) |
+| running | | 幂等 | ✅ | ✅ | ✅ |
+| stopping | | | 幂等 | ✅ | ✅ |
+| 终态 | 无出边 | | | | |
+
+`Protocol.legality(from, to)` 返回:
+
+| 返回值 | 含义 | 调用方应有的反应 |
+|---|---|---|
+| `ok` | 表里有的合法迁移 | 正常处理 |
+| `idempotent` | 原地重复上报 | 忽略 |
+| `regress` | 从终态往外走 | **拒绝** |
+| `unknown` | 表里没有(多半是前向兼容来的新状态) | **放宽**,只在日志留一句,绝不据此结束运行 |
+
+## 5. 阈值(`Protocol.SPEC.thresholds`)
+
+| 常量 | 值 | 语义 |
+|---|---|---|
+| `beatIntervalMs` | 2000 | 心跳间隔 |
+| `beatFreshMs` | 8000 | 新鲜。**只用于"加速判死",不产生任何文案** |
+| `beatDeadMs` | 15000 | 判死 |
+| `startAckSoftMs` | 6000 | 只把文案降成「启动中(较慢)」,**不改状态** |
+| `startAckMaxMs` | 15000 | 判 `failed`,并补写 `stop.flag`(挡住迟到的引擎) |
+| `stopAckMs` | 3000 | 等"引擎承认收到停止" |
+| `stopFinishMs` | 10000 | 承认之后等"真的收完尾" |
+| `legacyChildGraceMs` | 4000 | v1 回退:子引擎宽限期(改造前的值) |
+| `legacyStopTimeoutMs` | 8000 | v1 回退:停止超时(改造前的值) |
+| `legacyNoBeatDeadMs` | 30000 | 引擎说有 v2 但心跳线程没起来时的判死阈值 |
+
+### 5.1 判活公式(界面侧 `App.pollEngine`)
+
+```
+anchor = beatAt>0 ? beatAt : (ackedAt || startedAt)
+limit  = capabilities.heartbeat ? beatDeadMs : legacyNoBeatDeadMs
+accel  = capabilities.heartbeat ? beatFreshMs : legacyChildGraceMs
+
+判死 = (now - anchor > limit)                       // 主判据:心跳
+     || (!childAlive() && now - anchor > accel)      // 加速:列表里也没了
+```
+
+★ 反过来的方向**不成立**:心跳新鲜时,`engines.all()` 说"没有子引擎"**绝不能**判死。
+引擎在做长等待时从列表里消失是常态,这正是要防的那类误判。
+
+★ v1(run-state 里没有 `v`):完全走老口径 —— `sawChild && startedAt > 4s && !childAlive()`。
+
+## 6. 前向兼容
+
+1. 未知 `status` → 按**非终态**处理,永不据此结束运行
+2. 未知字段 → 忽略(写方唯一,每次写全量)
+3. `v` 高于本机 → 只读降级,只认 `runId/status/phase/done/total`,不报错
+4. **协议档次**只看两件事:
+   - `run-state.v >= 2`,或
+   - 已经收到过**本轮**的 `engine-beat.json`
+   - ★ 反例:「现在还没有心跳文件」**不是** v1 的证据 —— 新引擎刚起来那一两秒也没有
+5. 半截 JSON → `readState/readBeat` 返回 `null`,**任何状态都不改变**(尤其不得据此判死)
+6. 不用文件 mtime 判活(`files` 的 stat 语义在各运行时不一致)
+
+## 7. 停止的三段与两段超时
+
+```
+界面                                    引擎
+ │ 写 stop.flag (内容=runId)              │
+ │ 状态条:正在通知引擎                    │
+ │ ────────────────────────────────────▶ │ 心跳线程每 2s 读一次 flag
+ │                                       │ 命中 → 写 beat.stopSeenAt(sticky)
+ │ ◀──────────────────────────────────── │
+ │ stopAckedAt = now → 正在收尾            │ 主流程 wait.tick → checkStop
+ │                                       │ → report(stopping) → throw StopSignal
+ │ ────────────────────────────────────▶ │ finally: finish(stopped) → 停心跳
+ │ ◀──────────────────────────────────── │ run-state.status = stopped
+ │ finishRun()                           │
+```
+
+- **ack 通道有两条**:心跳的 `stopSeenAt`(快,不受主流程阻塞影响)和 `run-state.status === 'stopping'`(慢,要等主流程走到下一个 `tick`)
+- 超时:ack 3 秒;ack 之后 10 秒;两者都没等到就按 13 秒总时长收口,并如实说明是哪一段没等到
+- v1 引擎没有心跳,沿用旧的单段 8 秒
+
+## 8. 改了协议要跑什么
+
+```bash
+cd tiktok-app && $NODE tools/protocol-check.js   # 两份协议一致 + 迁移表自洽 + 阈值关系
+$NODE tools/build.js && $NODE tools/smoke.js     # 界面行为
+cd ../tiktok-auto && $NODE tools/build.js && $NODE tools/pc-selftest-bridge.js
+```
+
+★ `protocol-check` 里那条"两侧除挂载点外逐字节相同"是硬约束:
+要改就**改一份再同步另一份**(`sed` 替换挂载行即可),不要两边手改。

+ 7 - 1
tiktok-app/README.md

@@ -605,12 +605,18 @@ tiktok-app/
 ### 用一个假引擎验证界面交互
 
 `tools/fake-engine.js`:读同一套 `--config/--state/--stop-flag/--run-id` 协议,
-只写状态、循环等停止信号,**完全不碰 TikTok**。
+写 `run-state.json`(带 `v` / `engine.capabilities`)**和 `engine-beat.json`**,
+循环等停止信号,**完全不碰 TikTok**。
+
+★ 心跳不能省:界面在 v2 档位下是用心跳判活的,假引擎不跳的话跑 15 秒就会被判成
+「引擎已退出」—— 最常用的联调手段反而成了误报源。
 
 为什么需要它:用真引擎验证"状态条/停止"这些 UI 交互时,它会把 TikTok 拉到前台,
 我就没法同时操作界面上的按钮了。换成假引擎,界面始终留在前台,UI 交互能一次测干净。
 
 用法:把设备上的引擎产物临时替换掉(记得备份),验完换回来。
+它能验的:启动握手(先「启动中」再转「运行中」)、进度刷新、
+两段停止(「正在通知引擎」→「正在收尾」→ 结束)。
 
 ---
 

+ 547 - 38
tiktok-app/dist/tiktok-app.js

@@ -1,7 +1,7 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-29T10:43:51.193Z
- *  modules: theme, store, records, sources, device, service, fingerprint, engine, session, resume, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
+ *  built 2026-11-01T06:48:56.695Z
+ *  modules: theme, store, records, sources, device, service, fingerprint, protocol, engine, session, resume, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
 (function () {
@@ -2758,17 +2758,224 @@ var Fingerprint = (function () {
 }());
 
 
+/* ============================== src/protocol.js ============================== */
+/* ===========================================================================
+ * protocol.js —— 界面 ↔ 引擎 的运行期协议(唯一事实来源)
+ *
+ * 这个文件解决的是"两边各写一套枚举、靠魔法数字猜死活"的老问题。
+ * 它只放**纯数据 + 纯判定**:不读文件、不写文件、不起线程、不看时间。
+ * 所以界面侧与引擎侧可以各存一份完整拷贝,行为完全一致。
+ *
+ * ★ 两份拷贝的纪律:`tiktok-app/src/protocol.js` 与
+ *   `tiktok-auto/src/protocol.js` **除第 1 行(挂载点)外必须逐字节相同**。
+ *   界面侧是 `var Protocol = …`,引擎侧是 `TikTokAuto.protocol = …`。
+ *   `tiktok-app/tools/protocol-check.js` 会剥掉第 1 行后逐字节比对,
+ *   任何一边单独改都会立刻被抓出来。
+ *
+ * ★ 为什么状态判定要放在这里而不是各自实现:
+ *   `isTerminalStatus` 曾经只存在于界面(engine.js),引擎侧自己写
+ *   `'finished' / 'stopped' / 'failed'` 三元推导(main.js)。两边漏改一个
+ *   就会出现"引擎以为结束了、界面还在等"这种零报错的悬挂。
+ * =========================================================================== */
+
+var Protocol = (function () {
+    'use strict';
+
+    /** 协议版本。加字段不改语义不用抬;改语义 / 改判定规则必须抬。 */
+    var VERSION = 2;
+
+    /* ------------------------------------------------------------ 状态枚举 */
+
+    var STATUS = {
+        STARTING: 'starting',
+        RUNNING: 'running',
+        STOPPING: 'stopping',
+        FINISHED: 'finished',
+        STOPPED: 'stopped',
+        FAILED: 'failed'
+    };
+
+    /** 终态:到了这里就没有出边(再回到活跃态一律算 regress)。 */
+    var TERMINAL = { finished: true, stopped: true, failed: true };
+
+    /** 活跃态:运行条要显示、判活逻辑要关心的三档。 */
+    var ACTIVE = { starting: true, running: true, stopping: true };
+
+    /**
+     * 合法迁移表:from → 允许去的 to。
+     *
+     * 空串代表"还没有状态"(本轮的第一次上报)。
+     * `starting` 的出边特别宽:配置无效会在启动瞬间直接 failed,
+     * 极短任务可能 starting 之后直接终态,这两种都是真实的。
+     */
+    var EDGES = {
+        '': { starting: true },
+        starting: {
+            starting: true,
+            running: true,
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        running: {
+            running: true,
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        stopping: {
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        finished: {},
+        stopped: {},
+        failed: {}
+    };
+
+    /* ------------------------------------------------------------ 阈值 */
+
+    /**
+     * 所有超时都在这里,界面侧从 `Protocol.SPEC.thresholds` 取,
+     * 引擎侧心跳间隔也从这里取 —— 不允许在业务代码里再写字面量。
+     *
+     * 语义补充(光看名字看不出来的):
+     *   · beatFreshMs  只用于"加速判死"(见界面 pollEngine),**不产生文案**
+     *   · startAckSoftMs 只是日志与文案降级(「启动中(较慢)」),不改状态
+     *   · stopAckMs / stopFinishMs 是两段:先等"看见了停止请求",再等"真的收完尾"
+     *   · legacy* 是无心跳(v1)引擎的回退值,就是改造前在用的数字
+     */
+    var THRESHOLDS = {
+        /* 心跳 */
+        beatIntervalMs: 2000,
+        beatFreshMs: 8000,
+        beatDeadMs: 15000,
+        /* 启动握手 */
+        startAckSoftMs: 6000,
+        startAckMaxMs: 15000,
+        /* 停止两段 */
+        stopAckMs: 3000,
+        stopFinishMs: 10000,
+        /* v1 回退组(老引擎 / 心跳线程起不来) */
+        legacyChildGraceMs: 4000,
+        legacyStopTimeoutMs: 8000,
+        legacyNoBeatDeadMs: 30000
+    };
+
+    var SPEC = {
+        version: VERSION,
+        status: STATUS,
+        terminal: TERMINAL,
+        active: ACTIVE,
+        edges: EDGES,
+        thresholds: THRESHOLDS
+    };
+
+    /* ------------------------------------------------------------ 工具 */
+
+    /* 单文件拼接后所有模块共享一个作用域,而 `Object.prototype.hasOwnProperty`
+     * 的 call 形式会命中引擎侧构建黑名单里的那种写法(连注释都会被扫到),
+     * 所以这里统一走 apply。 */
+    function owns(object, key) {
+        return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
+    }
+
+    /** 把任意输入变成字符串状态;null / undefined → 空串(= 还没有状态)。 */
+    function statusOf(value) {
+        if (value === undefined || value === null) {
+            return '';
+        }
+        return String(value);
+    }
+
+    /* ------------------------------------------------------------ 判定 */
+
+    /**
+     * 是否终态。
+     * ★ 未知状态一律返回 false —— 界面绝不能因为读到一个不认识的 status
+     *   就把正在跑的运行结束掉(前向兼容第一条)。
+     */
+    function isTerminal(status) {
+        var key = statusOf(status);
+        return owns(TERMINAL, key) === true && TERMINAL[key] === true;
+    }
+
+    /** 是否活跃态(运行条要显示的那三档)。未知状态同样返回 false。 */
+    function isActive(status) {
+        var key = statusOf(status);
+        return owns(ACTIVE, key) === true && ACTIVE[key] === true;
+    }
+
+    /**
+     * 三分类,给日志和排障用。
+     * @returns 'active' | 'terminal' | 'unknown'
+     */
+    function levelOf(status) {
+        var key = statusOf(status);
+        if (isTerminal(key)) {
+            return 'terminal';
+        }
+        if (isActive(key)) {
+            return 'active';
+        }
+        return 'unknown';
+    }
+
+    /**
+     * 迁移合法性。
+     *
+     * @returns 'ok'         表里有的合法迁移
+     *          'idempotent' 原地重复上报(引擎允许重复写同一个状态)
+     *          'regress'    从终态往外走 —— **必须拒绝**
+     *          'unknown'    表里没有的组合,多半是前向兼容来的新状态,
+     *                       调用方应当"放宽"(不据此结束运行),
+     *                       只在日志里留一句
+     */
+    function legality(from, to) {
+        var a = statusOf(from);
+        var b = statusOf(to);
+        if (a === b) {
+            return 'idempotent';
+        }
+        if (isTerminal(a)) {
+            return 'regress';
+        }
+        if (owns(EDGES, a) === true && owns(EDGES[a], b) === true) {
+            return 'ok';
+        }
+        return 'unknown';
+    }
+
+    /* ------------------------------------------------------------ 导出 */
+
+    return {
+        VERSION: VERSION,
+        STATUS: STATUS,
+        SPEC: SPEC,
+        statusOf: statusOf,
+        isTerminal: isTerminal,
+        isActive: isActive,
+        levelOf: levelOf,
+        legality: legality
+    };
+})();
+
+
 /* ============================== src/engine.js ============================== */
 /* ============================== src/engine.js ==============================
  * 执行引擎桥接:把界面收集的配置交给 tiktok-auto,再把它的进度读回来。
  *
  * ## 为什么走文件,不走内存
  * 界面与被唤起的引擎是**两个独立的 ScriptEngine**:各有各的全局作用域,
- * 界面里的对象传不过去(实测确认)。所以两边只通过约定好的四个文件说话:
+ * 界面里的对象传不过去(实测确认)。所以两边只通过约定好的几个文件说话:
  *
  *   run-config.json     界面 → 人/追溯:完整配置快照(人可读)
  *   engine-config.json  界面 → 引擎:映射后的引擎配置
  *   run-state.json      引擎 → 界面:进度与终态
+ *   engine-beat.json    引擎 → 界面:心跳(每 2 秒一次,唯一写者是引擎的心跳线程)
  *   stop.flag           界面 → 引擎:停止请求(内容是本次运行的 runId)
  *
  * 文件通道是**异步**的,所以界面这边的规则是:**所有动作以状态文件的回报为准**,
@@ -2795,6 +3002,15 @@ Engine.CONFIG_PATH = Engine.DIR + "/engine-config.json";
 Engine.STATE_PATH = Engine.DIR + "/run-state.json";
 Engine.STOP_FLAG_PATH = Engine.DIR + "/stop.flag";
 
+/**
+ * 引擎心跳文件(引擎的心跳线程每 2 秒整份重写一次)。
+ *
+ * 为什么要和 run-state 分开:run-state 由引擎主流程按条目写,
+ * 心跳每 2 秒一次,两个写者写同一个文件必然互相覆盖。
+ * 有了它,界面才能区分「活着但正卡在一个 25 秒的 watch 里」和「已经死了」。
+ */
+Engine.BEAT_PATH = Engine.DIR + "/engine-beat.json";
+
 /**
  * 引擎的日志文件(jsonl,一行一条)。
  *
@@ -2811,6 +3027,14 @@ Engine.LOG_MAX_LINES = 200;
 /** 引擎产物在项目里的相对位置(项目模式下从这个位置找) */
 Engine.SCRIPT_RELATIVE = "tiktok-auto/dist/tiktok-auto.js";
 
+/**
+ * 协议阈值。唯一来源是 `src/protocol.js` ——
+ * 判活/握手/停止的所有超时都从这里取,**业务代码里不许再写字面量**。
+ * (改一个数忘了改另一个,会得到"ack 等得比心跳还短"这种永远等不到的怪状态,
+ *   `tools/protocol-check.js` 会把这种关系当场拦下。)
+ */
+Engine.T = Protocol.SPEC.thresholds;
+
 /** 本次运行的标识。由界面生成并同时告诉引擎与停止标志,三方比对。 */
 Engine.RUN_ID = "";
 
@@ -3450,10 +3674,22 @@ Engine.prepare = function (task, opts) {
     };
 };
 
-/** 把引擎配置落盘。返回是否成功。 */
-Engine.writeConfig = function (config) {
+/**
+ * 把引擎配置落盘。返回是否成功。
+ *
+ * `runId` 会在写入前塞进 `runtime.runId`:引擎拿它跟命令行里的 `--run-id` 比对,
+ * 挡住"上一轮的引擎迟到启动、却读到这一轮的配置并真的跑起来"。
+ * ★ 注入放在这里而不是四个 `buildXxxConfig` 里 —— 那四份 runtime 段本来就不一致
+ *   (dm / nurture 连 configPath 都没有),逐份加必然漏一份,而漏掉的那份会让
+ *   那个任务**永远启动失败**(配置与运行不匹配 → failed)。
+ */
+Engine.writeConfig = function (config, runId) {
     try {
         files.ensureDir(Engine.DIR + "/");
+        if (config) {
+            config.runtime = config.runtime || {};
+            config.runtime.runId = runId === undefined || runId === null ? "" : String(runId);
+        }
         files.write(Engine.CONFIG_PATH, JSON.stringify(config, null, 2));
         return true;
     } catch (e) {
@@ -3490,7 +3726,22 @@ Engine.launch = function (runId, scriptPath, opts) {
         if (!handle) {
             return { ok: false, error: "\u5F15\u64CE\u542F\u52A8\u8FD4\u56DE\u7A7A\uFF08\u53EF\u80FD\u88AB\u7CFB\u7EDF\u62E6\u622A\uFF09" };
         }
-        return { ok: true };
+
+        /* ★ 记下子引擎的身份,供 childAlive() 精确匹配。
+         *   `ScriptExecution.getEngine()` 的类型是 `ScriptEngine | null` ——
+         *   刚 execScriptFile 返回的那一瞬间,子引擎可能还没建好(真机上取到 null),
+         *   所以这里是"能拿到就拿,拿不到就算了":
+         *   取不到只会让判活退回旧的宽口径(任何第二个引擎都算活着),不会误判成已退出。 */
+        Engine.childId = "";
+        try {
+            var child = handle.getEngine ? handle.getEngine() : null;
+            if (child && child.id !== undefined && child.id !== null) {
+                Engine.childId = String(child.id);
+            }
+        } catch (childError) {
+            Engine.childId = "";
+        }
+        return { ok: true, childId: Engine.childId };
     } catch (e) {
         console.log("[engine] 唤起失败: " + e);
         return { ok: false, error: String(e) };
@@ -3544,14 +3795,39 @@ Engine.readState = function () {
     }
 };
 
+/** 读引擎心跳。没有 / 读坏了都返回 null(**绝不用文件时间戳判活**,stat 语义不可靠)。 */
+Engine.readBeat = function () {
+    try {
+        if (!files.exists(Engine.BEAT_PATH)) { return null; }
+        var parsed = JSON.parse(String(files.read(Engine.BEAT_PATH)));
+        return parsed && typeof parsed === "object" ? parsed : null;
+    } catch (e) {
+        return null;
+    }
+};
+
+/**
+ * 挑出属于本轮的心跳。
+ *
+ * 判定:`runId` 必须与本次运行**严格相等**。
+ * ★ 不等就是"还没有本轮心跳",**不等于"引擎不支持心跳"** ——
+ *   后者只由 `run-state.engine.capabilities.heartbeat` 决定。
+ *   把这两件事混在一起,会在引擎刚启动的那一两秒里误判成 v1。
+ */
+Engine.beatOf = function (beat, runId) {
+    if (!beat || !runId) { return null; }
+    if (String(beat.runId || "") !== String(runId)) { return null; }
+    return beat;
+};
+
 /** 运行中(含正在收尾)—— 界面据此继续显示状态条 */
 Engine.isActiveStatus = function (status) {
-    return status === "starting" || status === "running" || status === "stopping";
+    return Protocol.isActive(status);
 };
 
 /** 终态 —— 界面据此结束运行态并告知结果 */
 Engine.isTerminalStatus = function (status) {
-    return status === "finished" || status === "stopped" || status === "failed";
+    return Protocol.isTerminal(status);
 };
 
 /** 请求停止:写停止标志(内容 = runId,引擎比对通过才会停) */
@@ -3566,17 +3842,36 @@ Engine.requestStop = function (runId) {
     }
 };
 
+/** 上次 launch 到的子引擎 id;空串 = 没取到,按旧的"任意第二个引擎"判定 */
+Engine.childId = "";
+
 /**
  * 引擎是否还活着。
  *
  * 用途只有一个:**兜底判定"引擎没写终态就没了"**(被系统杀掉、被手工停掉)。
  * 查不出来时返回 true(当作还活着)—— 宁可多显示一会儿"运行中",
  * 也不要误判成"已结束"而把运行态清掉、让用户以为跑完了。
+ *
+ * ★ 优先按 `childId` 精确匹配:`engines.all()` 里出现**任意**第二个引擎都算活着
+ *   的说法太宽(用户自己开的别的脚本也算),那会让"子引擎静默消失"这条兜底
+ *   永远不触发。只有取不到 id 时才退回旧口径。
  */
 Engine.childAlive = function () {
     try {
         var mine = engines.myEngine();
         var all = engines.all() || [];
+        var wanted = Engine.childId;
+
+        if (wanted !== "") {
+            for (var k = 0; k < all.length; k += 1) {
+                var child = all[k];
+                if (child && String(child.id) === String(wanted)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
         for (var i = 0; i < all.length; i += 1) {
             var one = all[i];
             if (one && String(one.id) !== String(mine.id)) {
@@ -3635,6 +3930,46 @@ Session.stopping = false;
 /** 发出停止请求的时刻,用于超时兜底(引擎不确认也不能一直等)。 */
 Session.stopRequestedAt = 0;
 
+/* ---------------------------------------------------------------------------
+ * 握手字段(2026-11 新增,全部是**独立字段**,不改上面任何一个的语义)
+ *
+ * 老逻辑只有"运行中 / 正在收尾 / 结束"三档,中间那段
+ * 「点了开始但引擎还没起来」和「点了停止但引擎还没回话」是**模糊**的:
+ * 状态条说"运行中",可实际上什么都没在跑。这四个字段把它们拆开。
+ * ------------------------------------------------------------------------- */
+
+/**
+ * 还在等引擎的**启动确认**(收到本轮 run-state 或本轮心跳即为确认)。
+ *
+ * ★ 由 `Session.start()` 置 true —— 不能由 `App.startRun()` 置:
+ *   startRun 里的顺序是 launch(先) → Session.start(后),后者会把字段复位掉。
+ */
+Session.awaitingAck = false;
+
+/** 收到启动确认的时刻。0 = 还没收到。 */
+Session.ackedAt = 0;
+
+/**
+ * 本轮"开始等引擎"的时刻。握手超时从**它**算,不从 `startedAt` 算。
+ *
+ * 为什么单开一个:`startedAt` 是"用户点开始"的时刻,会被运行计时直接使用;
+ * 自检里为了验证计时格式会把它往前推几小时,若握手超时也用它,
+ * 一次"验证时长显示"的用例就会顺带把运行判成启动超时。
+ */
+Session.ackStartedAt = 0;
+
+/** 启动确认来得太慢(超过 startAckSoftMs)。只影响文案,不影响状态。 */
+Session.ackSlow = false;
+
+/** 最近一次**属于本轮**的心跳时刻。0 = 本轮还没收到过心跳。 */
+Session.beatAt = 0;
+
+/**
+ * 引擎确认收到停止请求的时刻(心跳里的 stopSeenAt,或它自己报了 stopping)。
+ * 0 = 还没确认 —— 状态条此时说「正在通知引擎」,确认后才说「正在收尾」。
+ */
+Session.stopAckedAt = 0;
+
 /* 计时器句柄。用 setInterval 而不是 threads.start:
  * UI 模式下 setInterval 的回调跑在主线程的 Looper 上,可以安全改 View;
  * 而 threads.start + ui.run 那条链路在真机上改 TextView 会崩
@@ -3659,6 +3994,14 @@ Session.start = function (tab, scriptKey, scriptName, runId) {
     Session.total = 0;
     Session.stopping = false;
     Session.stopRequestedAt = 0;
+    /* 新一次运行 = 还没握手成功。放在这里而不是 startRun:
+     * startRun 是 launch → Session.start 的顺序,放前面会被这里复位掉。 */
+    Session.awaitingAck = true;
+    Session.ackedAt = 0;
+    Session.ackStartedAt = Session.startedAt;
+    Session.ackSlow = false;
+    Session.beatAt = 0;
+    Session.stopAckedAt = 0;
 };
 
 Session.stop = function () {
@@ -3673,6 +4016,14 @@ Session.stop = function () {
     Session.total = 0;
     Session.stopping = false;
     Session.stopRequestedAt = 0;
+    /* ★ 与 start 成对:漏一个就会把上一轮的脏值带进下一次运行
+     *   (audit-values 的场景 E 会真的 start/stop,然后接着扫全页)。 */
+    Session.awaitingAck = false;
+    Session.ackedAt = 0;
+    Session.ackStartedAt = 0;
+    Session.ackSlow = false;
+    Session.beatAt = 0;
+    Session.stopAckedAt = 0;
 };
 
 /**
@@ -3694,7 +4045,22 @@ Session.barText = function () {
     var dup = short === "" || short === taskName || short.indexOf(taskName) >= 0;
     var head = dup ? taskName : (taskName + " \u00B7 " + short);
 
-    if (Session.stopping) { return head + " \u00B7 \u6B63\u5728\u6536\u5C3E"; }
+    /* ★ 顺序就是正确性,别调换:
+     *   ① stopping 必须排最前 —— 点了停止之后 regardless 其它字段,
+     *      显示的都应该是停止相关的文案("运行中"在那时说不过去);
+     *   ② stopping 内部再分两档:引擎还没回话时说「正在通知引擎」,
+     *      回了话说「正在收尾」—— 否则"已发出但没人应答"会被说成"正在收尾",
+     *      用户会以为它听见了;
+     *   ③ awaitingAck 排在 phase 之前:启动瞬间引擎可能已经写了
+     *      `phase: "启动应用"`,但那时候"启动中"比"启动应用"更准确。 */
+    if (Session.stopping) {
+        return head + " \u00B7 " +
+            (Session.stopAckedAt ? "\u6B63\u5728\u6536\u5C3E" : "\u6B63\u5728\u901A\u77E5\u5F15\u64CE");
+    }
+    if (Session.awaitingAck) {
+        return head + " \u00B7 " +
+            (Session.ackSlow ? "\u542F\u52A8\u4E2D\uFF08\u8F83\u6162\uFF09" : "\u542F\u52A8\u4E2D");
+    }
     if (Session.phase) { return head + " \u00B7 " + Session.phase; }
     return head + " \u00B7 \u8FD0\u884C\u4E2D";
 };
@@ -9304,16 +9670,19 @@ App.failBar = null;
 App.recordId = "";
 
 /**
- * 引擎"没写终态就没了"的判定宽限期。
+ * 引擎"没写终态就没了"的判定宽限期(**只有 v1 / 无心跳引擎用得上**)。
  * 引擎刚起来时还没出现在引擎列表里,头几秒不判定,免得刚启动就被判成已退出。
+ *
+ * 值来自 `Protocol.SPEC.thresholds.legacyChildGraceMs`:超时数字只有协议模块
+ * 一个来源,这里只是给它一个好读的名字,不再写字面量。
  */
-App.CHILD_GRACE_MS = 4000;
+App.CHILD_GRACE_MS = Engine.T.legacyChildGraceMs;
 
 /**
- * 发出停止请求后等引擎确认的上限。
+ * 发出停止请求后等引擎确认的上限(同样只给 v1 引擎兜底)。
  * 超时就当它已经停了:用户按了停止,界面不能一直卡在"正在收尾"上。
  */
-App.STOP_TIMEOUT_MS = 8000;
+App.STOP_TIMEOUT_MS = Engine.T.legacyStopTimeoutMs;
 
 /**
  * 本次运行的 runId,在唤起引擎之前就定下来。
@@ -9323,6 +9692,9 @@ App.STOP_TIMEOUT_MS = 8000;
  */
 App.pendingRunId = "";
 
+/** 本轮"引擎无心跳"这件事记过日志了吗(每轮只记一次,免得每秒刷屏) */
+App.tierLogged = false;
+
 /** 本次运行是否已提示过「引擎需要 TikTok 保持前台」。来回切窗口时只提醒一次。 */
 App.backgroundWarned = false;
 
@@ -9780,51 +10152,177 @@ App.tickTimer = function () {
 };
 
 /**
- * 读引擎上报的状态,驱动界面的运行态。
+ * 本轮引擎的协议档次。
+ *
+ * @param mine 本轮 run-state(可能为 null)
+ * @param beat 本轮心跳(可能为 null)
+ * @returns 'v1'      老引擎 → 只能靠 engines.all() 判活
+ *          'v2'      懂启动握手与心跳
+ *          'unknown' 本轮还没收到过任何 run-state
+ *
+ * ★ 判据是「run-state 里的 v」**或**「已经收到过本轮心跳」。
+ *   两者都是**正向证据**:有 v≥2 说明是新引擎,有心跳更是铁证。
+ *   反过来**不成立** —— 没有心跳不能当成 v1:新引擎起来后的头一两秒
+ *   同样没有心跳文件,那不是老引擎,是还没开始跳。这个方向搞反了,
+ *   每次启动都会被降级到 4 秒的旧宽限期,反而更容易误判退出。
+ */
+App.engineTier = function (mine, beat) {
+    if (beat) { return "v2"; }
+    if (!mine) { return "unknown"; }
+    return Number(mine.v) >= 2 ? "v2" : "v1";
+};
+
+/** 引擎自报"心跳线程起来了"吗?没报或报 false 都按"没有心跳"处理(阈值放大)。 */
+App.engineCapBeat = function (mine) {
+    return !!(mine && mine.engine && mine.engine.capabilities &&
+        mine.engine.capabilities.heartbeat === true);
+};
+
+/**
+ * 读引擎上报的状态与心跳,驱动界面的运行态。
  *
  * ★ 必须比对 runId:状态文件是设备上的共享文件,上一轮运行留下的内容还在。
  *   不比对就会出现「刚点开始运行,界面立刻显示上一轮的进度」。
+ *
+ * 四段,顺序不能换:
+ *   ① 终态——看到就立刻收,不等任何超时;
+ *   ② 启动握手——本轮还没收到任何消息时说"启动中",超时才判失败;
+ *   ③ 停止两段——先等"看见了停止请求",再等"真的收完尾";
+ *   ④ 常态——进度回填 + 判活。
  */
 App.pollEngine = function () {
+    var now = Date.now();
     var state = Engine.readState();
+    var mine = state && state.runId === Session.runId ? state : null;
+    var beat = Engine.beatOf(Engine.readBeat(), Session.runId);
+    var tier = App.engineTier(mine, beat);
+
+    /* —— ① 终态:最高优先,看到就收 —— */
+    if (mine && Engine.isTerminalStatus(mine.status)) {
+        Engine.sawChild = true;
+        App.finishRun(mine);
+        return;
+    }
 
-    if (state && state.runId === Session.runId) {
-        /* 看到过它,才有资格在后来说"它不见了" —— 见下面的兜底判定 */
-        if (Engine.childAlive()) { Engine.sawChild = true; }
+    /* —— ② 启动握手 —— */
+    if (Session.awaitingAck) {
+        if (mine || beat) {
+            Session.awaitingAck = false;
+            Session.ackedAt = now;
+            Session.ackSlow = false;
+            /* 每轮只记一次:排障时最想知道的就是"这台设备上跑的是哪一档协议" */
+            if (tier === "v1" && !App.tierLogged) {
+                App.tierLogged = true;
+                console.log("[app] 引擎没有心跳(协议 v1),按旧阈值判活");
+            }
+        } else {
+            var waited = now - (Session.ackStartedAt || Session.startedAt);
+            if (waited > Engine.T.startAckSoftMs && !Session.ackSlow) {
+                /* 只降文案、不改状态:慢一次很常见,不必吓人 */
+                Session.ackSlow = true;
+                console.log("[app] 引擎启动较慢,已等待 " + waited + "ms");
+            }
+            if (waited > Engine.T.startAckMaxMs) {
+                /* 迟到的引擎醒来后可能读到本轮配置就开跑。补写停止标志
+                 *(内容 = 本轮 runId),它一 checkStop 就会自己收手。
+                 * ★ 不要"顺手清掉"这个标志:下一轮的 runId 不同,自然失效。 */
+                Engine.requestStop(Session.runId);
+                App.finishRun({
+                    status: "failed",
+                    message: "启动超时(引擎没有响应)"
+                });
+                return;
+            }
+            /* 握手期不做判活:这时候"还没有心跳"是正常的,不代表死了 */
+            if (Engine.childAlive()) { Engine.sawChild = true; }
+            return;
+        }
+    }
 
-        if (Engine.isTerminalStatus(state.status)) {
-            App.finishRun(state);
+    /* —— ③ 停止:ack(看见请求)→ 收尾(写出终态)—— */
+    if (Session.stopping) {
+        if (tier === "v1") {
+            /* 老引擎没有心跳,只能用单段超时,就是改造前的那 8 秒 */
+            if (now - Session.stopRequestedAt > App.STOP_TIMEOUT_MS) {
+                App.finishRun({
+                    status: "stopped",
+                    message: "已发出停止请求(引擎未确认)"
+                });
+            }
             return;
         }
-        if (state.phase) { Session.phase = state.phase; }
-        Session.done = state.done || 0;
-        Session.total = state.total || 0;
-        if (state.status === "stopping") { Session.stopping = true; }
+
+        var seenAt = beat && beat.stopSeenAt ? Number(beat.stopSeenAt) : 0;
+        if (!Session.stopAckedAt && (seenAt > 0 || (mine && mine.status === "stopping"))) {
+            Session.stopAckedAt = now;
+        }
+        if (Session.stopRequestedAt <= 0) {
+            /* 引擎自己先报了 stopping:补一个起点,否则下面会算到 1970 年、
+             * 立刻误判成"已经停了" */
+            Session.stopRequestedAt = now;
+        }
+        var deadline = Session.stopAckedAt > 0
+            ? Session.stopAckedAt + Engine.T.stopFinishMs
+            : Session.stopRequestedAt + Engine.T.stopAckMs + Engine.T.stopFinishMs;
+        if (now > deadline) {
+            App.finishRun({
+                status: "stopped",
+                message: Session.stopAckedAt > 0
+                    ? "已停止(引擎未确认收尾)"
+                    : "已停止(引擎未确认收到请求)"
+            });
+        }
+        return;
+    }
+
+    /* —— ④ 常态:进度回填 —— */
+    if (mine) {
+        Engine.sawChild = true;
+        if (mine.phase) { Session.phase = mine.phase; }
+        Session.done = mine.done || 0;
+        Session.total = mine.total || 0;
+        if (mine.status === "stopping") { Session.stopping = true; }
     } else if (Engine.childAlive()) {
         /* 引擎在跑但还没写出状态:正常的启动间隙,什么都不做 */
         Engine.sawChild = true;
     }
 
-    /* —— 兜底一:点了停止但引擎迟迟不确认 —— */
-    if (Session.stopping && Date.now() - Session.stopRequestedAt > App.STOP_TIMEOUT_MS) {
+    if (beat) { Session.beatAt = Number(beat.beatAt) || now; }
+
+    /* —— 判活 —— */
+    if (tier === "v1") {
+        /* 老口径:只认 engines.all(),且"没看到过就不判",宁可多显示运行中 */
+        if (Engine.sawChild &&
+            now - Session.startedAt > App.CHILD_GRACE_MS &&
+            !Engine.childAlive()) {
+            App.finishRun({
+                status: "failed",
+                message: "引擎已退出(未收到结束信号)"
+            });
+        }
+        return;
+    }
+
+    if (Session.stopping) { return; }
+
+    var capBeat = App.engineCapBeat(mine);
+    var limitMs = capBeat ? Engine.T.beatDeadMs : Engine.T.legacyNoBeatDeadMs;
+    var accelMs = capBeat ? Engine.T.beatFreshMs : App.CHILD_GRACE_MS;
+    var anchor = Session.beatAt > 0 ? Session.beatAt : (Session.ackedAt || Session.startedAt);
+
+    if (now - anchor > limitMs) {
         App.finishRun({
-            status: "stopped",
-            message: "\u5DF2\u53D1\u51FA\u505C\u6B62\u8BF7\u6C42\uFF08\u5F15\u64CE\u672A\u786E\u8BA4\uFF09"
+            status: "failed",
+            message: "引擎已退出(未收到结束信号)"
         });
         return;
     }
-
-    /* —— 兜底二:引擎没写终态就没了(被系统杀掉 / 被人手工停掉)——
-     * 只在"确实看到过它、现在它不见了"时判定。`engines.all()` 在不同运行时里
-     * 的可见性不完全一致,没看到过就当作"查不出来",宁可多显示一会儿运行中,
-     * 也不要误判成已结束把运行态清掉。 */
-    if (!Session.stopping &&
-        Engine.sawChild &&
-        Date.now() - Session.startedAt > App.CHILD_GRACE_MS &&
-        !Engine.childAlive()) {
+    /* 加速判死:引擎列表里也没了,且心跳已经不新鲜 —— 不用等满 15 秒。
+     * ★ 反过来说:心跳新鲜时**绝不**因为 engines.all() 说没有就判死。 */
+    if (!Engine.childAlive() && now - anchor > accelMs) {
         App.finishRun({
             status: "failed",
-            message: "\u5F15\u64CE\u5DF2\u9000\u51FA\uFF08\u672A\u6536\u5230\u7ED3\u675F\u4FE1\u53F7\uFF09"
+            message: "引擎已退出(未收到结束信号)"
         });
     }
 };
@@ -10181,11 +10679,17 @@ App.startRun = function (task, opts) {
         toast("\u8FD0\u884C\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650");
         return;
     }
-    if (!Engine.writeConfig(plan.config)) {
+    if (!Engine.writeConfig(plan.config, App.pendingRunId)) {
         toast("\u5F15\u64CE\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650");
         return;
     }
 
+    /* 上一轮的子引擎 id 先清掉:万一这次唤起直接抛错,
+     * 留着旧 id 会让 childAlive() 一直返回 false,
+     * 于是新一轮刚起跑就被判成"引擎已退出"。 */
+    Engine.childId = "";
+    App.tierLogged = false;
+
     var launched = Engine.launch(App.pendingRunId, plan.scriptPath, opts);
     if (!launched.ok) {
         Dialog.engineUnavailable("\u5F15\u64CE\u542F\u52A8\u5931\u8D25\uFF1A" + launched.error);
@@ -10238,6 +10742,10 @@ App.stopRun = function () {
     if (Engine.requestStop(Session.runId)) {
         Session.stopping = true;
         Session.stopRequestedAt = Date.now();
+        /* ★ 新字段,不复用 stopRequestedAt:
+         *   "什么时候点的停止"和"引擎什么时候承认收到了"是两个时刻,
+         *   混用一个字段就没法区分"还没听见"和"听见了正在收尾"。 */
+        Session.stopAckedAt = 0;
         console.log("[app] 已发出停止请求 runId=" + Session.runId);
         return;
     }
@@ -10600,6 +11108,7 @@ if (typeof globalThis !== "undefined" && globalThis.__FEIZHOU_TEST__) {
         Session: Session,
         Resume: Resume,
         Fingerprint: Fingerprint,
+        Protocol: Protocol,
         W: W,
         Dialog: Dialog,
         Pages: Pages,

+ 95 - 6
tiktok-app/src/engine.js

@@ -3,11 +3,12 @@
  *
  * ## 为什么走文件,不走内存
  * 界面与被唤起的引擎是**两个独立的 ScriptEngine**:各有各的全局作用域,
- * 界面里的对象传不过去(实测确认)。所以两边只通过约定好的四个文件说话:
+ * 界面里的对象传不过去(实测确认)。所以两边只通过约定好的几个文件说话:
  *
  *   run-config.json     界面 → 人/追溯:完整配置快照(人可读)
  *   engine-config.json  界面 → 引擎:映射后的引擎配置
  *   run-state.json      引擎 → 界面:进度与终态
+ *   engine-beat.json    引擎 → 界面:心跳(每 2 秒一次,唯一写者是引擎的心跳线程)
  *   stop.flag           界面 → 引擎:停止请求(内容是本次运行的 runId)
  *
  * 文件通道是**异步**的,所以界面这边的规则是:**所有动作以状态文件的回报为准**,
@@ -34,6 +35,15 @@ Engine.CONFIG_PATH = Engine.DIR + "/engine-config.json";
 Engine.STATE_PATH = Engine.DIR + "/run-state.json";
 Engine.STOP_FLAG_PATH = Engine.DIR + "/stop.flag";
 
+/**
+ * 引擎心跳文件(引擎的心跳线程每 2 秒整份重写一次)。
+ *
+ * 为什么要和 run-state 分开:run-state 由引擎主流程按条目写,
+ * 心跳每 2 秒一次,两个写者写同一个文件必然互相覆盖。
+ * 有了它,界面才能区分「活着但正卡在一个 25 秒的 watch 里」和「已经死了」。
+ */
+Engine.BEAT_PATH = Engine.DIR + "/engine-beat.json";
+
 /**
  * 引擎的日志文件(jsonl,一行一条)。
  *
@@ -50,6 +60,14 @@ Engine.LOG_MAX_LINES = 200;
 /** 引擎产物在项目里的相对位置(项目模式下从这个位置找) */
 Engine.SCRIPT_RELATIVE = "tiktok-auto/dist/tiktok-auto.js";
 
+/**
+ * 协议阈值。唯一来源是 `src/protocol.js` ——
+ * 判活/握手/停止的所有超时都从这里取,**业务代码里不许再写字面量**。
+ * (改一个数忘了改另一个,会得到"ack 等得比心跳还短"这种永远等不到的怪状态,
+ *   `tools/protocol-check.js` 会把这种关系当场拦下。)
+ */
+Engine.T = Protocol.SPEC.thresholds;
+
 /** 本次运行的标识。由界面生成并同时告诉引擎与停止标志,三方比对。 */
 Engine.RUN_ID = "";
 
@@ -689,10 +707,22 @@ Engine.prepare = function (task, opts) {
     };
 };
 
-/** 把引擎配置落盘。返回是否成功。 */
-Engine.writeConfig = function (config) {
+/**
+ * 把引擎配置落盘。返回是否成功。
+ *
+ * `runId` 会在写入前塞进 `runtime.runId`:引擎拿它跟命令行里的 `--run-id` 比对,
+ * 挡住"上一轮的引擎迟到启动、却读到这一轮的配置并真的跑起来"。
+ * ★ 注入放在这里而不是四个 `buildXxxConfig` 里 —— 那四份 runtime 段本来就不一致
+ *   (dm / nurture 连 configPath 都没有),逐份加必然漏一份,而漏掉的那份会让
+ *   那个任务**永远启动失败**(配置与运行不匹配 → failed)。
+ */
+Engine.writeConfig = function (config, runId) {
     try {
         files.ensureDir(Engine.DIR + "/");
+        if (config) {
+            config.runtime = config.runtime || {};
+            config.runtime.runId = runId === undefined || runId === null ? "" : String(runId);
+        }
         files.write(Engine.CONFIG_PATH, JSON.stringify(config, null, 2));
         return true;
     } catch (e) {
@@ -729,7 +759,22 @@ Engine.launch = function (runId, scriptPath, opts) {
         if (!handle) {
             return { ok: false, error: "\u5F15\u64CE\u542F\u52A8\u8FD4\u56DE\u7A7A\uFF08\u53EF\u80FD\u88AB\u7CFB\u7EDF\u62E6\u622A\uFF09" };
         }
-        return { ok: true };
+
+        /* ★ 记下子引擎的身份,供 childAlive() 精确匹配。
+         *   `ScriptExecution.getEngine()` 的类型是 `ScriptEngine | null` ——
+         *   刚 execScriptFile 返回的那一瞬间,子引擎可能还没建好(真机上取到 null),
+         *   所以这里是"能拿到就拿,拿不到就算了":
+         *   取不到只会让判活退回旧的宽口径(任何第二个引擎都算活着),不会误判成已退出。 */
+        Engine.childId = "";
+        try {
+            var child = handle.getEngine ? handle.getEngine() : null;
+            if (child && child.id !== undefined && child.id !== null) {
+                Engine.childId = String(child.id);
+            }
+        } catch (childError) {
+            Engine.childId = "";
+        }
+        return { ok: true, childId: Engine.childId };
     } catch (e) {
         console.log("[engine] 唤起失败: " + e);
         return { ok: false, error: String(e) };
@@ -783,14 +828,39 @@ Engine.readState = function () {
     }
 };
 
+/** 读引擎心跳。没有 / 读坏了都返回 null(**绝不用文件时间戳判活**,stat 语义不可靠)。 */
+Engine.readBeat = function () {
+    try {
+        if (!files.exists(Engine.BEAT_PATH)) { return null; }
+        var parsed = JSON.parse(String(files.read(Engine.BEAT_PATH)));
+        return parsed && typeof parsed === "object" ? parsed : null;
+    } catch (e) {
+        return null;
+    }
+};
+
+/**
+ * 挑出属于本轮的心跳。
+ *
+ * 判定:`runId` 必须与本次运行**严格相等**。
+ * ★ 不等就是"还没有本轮心跳",**不等于"引擎不支持心跳"** ——
+ *   后者只由 `run-state.engine.capabilities.heartbeat` 决定。
+ *   把这两件事混在一起,会在引擎刚启动的那一两秒里误判成 v1。
+ */
+Engine.beatOf = function (beat, runId) {
+    if (!beat || !runId) { return null; }
+    if (String(beat.runId || "") !== String(runId)) { return null; }
+    return beat;
+};
+
 /** 运行中(含正在收尾)—— 界面据此继续显示状态条 */
 Engine.isActiveStatus = function (status) {
-    return status === "starting" || status === "running" || status === "stopping";
+    return Protocol.isActive(status);
 };
 
 /** 终态 —— 界面据此结束运行态并告知结果 */
 Engine.isTerminalStatus = function (status) {
-    return status === "finished" || status === "stopped" || status === "failed";
+    return Protocol.isTerminal(status);
 };
 
 /** 请求停止:写停止标志(内容 = runId,引擎比对通过才会停) */
@@ -805,17 +875,36 @@ Engine.requestStop = function (runId) {
     }
 };
 
+/** 上次 launch 到的子引擎 id;空串 = 没取到,按旧的"任意第二个引擎"判定 */
+Engine.childId = "";
+
 /**
  * 引擎是否还活着。
  *
  * 用途只有一个:**兜底判定"引擎没写终态就没了"**(被系统杀掉、被手工停掉)。
  * 查不出来时返回 true(当作还活着)—— 宁可多显示一会儿"运行中",
  * 也不要误判成"已结束"而把运行态清掉、让用户以为跑完了。
+ *
+ * ★ 优先按 `childId` 精确匹配:`engines.all()` 里出现**任意**第二个引擎都算活着
+ *   的说法太宽(用户自己开的别的脚本也算),那会让"子引擎静默消失"这条兜底
+ *   永远不触发。只有取不到 id 时才退回旧口径。
  */
 Engine.childAlive = function () {
     try {
         var mine = engines.myEngine();
         var all = engines.all() || [];
+        var wanted = Engine.childId;
+
+        if (wanted !== "") {
+            for (var k = 0; k < all.length; k += 1) {
+                var child = all[k];
+                if (child && String(child.id) === String(wanted)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
         for (var i = 0; i < all.length; i += 1) {
             var one = all[i];
             if (one && String(one.id) !== String(mine.id)) {

+ 172 - 29
tiktok-app/src/main.js

@@ -79,16 +79,19 @@ App.failBar = null;
 App.recordId = "";
 
 /**
- * 引擎"没写终态就没了"的判定宽限期。
+ * 引擎"没写终态就没了"的判定宽限期(**只有 v1 / 无心跳引擎用得上**)。
  * 引擎刚起来时还没出现在引擎列表里,头几秒不判定,免得刚启动就被判成已退出。
+ *
+ * 值来自 `Protocol.SPEC.thresholds.legacyChildGraceMs`:超时数字只有协议模块
+ * 一个来源,这里只是给它一个好读的名字,不再写字面量。
  */
-App.CHILD_GRACE_MS = 4000;
+App.CHILD_GRACE_MS = Engine.T.legacyChildGraceMs;
 
 /**
- * 发出停止请求后等引擎确认的上限。
+ * 发出停止请求后等引擎确认的上限(同样只给 v1 引擎兜底)。
  * 超时就当它已经停了:用户按了停止,界面不能一直卡在"正在收尾"上。
  */
-App.STOP_TIMEOUT_MS = 8000;
+App.STOP_TIMEOUT_MS = Engine.T.legacyStopTimeoutMs;
 
 /**
  * 本次运行的 runId,在唤起引擎之前就定下来。
@@ -98,6 +101,9 @@ App.STOP_TIMEOUT_MS = 8000;
  */
 App.pendingRunId = "";
 
+/** 本轮"引擎无心跳"这件事记过日志了吗(每轮只记一次,免得每秒刷屏) */
+App.tierLogged = false;
+
 /** 本次运行是否已提示过「引擎需要 TikTok 保持前台」。来回切窗口时只提醒一次。 */
 App.backgroundWarned = false;
 
@@ -555,51 +561,177 @@ App.tickTimer = function () {
 };
 
 /**
- * 读引擎上报的状态,驱动界面的运行态。
+ * 本轮引擎的协议档次。
+ *
+ * @param mine 本轮 run-state(可能为 null)
+ * @param beat 本轮心跳(可能为 null)
+ * @returns 'v1'      老引擎 → 只能靠 engines.all() 判活
+ *          'v2'      懂启动握手与心跳
+ *          'unknown' 本轮还没收到过任何 run-state
+ *
+ * ★ 判据是「run-state 里的 v」**或**「已经收到过本轮心跳」。
+ *   两者都是**正向证据**:有 v≥2 说明是新引擎,有心跳更是铁证。
+ *   反过来**不成立** —— 没有心跳不能当成 v1:新引擎起来后的头一两秒
+ *   同样没有心跳文件,那不是老引擎,是还没开始跳。这个方向搞反了,
+ *   每次启动都会被降级到 4 秒的旧宽限期,反而更容易误判退出。
+ */
+App.engineTier = function (mine, beat) {
+    if (beat) { return "v2"; }
+    if (!mine) { return "unknown"; }
+    return Number(mine.v) >= 2 ? "v2" : "v1";
+};
+
+/** 引擎自报"心跳线程起来了"吗?没报或报 false 都按"没有心跳"处理(阈值放大)。 */
+App.engineCapBeat = function (mine) {
+    return !!(mine && mine.engine && mine.engine.capabilities &&
+        mine.engine.capabilities.heartbeat === true);
+};
+
+/**
+ * 读引擎上报的状态与心跳,驱动界面的运行态。
  *
  * ★ 必须比对 runId:状态文件是设备上的共享文件,上一轮运行留下的内容还在。
  *   不比对就会出现「刚点开始运行,界面立刻显示上一轮的进度」。
+ *
+ * 四段,顺序不能换:
+ *   ① 终态——看到就立刻收,不等任何超时;
+ *   ② 启动握手——本轮还没收到任何消息时说"启动中",超时才判失败;
+ *   ③ 停止两段——先等"看见了停止请求",再等"真的收完尾";
+ *   ④ 常态——进度回填 + 判活。
  */
 App.pollEngine = function () {
+    var now = Date.now();
     var state = Engine.readState();
+    var mine = state && state.runId === Session.runId ? state : null;
+    var beat = Engine.beatOf(Engine.readBeat(), Session.runId);
+    var tier = App.engineTier(mine, beat);
 
-    if (state && state.runId === Session.runId) {
-        /* 看到过它,才有资格在后来说"它不见了" —— 见下面的兜底判定 */
-        if (Engine.childAlive()) { Engine.sawChild = true; }
+    /* —— ① 终态:最高优先,看到就收 —— */
+    if (mine && Engine.isTerminalStatus(mine.status)) {
+        Engine.sawChild = true;
+        App.finishRun(mine);
+        return;
+    }
 
-        if (Engine.isTerminalStatus(state.status)) {
-            App.finishRun(state);
+    /* —— ② 启动握手 —— */
+    if (Session.awaitingAck) {
+        if (mine || beat) {
+            Session.awaitingAck = false;
+            Session.ackedAt = now;
+            Session.ackSlow = false;
+            /* 每轮只记一次:排障时最想知道的就是"这台设备上跑的是哪一档协议" */
+            if (tier === "v1" && !App.tierLogged) {
+                App.tierLogged = true;
+                console.log("[app] 引擎没有心跳(协议 v1),按旧阈值判活");
+            }
+        } else {
+            var waited = now - (Session.ackStartedAt || Session.startedAt);
+            if (waited > Engine.T.startAckSoftMs && !Session.ackSlow) {
+                /* 只降文案、不改状态:慢一次很常见,不必吓人 */
+                Session.ackSlow = true;
+                console.log("[app] 引擎启动较慢,已等待 " + waited + "ms");
+            }
+            if (waited > Engine.T.startAckMaxMs) {
+                /* 迟到的引擎醒来后可能读到本轮配置就开跑。补写停止标志
+                 *(内容 = 本轮 runId),它一 checkStop 就会自己收手。
+                 * ★ 不要"顺手清掉"这个标志:下一轮的 runId 不同,自然失效。 */
+                Engine.requestStop(Session.runId);
+                App.finishRun({
+                    status: "failed",
+                    message: "启动超时(引擎没有响应)"
+                });
+                return;
+            }
+            /* 握手期不做判活:这时候"还没有心跳"是正常的,不代表死了 */
+            if (Engine.childAlive()) { Engine.sawChild = true; }
             return;
         }
-        if (state.phase) { Session.phase = state.phase; }
-        Session.done = state.done || 0;
-        Session.total = state.total || 0;
-        if (state.status === "stopping") { Session.stopping = true; }
+    }
+
+    /* —— ③ 停止:ack(看见请求)→ 收尾(写出终态)—— */
+    if (Session.stopping) {
+        if (tier === "v1") {
+            /* 老引擎没有心跳,只能用单段超时,就是改造前的那 8 秒 */
+            if (now - Session.stopRequestedAt > App.STOP_TIMEOUT_MS) {
+                App.finishRun({
+                    status: "stopped",
+                    message: "已发出停止请求(引擎未确认)"
+                });
+            }
+            return;
+        }
+
+        var seenAt = beat && beat.stopSeenAt ? Number(beat.stopSeenAt) : 0;
+        if (!Session.stopAckedAt && (seenAt > 0 || (mine && mine.status === "stopping"))) {
+            Session.stopAckedAt = now;
+        }
+        if (Session.stopRequestedAt <= 0) {
+            /* 引擎自己先报了 stopping:补一个起点,否则下面会算到 1970 年、
+             * 立刻误判成"已经停了" */
+            Session.stopRequestedAt = now;
+        }
+        var deadline = Session.stopAckedAt > 0
+            ? Session.stopAckedAt + Engine.T.stopFinishMs
+            : Session.stopRequestedAt + Engine.T.stopAckMs + Engine.T.stopFinishMs;
+        if (now > deadline) {
+            App.finishRun({
+                status: "stopped",
+                message: Session.stopAckedAt > 0
+                    ? "已停止(引擎未确认收尾)"
+                    : "已停止(引擎未确认收到请求)"
+            });
+        }
+        return;
+    }
+
+    /* —— ④ 常态:进度回填 —— */
+    if (mine) {
+        Engine.sawChild = true;
+        if (mine.phase) { Session.phase = mine.phase; }
+        Session.done = mine.done || 0;
+        Session.total = mine.total || 0;
+        if (mine.status === "stopping") { Session.stopping = true; }
     } else if (Engine.childAlive()) {
         /* 引擎在跑但还没写出状态:正常的启动间隙,什么都不做 */
         Engine.sawChild = true;
     }
 
-    /* —— 兜底一:点了停止但引擎迟迟不确认 —— */
-    if (Session.stopping && Date.now() - Session.stopRequestedAt > App.STOP_TIMEOUT_MS) {
+    if (beat) { Session.beatAt = Number(beat.beatAt) || now; }
+
+    /* —— 判活 —— */
+    if (tier === "v1") {
+        /* 老口径:只认 engines.all(),且"没看到过就不判",宁可多显示运行中 */
+        if (Engine.sawChild &&
+            now - Session.startedAt > App.CHILD_GRACE_MS &&
+            !Engine.childAlive()) {
+            App.finishRun({
+                status: "failed",
+                message: "引擎已退出(未收到结束信号)"
+            });
+        }
+        return;
+    }
+
+    if (Session.stopping) { return; }
+
+    var capBeat = App.engineCapBeat(mine);
+    var limitMs = capBeat ? Engine.T.beatDeadMs : Engine.T.legacyNoBeatDeadMs;
+    var accelMs = capBeat ? Engine.T.beatFreshMs : App.CHILD_GRACE_MS;
+    var anchor = Session.beatAt > 0 ? Session.beatAt : (Session.ackedAt || Session.startedAt);
+
+    if (now - anchor > limitMs) {
         App.finishRun({
-            status: "stopped",
-            message: "\u5DF2\u53D1\u51FA\u505C\u6B62\u8BF7\u6C42\uFF08\u5F15\u64CE\u672A\u786E\u8BA4\uFF09"
+            status: "failed",
+            message: "引擎已退出(未收到结束信号)"
         });
         return;
     }
-
-    /* —— 兜底二:引擎没写终态就没了(被系统杀掉 / 被人手工停掉)——
-     * 只在"确实看到过它、现在它不见了"时判定。`engines.all()` 在不同运行时里
-     * 的可见性不完全一致,没看到过就当作"查不出来",宁可多显示一会儿运行中,
-     * 也不要误判成已结束把运行态清掉。 */
-    if (!Session.stopping &&
-        Engine.sawChild &&
-        Date.now() - Session.startedAt > App.CHILD_GRACE_MS &&
-        !Engine.childAlive()) {
+    /* 加速判死:引擎列表里也没了,且心跳已经不新鲜 —— 不用等满 15 秒。
+     * ★ 反过来说:心跳新鲜时**绝不**因为 engines.all() 说没有就判死。 */
+    if (!Engine.childAlive() && now - anchor > accelMs) {
         App.finishRun({
             status: "failed",
-            message: "\u5F15\u64CE\u5DF2\u9000\u51FA\uFF08\u672A\u6536\u5230\u7ED3\u675F\u4FE1\u53F7\uFF09"
+            message: "引擎已退出(未收到结束信号)"
         });
     }
 };
@@ -956,11 +1088,17 @@ App.startRun = function (task, opts) {
         toast("\u8FD0\u884C\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650");
         return;
     }
-    if (!Engine.writeConfig(plan.config)) {
+    if (!Engine.writeConfig(plan.config, App.pendingRunId)) {
         toast("\u5F15\u64CE\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650");
         return;
     }
 
+    /* 上一轮的子引擎 id 先清掉:万一这次唤起直接抛错,
+     * 留着旧 id 会让 childAlive() 一直返回 false,
+     * 于是新一轮刚起跑就被判成"引擎已退出"。 */
+    Engine.childId = "";
+    App.tierLogged = false;
+
     var launched = Engine.launch(App.pendingRunId, plan.scriptPath, opts);
     if (!launched.ok) {
         Dialog.engineUnavailable("\u5F15\u64CE\u542F\u52A8\u5931\u8D25\uFF1A" + launched.error);
@@ -1013,6 +1151,10 @@ App.stopRun = function () {
     if (Engine.requestStop(Session.runId)) {
         Session.stopping = true;
         Session.stopRequestedAt = Date.now();
+        /* ★ 新字段,不复用 stopRequestedAt:
+         *   "什么时候点的停止"和"引擎什么时候承认收到了"是两个时刻,
+         *   混用一个字段就没法区分"还没听见"和"听见了正在收尾"。 */
+        Session.stopAckedAt = 0;
         console.log("[app] 已发出停止请求 runId=" + Session.runId);
         return;
     }
@@ -1375,6 +1517,7 @@ if (typeof globalThis !== "undefined" && globalThis.__FEIZHOU_TEST__) {
         Session: Session,
         Resume: Resume,
         Fingerprint: Fingerprint,
+        Protocol: Protocol,
         W: W,
         Dialog: Dialog,
         Pages: Pages,

+ 203 - 0
tiktok-app/src/protocol.js

@@ -0,0 +1,203 @@
+/* ===========================================================================
+ * protocol.js —— 界面 ↔ 引擎 的运行期协议(唯一事实来源)
+ *
+ * 这个文件解决的是"两边各写一套枚举、靠魔法数字猜死活"的老问题。
+ * 它只放**纯数据 + 纯判定**:不读文件、不写文件、不起线程、不看时间。
+ * 所以界面侧与引擎侧可以各存一份完整拷贝,行为完全一致。
+ *
+ * ★ 两份拷贝的纪律:`tiktok-app/src/protocol.js` 与
+ *   `tiktok-auto/src/protocol.js` **除第 1 行(挂载点)外必须逐字节相同**。
+ *   界面侧是 `var Protocol = …`,引擎侧是 `TikTokAuto.protocol = …`。
+ *   `tiktok-app/tools/protocol-check.js` 会剥掉第 1 行后逐字节比对,
+ *   任何一边单独改都会立刻被抓出来。
+ *
+ * ★ 为什么状态判定要放在这里而不是各自实现:
+ *   `isTerminalStatus` 曾经只存在于界面(engine.js),引擎侧自己写
+ *   `'finished' / 'stopped' / 'failed'` 三元推导(main.js)。两边漏改一个
+ *   就会出现"引擎以为结束了、界面还在等"这种零报错的悬挂。
+ * =========================================================================== */
+
+var Protocol = (function () {
+    'use strict';
+
+    /** 协议版本。加字段不改语义不用抬;改语义 / 改判定规则必须抬。 */
+    var VERSION = 2;
+
+    /* ------------------------------------------------------------ 状态枚举 */
+
+    var STATUS = {
+        STARTING: 'starting',
+        RUNNING: 'running',
+        STOPPING: 'stopping',
+        FINISHED: 'finished',
+        STOPPED: 'stopped',
+        FAILED: 'failed'
+    };
+
+    /** 终态:到了这里就没有出边(再回到活跃态一律算 regress)。 */
+    var TERMINAL = { finished: true, stopped: true, failed: true };
+
+    /** 活跃态:运行条要显示、判活逻辑要关心的三档。 */
+    var ACTIVE = { starting: true, running: true, stopping: true };
+
+    /**
+     * 合法迁移表:from → 允许去的 to。
+     *
+     * 空串代表"还没有状态"(本轮的第一次上报)。
+     * `starting` 的出边特别宽:配置无效会在启动瞬间直接 failed,
+     * 极短任务可能 starting 之后直接终态,这两种都是真实的。
+     */
+    var EDGES = {
+        '': { starting: true },
+        starting: {
+            starting: true,
+            running: true,
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        running: {
+            running: true,
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        stopping: {
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        finished: {},
+        stopped: {},
+        failed: {}
+    };
+
+    /* ------------------------------------------------------------ 阈值 */
+
+    /**
+     * 所有超时都在这里,界面侧从 `Protocol.SPEC.thresholds` 取,
+     * 引擎侧心跳间隔也从这里取 —— 不允许在业务代码里再写字面量。
+     *
+     * 语义补充(光看名字看不出来的):
+     *   · beatFreshMs  只用于"加速判死"(见界面 pollEngine),**不产生文案**
+     *   · startAckSoftMs 只是日志与文案降级(「启动中(较慢)」),不改状态
+     *   · stopAckMs / stopFinishMs 是两段:先等"看见了停止请求",再等"真的收完尾"
+     *   · legacy* 是无心跳(v1)引擎的回退值,就是改造前在用的数字
+     */
+    var THRESHOLDS = {
+        /* 心跳 */
+        beatIntervalMs: 2000,
+        beatFreshMs: 8000,
+        beatDeadMs: 15000,
+        /* 启动握手 */
+        startAckSoftMs: 6000,
+        startAckMaxMs: 15000,
+        /* 停止两段 */
+        stopAckMs: 3000,
+        stopFinishMs: 10000,
+        /* v1 回退组(老引擎 / 心跳线程起不来) */
+        legacyChildGraceMs: 4000,
+        legacyStopTimeoutMs: 8000,
+        legacyNoBeatDeadMs: 30000
+    };
+
+    var SPEC = {
+        version: VERSION,
+        status: STATUS,
+        terminal: TERMINAL,
+        active: ACTIVE,
+        edges: EDGES,
+        thresholds: THRESHOLDS
+    };
+
+    /* ------------------------------------------------------------ 工具 */
+
+    /* 单文件拼接后所有模块共享一个作用域,而 `Object.prototype.hasOwnProperty`
+     * 的 call 形式会命中引擎侧构建黑名单里的那种写法(连注释都会被扫到),
+     * 所以这里统一走 apply。 */
+    function owns(object, key) {
+        return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
+    }
+
+    /** 把任意输入变成字符串状态;null / undefined → 空串(= 还没有状态)。 */
+    function statusOf(value) {
+        if (value === undefined || value === null) {
+            return '';
+        }
+        return String(value);
+    }
+
+    /* ------------------------------------------------------------ 判定 */
+
+    /**
+     * 是否终态。
+     * ★ 未知状态一律返回 false —— 界面绝不能因为读到一个不认识的 status
+     *   就把正在跑的运行结束掉(前向兼容第一条)。
+     */
+    function isTerminal(status) {
+        var key = statusOf(status);
+        return owns(TERMINAL, key) === true && TERMINAL[key] === true;
+    }
+
+    /** 是否活跃态(运行条要显示的那三档)。未知状态同样返回 false。 */
+    function isActive(status) {
+        var key = statusOf(status);
+        return owns(ACTIVE, key) === true && ACTIVE[key] === true;
+    }
+
+    /**
+     * 三分类,给日志和排障用。
+     * @returns 'active' | 'terminal' | 'unknown'
+     */
+    function levelOf(status) {
+        var key = statusOf(status);
+        if (isTerminal(key)) {
+            return 'terminal';
+        }
+        if (isActive(key)) {
+            return 'active';
+        }
+        return 'unknown';
+    }
+
+    /**
+     * 迁移合法性。
+     *
+     * @returns 'ok'         表里有的合法迁移
+     *          'idempotent' 原地重复上报(引擎允许重复写同一个状态)
+     *          'regress'    从终态往外走 —— **必须拒绝**
+     *          'unknown'    表里没有的组合,多半是前向兼容来的新状态,
+     *                       调用方应当"放宽"(不据此结束运行),
+     *                       只在日志里留一句
+     */
+    function legality(from, to) {
+        var a = statusOf(from);
+        var b = statusOf(to);
+        if (a === b) {
+            return 'idempotent';
+        }
+        if (isTerminal(a)) {
+            return 'regress';
+        }
+        if (owns(EDGES, a) === true && owns(EDGES[a], b) === true) {
+            return 'ok';
+        }
+        return 'unknown';
+    }
+
+    /* ------------------------------------------------------------ 导出 */
+
+    return {
+        VERSION: VERSION,
+        STATUS: STATUS,
+        SPEC: SPEC,
+        statusOf: statusOf,
+        isTerminal: isTerminal,
+        isActive: isActive,
+        levelOf: levelOf,
+        legality: legality
+    };
+})();

+ 72 - 1
tiktok-app/src/session.js

@@ -37,6 +37,46 @@ Session.stopping = false;
 /** 发出停止请求的时刻,用于超时兜底(引擎不确认也不能一直等)。 */
 Session.stopRequestedAt = 0;
 
+/* ---------------------------------------------------------------------------
+ * 握手字段(2026-11 新增,全部是**独立字段**,不改上面任何一个的语义)
+ *
+ * 老逻辑只有"运行中 / 正在收尾 / 结束"三档,中间那段
+ * 「点了开始但引擎还没起来」和「点了停止但引擎还没回话」是**模糊**的:
+ * 状态条说"运行中",可实际上什么都没在跑。这四个字段把它们拆开。
+ * ------------------------------------------------------------------------- */
+
+/**
+ * 还在等引擎的**启动确认**(收到本轮 run-state 或本轮心跳即为确认)。
+ *
+ * ★ 由 `Session.start()` 置 true —— 不能由 `App.startRun()` 置:
+ *   startRun 里的顺序是 launch(先) → Session.start(后),后者会把字段复位掉。
+ */
+Session.awaitingAck = false;
+
+/** 收到启动确认的时刻。0 = 还没收到。 */
+Session.ackedAt = 0;
+
+/**
+ * 本轮"开始等引擎"的时刻。握手超时从**它**算,不从 `startedAt` 算。
+ *
+ * 为什么单开一个:`startedAt` 是"用户点开始"的时刻,会被运行计时直接使用;
+ * 自检里为了验证计时格式会把它往前推几小时,若握手超时也用它,
+ * 一次"验证时长显示"的用例就会顺带把运行判成启动超时。
+ */
+Session.ackStartedAt = 0;
+
+/** 启动确认来得太慢(超过 startAckSoftMs)。只影响文案,不影响状态。 */
+Session.ackSlow = false;
+
+/** 最近一次**属于本轮**的心跳时刻。0 = 本轮还没收到过心跳。 */
+Session.beatAt = 0;
+
+/**
+ * 引擎确认收到停止请求的时刻(心跳里的 stopSeenAt,或它自己报了 stopping)。
+ * 0 = 还没确认 —— 状态条此时说「正在通知引擎」,确认后才说「正在收尾」。
+ */
+Session.stopAckedAt = 0;
+
 /* 计时器句柄。用 setInterval 而不是 threads.start:
  * UI 模式下 setInterval 的回调跑在主线程的 Looper 上,可以安全改 View;
  * 而 threads.start + ui.run 那条链路在真机上改 TextView 会崩
@@ -61,6 +101,14 @@ Session.start = function (tab, scriptKey, scriptName, runId) {
     Session.total = 0;
     Session.stopping = false;
     Session.stopRequestedAt = 0;
+    /* 新一次运行 = 还没握手成功。放在这里而不是 startRun:
+     * startRun 是 launch → Session.start 的顺序,放前面会被这里复位掉。 */
+    Session.awaitingAck = true;
+    Session.ackedAt = 0;
+    Session.ackStartedAt = Session.startedAt;
+    Session.ackSlow = false;
+    Session.beatAt = 0;
+    Session.stopAckedAt = 0;
 };
 
 Session.stop = function () {
@@ -75,6 +123,14 @@ Session.stop = function () {
     Session.total = 0;
     Session.stopping = false;
     Session.stopRequestedAt = 0;
+    /* ★ 与 start 成对:漏一个就会把上一轮的脏值带进下一次运行
+     *   (audit-values 的场景 E 会真的 start/stop,然后接着扫全页)。 */
+    Session.awaitingAck = false;
+    Session.ackedAt = 0;
+    Session.ackStartedAt = 0;
+    Session.ackSlow = false;
+    Session.beatAt = 0;
+    Session.stopAckedAt = 0;
 };
 
 /**
@@ -96,7 +152,22 @@ Session.barText = function () {
     var dup = short === "" || short === taskName || short.indexOf(taskName) >= 0;
     var head = dup ? taskName : (taskName + " \u00B7 " + short);
 
-    if (Session.stopping) { return head + " \u00B7 \u6B63\u5728\u6536\u5C3E"; }
+    /* ★ 顺序就是正确性,别调换:
+     *   ① stopping 必须排最前 —— 点了停止之后 regardless 其它字段,
+     *      显示的都应该是停止相关的文案("运行中"在那时说不过去);
+     *   ② stopping 内部再分两档:引擎还没回话时说「正在通知引擎」,
+     *      回了话说「正在收尾」—— 否则"已发出但没人应答"会被说成"正在收尾",
+     *      用户会以为它听见了;
+     *   ③ awaitingAck 排在 phase 之前:启动瞬间引擎可能已经写了
+     *      `phase: "启动应用"`,但那时候"启动中"比"启动应用"更准确。 */
+    if (Session.stopping) {
+        return head + " \u00B7 " +
+            (Session.stopAckedAt ? "\u6B63\u5728\u6536\u5C3E" : "\u6B63\u5728\u901A\u77E5\u5F15\u64CE");
+    }
+    if (Session.awaitingAck) {
+        return head + " \u00B7 " +
+            (Session.ackSlow ? "\u542F\u52A8\u4E2D\uFF08\u8F83\u6162\uFF09" : "\u542F\u52A8\u4E2D");
+    }
     if (Session.phase) { return head + " \u00B7 " + Session.phase; }
     return head + " \u00B7 \u8FD0\u884C\u4E2D";
 };

+ 7 - 1
tiktok-app/tools/android-stub.js

@@ -571,7 +571,13 @@ function install() {
         all: () => [{ id: 'host-1' }].concat(engineState.children),
         execScriptFile: (scriptPath, config) => {
             engineState.spawned.push({ path: scriptPath, config: config });
-            return { getEngine: () => null };
+            /* ★ 真机的 `ScriptExecution.getEngine()` 会返回子引擎对象(也可能是 null,
+             *   取决于子引擎建好了没)。这里返回 `children[0]` 是为了让
+             *   `Engine.childId` 精确匹配那条路径被测到 —— 否则永远走"任意第二个引擎"
+             *   的宽口径,精确匹配等于没测。 */
+            return {
+                getEngine: () => engineState.children[0] || null
+            };
         }
     };
 

+ 26 - 0
tiktok-app/tools/build.js

@@ -45,6 +45,7 @@ const MODULE_ORDER = [
     'device',
     'service',
     'fingerprint',  // 配置指纹:与 tiktok-auto/src/fingerprint.js 同算法(断点续跑用)
+    'protocol',     // 运行期协议:状态枚举/迁移表/阈值,引擎侧有一份同样的拷贝(见 tools/protocol-check.js)
     'engine',       // 执行引擎桥接:界面与 tiktok-auto 之间的文件通道
     'session',
     'resume',       // 断点续跑:读断点/判断能否续/清空(只读引擎写的进度文件)
@@ -103,6 +104,7 @@ function build() {
     assertUiDirective(source);
     assertSyntax(source);
     assertNoLeftoverMarkers(source);
+    assertProtocolPresent(source);
 
     return { source, modules: MODULE_ORDER };
 }
@@ -135,6 +137,30 @@ function assertNoLeftoverMarkers(source) {
     }
 }
 
+/**
+ * 协议模块必须真的进了产物。
+ *
+ * ★ 这是补一个**静默失败**:`build()` 只遍历 `MODULE_ORDER`,
+ *   新建 `src/protocol.js` 却忘了加进清单时,构建会**成功**,
+ *   产物里根本没有它,直到运行期 `Protocol is not defined` 才炸。
+ *   同理,忘了往 `__FEIZHOU__` 白名单里加,`smoke.js` 拿到的是 undefined,
+ *   那一整批协议断言会被"跳过"而不是失败 —— 这里一起挡住。
+ */
+function assertProtocolPresent(source) {
+    /* 挂载行就是"这个文件被拼进来了"的证据:产物里没有它,
+     * 说明 MODULE_ORDER 漏了 protocol,或者文件被改名了 —— 两种都要当场拦。 */
+    if (source.indexOf('var Protocol = (function () {') < 0) {
+        throw new Error(
+            '构建产物里没有 Protocol 模块:src/protocol.js 没进 MODULE_ORDER?'
+        );
+    }
+    if (source.indexOf('Protocol: Protocol') < 0) {
+        throw new Error(
+            '构建产物里 __FEIZHOU__ 没有暴露 Protocol:PC 自检将无法断言协议模块'
+        );
+    }
+}
+
 /**
  * 项目入口检查(本界面是**整个 AutoX 项目的启动入口**)。
  *

+ 42 - 3
tiktok-app/tools/fake-engine.js

@@ -1,11 +1,15 @@
 /**
- * 假引擎:模拟「长时间运行 + 能被叫停」,**完全不碰 TikTok**。
+ * 假引擎:模拟「长时间运行 + 能被叫停 + 会跳心跳」,**完全不碰 TikTok**。
  *
- * 用途:验证界面侧的运行态交互(状态条、进度、两步停止)。
+ * 用途:验证界面侧的运行态交互(启动握手、状态条、进度、两段停止、心跳判活)。
  * 用真引擎验证这些时,它会把 TikTok 拉到前台,我就没法同时操作界面上的按钮了 ——
  * 而这个假引擎不切应用,界面始终留在前台,正好把 UI 交互测干净。
  *
- * 协议与真引擎完全一致:读 --config/--state/--stop-flag/--run-id,写 run-state.json。
+ * 协议与真引擎完全一致:读 --config/--state/--stop-flag/--run-id,
+ * 写 run-state.json(带 v / engine.capabilities)与 engine-beat.json。
+ *
+ * ★ 心跳不能省:界面在 v2 档位下是用心跳判活的。这个假引擎要是不跳,
+ *   跑起来 15 秒就会被界面判成"引擎已退出" —— 最常用的联调手段反而成了误报源。
  */
 
 function argOf(argv, key) {
@@ -20,12 +24,40 @@ var STATE = argOf(argv, "--state");
 var STOP = argOf(argv, "--stop-flag");
 var RUN_ID = argOf(argv, "--run-id");
 
+/* 心跳文件跟 run-state 放同一目录(真引擎也是这么约定的),
+ * 这样不必额外传一个 --beat 参数,命令行协议保持 5 个参数不变。 */
+var slash = STATE.lastIndexOf("/");
+var BEAT = (slash >= 0 ? STATE.substring(0, slash) : "") + "/engine-beat.json";
+
+var seq = 0;
+var seenAt = 0;
+
 function report(payload) {
+    payload.v = 2;
     payload.runId = RUN_ID;
+    payload.engine = payload.engine || { task: "fake", capabilities: { heartbeat: true } };
     payload.updatedAt = Date.now();
     files.write(STATE, JSON.stringify(payload));
 }
 
+/* 心跳必须是**独立线程**:主流程会 sleep(300) 轮询,
+ * 若把心跳写在同一循环里,一旦某一步卡住心跳就跟着停 —— 那就失去意义了。
+ * (真引擎侧也是同样的理由,见 tiktok-auto/src/bridge.js 的 heartbeatStart) */
+var beating = true;
+threads.start(function () {
+    while (beating) {
+        seq += 1;
+        try {
+            files.write(BEAT, JSON.stringify({
+                v: 2, runId: RUN_ID, seq: seq, beatAt: Date.now(), stopSeenAt: seenAt
+            }));
+        } catch (e) { }
+        sleep(2000);
+    }
+});
+
+report({ status: "starting", phase: "假引擎启动中", done: 0, total: 3 });
+sleep(600);
 report({ status: "running", phase: "假引擎运行中", done: 0, total: 3 });
 
 var began = Date.now();
@@ -48,10 +80,17 @@ while (Date.now() - began < 60000) {
     } catch (e) { }
 
     if (content !== "" && content === RUN_ID) {
+        /* 先承认收到(sticky 的 stopSeenAt,界面靠它把"正在通知引擎"换成"正在收尾"),
+         * 再走一段"收尾中"的过场 —— 真实引擎也是这样,不会立刻消失。 */
+        seenAt = Date.now();
+        report({ status: "stopping", phase: "正在收尾", message: "收到停止请求(假引擎)" });
+        sleep(1200);
+        beating = false;
         report({ status: "stopped", phase: "", message: "已停止(假引擎)" });
         exit();
     }
     sleep(300);
 }
 
+beating = false;
 report({ status: "finished", phase: "", done: 3, total: 3, message: "跑完了(假引擎)" });

+ 248 - 0
tiktok-app/tools/protocol-check.js

@@ -0,0 +1,248 @@
+#!/usr/bin/env node
+
+/* ===========================================================================
+ * protocol-check.js —— 两侧协议模块的一致性 / 自洽性检查(PC 侧)
+ *
+ * 为什么需要它:`tiktok-app/src/protocol.js` 与 `tiktok-auto/src/protocol.js`
+ * 是**两份实体文件**(两个独立产物,做不了共享模块)。任何一边被单独改动,
+ * 就会出现"界面认为 stopped 是终态、引擎认为不是"这种零报错的悬挂。
+ * 这里在 PC 上把它们分别加载进**两个独立沙箱**,逐项比对。
+ *
+ * 用法:
+ *   $NODE tools/protocol-check.js      # 期望最后一行:PROTOCOL_CLEAN
+ * =========================================================================== */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const ROOT = path.join(__dirname, '..', '..');
+const APP_FILE = path.join(ROOT, 'tiktok-app', 'src', 'protocol.js');
+const AUTO_FILE = path.join(ROOT, 'tiktok-auto', 'src', 'protocol.js');
+
+/* 两侧唯一允许的差异行(挂载点) */
+const APP_MOUNT = 'var Protocol = (function () {';
+const AUTO_MOUNT = 'TikTokAuto.protocol = (function () {';
+
+let passed = 0;
+const failures = [];
+
+function check(name, condition, detail) {
+    if (condition) {
+        passed += 1;
+        return;
+    }
+    failures.push(name + (detail ? '  → ' + detail : ''));
+}
+
+function eq(name, actual, expected) {
+    check(name, actual === expected, `期望 ${expected},实际 ${actual}`);
+}
+
+/* ------------------------------------------------------------ 加载两侧 */
+
+function load(source) {
+    const sandbox = {
+        JSON: JSON,
+        String: String,
+        Number: Number,
+        Object: Object,
+        Array: Array,
+        Boolean: Boolean,
+        Error: Error,
+        TikTokAuto: {}
+    };
+    vm.createContext(sandbox);
+    vm.runInContext(source, sandbox, { filename: 'protocol.js' });
+    return sandbox;
+}
+
+const appSource = fs.readFileSync(APP_FILE, 'utf8');
+const autoSource = fs.readFileSync(AUTO_FILE, 'utf8');
+
+const appBox = load(appSource);
+const autoBox = load(autoSource);
+
+const A = appBox.Protocol;
+const B = autoBox.TikTokAuto.protocol;
+
+check('界面侧 protocol.js 挂载出了 Protocol', !!A);
+check('引擎侧 protocol.js 挂载出了 TikTokAuto.protocol', !!B);
+if (!A || !B) {
+    console.log('');
+    console.log(`通过 ${passed} 项`);
+    failures.forEach((f) => console.log('  ✗ ' + f));
+    console.log('');
+    console.log('PROTOCOL_FAILED');
+    process.exit(1);
+}
+
+/* ------------------------------------------------- 1. 两份内容必须一致 */
+
+/* 只允许「挂载点那一行」不同;其余逐字节相同。 */
+const appBody = appSource.split(APP_MOUNT).join('<MOUNT>');
+const autoBody = autoSource.split(AUTO_MOUNT).join('<MOUNT>');
+
+check('两侧文件除挂载点外逐字节相同', appBody === autoBody,
+    firstDiff(appBody, autoBody));
+
+eq('两侧挂载点各出现一次', appSource.split(APP_MOUNT).length, 2);
+eq('引擎侧不含界面侧的挂载点', autoSource.indexOf(APP_MOUNT), -1);
+eq('界面侧不含引擎侧的挂载点', appSource.indexOf(AUTO_MOUNT), -1);
+
+eq('两侧 VERSION 相同', A.VERSION, B.VERSION);
+check('两侧 SPEC 序列化后相同',
+    JSON.stringify(A.SPEC) === JSON.stringify(B.SPEC), 'SPEC 不一致');
+
+/* ------------------------------------------------ 2. 枚举与迁移表自洽 */
+
+/* STATUS 是「常量名 → 状态值」的映射,这里要的是**状态值**。 */
+const STATUS_VALUES = Object.keys(A.SPEC.status).map((k) => A.SPEC.status[k]);
+const TERMINAL = A.SPEC.terminal;
+const EDGES = A.SPEC.edges;
+
+eq('状态枚举是 6 档', STATUS_VALUES.length, 6);
+
+/* 迁移表里出现的每个 from(空串除外)都必须是已知状态 */
+const unknownFroms = Object.keys(EDGES).filter(
+    (k) => k !== '' && STATUS_VALUES.indexOf(k) < 0
+);
+check('迁移表的 from 都在枚举内', unknownFroms.length === 0, unknownFroms.join(','));
+
+/* 迁移表里出现的每个 to 都必须是已知状态 */
+const badTos = [];
+Object.keys(EDGES).forEach((from) => {
+    Object.keys(EDGES[from]).forEach((to) => {
+        if (STATUS_VALUES.indexOf(to) < 0) {
+            badTos.push(from + '→' + to);
+        }
+    });
+});
+check('迁移表的 to 都在枚举内', badTos.length === 0, badTos.join(','));
+
+/* 终态必须没有出边 —— 这是"终态"的定义 */
+const leaky = Object.keys(TERMINAL).filter((t) => Object.keys(EDGES[t] || {}).length > 0);
+check('终态没有出边', leaky.length === 0, leaky.join(','));
+
+/* 终态往外走必须判 regress,不能判 unknown */
+const regressMiss = Object.keys(TERMINAL).filter(
+    (t) => A.legality(t, 'running') !== 'regress'
+);
+check('终态 → running 判 regress', regressMiss.length === 0, regressMiss.join(','));
+eq('legality(stopped, running)', A.legality('stopped', 'running'), 'regress');
+eq('legality(running, running) 是幂等', A.legality('running', 'running'), 'idempotent');
+eq('legality(running, stopping) 合法', A.legality('running', 'stopping'), 'ok');
+eq('legality(空, starting) 合法', A.legality('', 'starting'), 'ok');
+eq('legality(空, running) 非法(第一跳必须 starting)', A.legality('', 'running'), 'unknown');
+
+/* 每个非终态 ≤2 步到终态(BFS),防止新增状态后出现"永远收不了尾" */
+const unreachable = STATUS_VALUES.filter(
+    (s) => !TERMINAL[s] && stepsToTerminal(EDGES, TERMINAL, s) > 2
+);
+check('每个非终态 ≤2 步到终态', unreachable.length === 0,
+    unreachable.map((s) => s + '=' + stepsToTerminal(EDGES, TERMINAL, s)).join(','));
+
+/* ------------------------------------------------ 3. 判定函数与数据一致 */
+
+const witness = [];
+STATUS_VALUES.concat(['', 'weird', 'FINISHED', null, undefined]).forEach((s) => {
+    const expectTerminal = STATUS_VALUES.indexOf(s) >= 0 && TERMINAL[s] === true;
+    if (A.isTerminal(s) !== expectTerminal) {
+        witness.push('isTerminal(' + String(s) + ')');
+    }
+});
+check('isTerminal 与终态表逐项一致', witness.length === 0, witness.join(','));
+
+const activeMiss = STATUS_VALUES.filter(
+    (s) => A.isActive(s) !== (TERMINAL[s] !== true)
+);
+check('isActive 恰好是"非终态的已知状态"', activeMiss.length === 0, activeMiss.join(','));
+
+/* 前向兼容第一条:未知状态绝不能被视为终态 */
+const unknown = ['weird', 'paused', 'unknown', '', 'Starting', 'RUNNING'];
+const badUnknown = unknown.filter((s) => A.isTerminal(s) === true);
+check('未知/大小写不符的 status 都不判终态', badUnknown.length === 0, badUnknown.join(','));
+
+check('未知 status 的 levelOf 是 unknown',
+    A.levelOf('weird') === 'unknown' && A.levelOf('') === 'unknown');
+eq('levelOf(running)', A.levelOf('running'), 'active');
+eq('levelOf(failed)', A.levelOf('failed'), 'terminal');
+
+/* ------------------------------------------------ 4. 阈值关系 */
+
+const T = A.SPEC.thresholds;
+
+check('心跳间隔为正', T.beatIntervalMs > 0);
+check('beatFreshMs ≥ 2 个心跳', T.beatFreshMs >= T.beatIntervalMs * 2,
+    `${T.beatFreshMs} vs ${T.beatIntervalMs}`);
+check('beatDeadMs > beatFreshMs', T.beatDeadMs > T.beatFreshMs,
+    `${T.beatDeadMs} vs ${T.beatFreshMs}`);
+check('startAckSoftMs < startAckMaxMs', T.startAckSoftMs < T.startAckMaxMs,
+    `${T.startAckSoftMs} vs ${T.startAckMaxMs}`);
+check('stopAckMs > 一个心跳(否则慢心跳永远等不到 ack)',
+    T.stopAckMs > T.beatIntervalMs, `${T.stopAckMs} vs ${T.beatIntervalMs}`);
+check('stopFinishMs > stopAckMs', T.stopFinishMs > T.stopAckMs);
+check('v1 回退的判死比 v2 更宽松', T.legacyNoBeatDeadMs >= T.beatDeadMs,
+    `${T.legacyNoBeatDeadMs} vs ${T.beatDeadMs}`);
+check('v1 回退的停止超时是改造前的 8s', T.legacyStopTimeoutMs === 8000,
+    String(T.legacyStopTimeoutMs));
+check('v1 回退的子引擎宽限期是改造前的 4s', T.legacyChildGraceMs === 4000,
+    String(T.legacyChildGraceMs));
+
+/* ------------------------------------------------ 5. 阈值键集合一致 */
+
+const appKeys = Object.keys(T).sort().join(',');
+const autoKeys = Object.keys(B.SPEC.thresholds).sort().join(',');
+eq('两侧阈值键集合相同', appKeys, autoKeys);
+
+/* ------------------------------------------------------------ 汇总 */
+
+console.log('');
+console.log(`通过 ${passed} 项`);
+if (failures.length > 0) {
+    console.log(`失败 ${failures.length} 项:`);
+    failures.forEach((f) => console.log('  ✗ ' + f));
+    console.log('');
+    console.log('PROTOCOL_FAILED');
+    process.exit(1);
+}
+console.log('PROTOCOL_CLEAN');
+
+/* ------------------------------------------------------------ 辅助 */
+
+function stepsToTerminal(edges, terminal, start) {
+    let frontier = [start];
+    const seen = {};
+    seen[start] = true;
+    for (let step = 1; step <= 4; step += 1) {
+        const next = [];
+        for (let i = 0; i < frontier.length; i += 1) {
+            const edgesOfNode = edges[frontier[i]] || {};
+            const targets = Object.keys(edgesOfNode);
+            for (let j = 0; j < targets.length; j += 1) {
+                const to = targets[j];
+                if (terminal[to] === true) {
+                    return step;
+                }
+                if (seen[to] !== true) {
+                    seen[to] = true;
+                    next.push(to);
+                }
+            }
+        }
+        frontier = next;
+    }
+    return 99;
+}
+
+function firstDiff(a, b) {
+    const len = Math.min(a.length, b.length);
+    for (let i = 0; i < len; i += 1) {
+        if (a[i] !== b[i]) {
+            return `首个差异在第 ${i} 字符:${JSON.stringify(a.slice(i, i + 40))} vs ${JSON.stringify(b.slice(i, i + 40))}`;
+        }
+    }
+    return `长度不同:${a.length} vs ${b.length}`;
+}

+ 173 - 11
tiktok-app/tools/smoke.js

@@ -715,7 +715,20 @@ eq('定位到的是项目目录下的引擎产物', api.Engine.scriptPath, env.E
 api.App.toggleRun();
 eq('权限齐全后进入运行态', api.Session.running, true);
 check('运行时出现全局运行条', !!api.App.runBar);
-check('运行条显示任务名 · 脚本名 · 运行中',
+check('运行条先显示「启动中」——此时引擎一个字都还没回',
+    textsOf(api.App.runBar.root).indexOf('视频功能 · 启动中') >= 0,
+    textsOf(api.App.runBar.root).join(' | '));
+eq('启动中时 awaitingAck 为真', api.Session.awaitingAck, true);
+
+/* 引擎报到(第一次 run-state)= 启动确认。 */
+env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
+    v: 2, runId: api.Session.runId, status: 'starting', phase: '',
+    done: 0, total: 0,
+    engine: { task: 'video', capabilities: { heartbeat: true } }
+});
+env.flushIntervals();
+eq('收到本轮 run-state 后握手完成', api.Session.awaitingAck, false);
+check('运行条转回「运行中」',
     textsOf(api.App.runBar.root).indexOf('视频功能 · 运行中') >= 0,
     textsOf(api.App.runBar.root).join(' | '));
 check('运行按钮文案切为「停止运行」',
@@ -822,7 +835,8 @@ eq('时长格式化补零', api.Session.formatElapsed(65000), '00:01:05');
 /* ④ 引擎上报进度 → 状态条跟着变(界面不重建,只改文字) */
 const beforeBarRoot = api.App.runBar.root;
 env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
-    runId: api.Session.runId, status: 'running', phase: '已完成 2/5', done: 2, total: 5
+    v: 2, runId: api.Session.runId, status: 'running', phase: '已完成 2/5', done: 2, total: 5,
+    engine: { task: 'video', capabilities: { heartbeat: true } }
 });
 env.flushIntervals();
 check('状态条显示引擎上报的阶段',
@@ -830,16 +844,31 @@ check('状态条显示引擎上报的阶段',
     textsOf(api.App.runBar.root).join(' | '));
 eq('进度刷新不重建状态条(只改文字)', api.App.runBar.root === beforeBarRoot, true);
 
-/* ④ 点停止是**两步**的:先发请求、`正在收尾`,等引擎确认才真正结束 */
+/* ④ 点停止是**两步**的:先发请求(「正在通知引擎」)、引擎承认收到(「正在收尾」),
+ *    最后等它写终态才真正结束。三步分别对应三种真实处境:
+ *    ① 我点了但它还没听见 → 说"正在通知"是诚实的;
+ *    ② 它听见了、正在退出 → "正在收尾";
+ *    ③ 它写完了 → 收工。 */
 api.App.toggleRun();
 eq('发出停止请求后仍处于运行态', api.Session.running, true);
-eq('界面进入「正在收尾」', api.Session.stopping, true);
-check('状态条显示「正在收尾」',
-    textsOf(api.App.runBar.root).indexOf('视频功能 · 正在收尾') >= 0,
+eq('界面进入停止态', api.Session.stopping, true);
+check('还没收到引擎确认时说「正在通知引擎」',
+    textsOf(api.App.runBar.root).indexOf('视频功能 · 正在通知引擎') >= 0,
     textsOf(api.App.runBar.root).join(' | '));
+eq('停止确认时间戳还没置', api.Session.stopAckedAt, 0);
 eq('停止标志写的内容是本次 runId',
     env.memoryFiles[api.Engine.STOP_FLAG_PATH], api.Session.runId);
 
+/* 引擎的心跳线程先看见停止请求(它是独立线程,不受主流程长等待影响) */
+env.memoryFiles[api.Engine.BEAT_PATH] = JSON.stringify({
+    v: 2, runId: api.Session.runId, seq: 5, beatAt: Date.now(), stopSeenAt: Date.now()
+});
+env.flushIntervals();
+check('心跳承认收到停止后转「正在收尾」', api.Session.stopAckedAt > 0);
+check('运行条显示「正在收尾」',
+    textsOf(api.App.runBar.root).indexOf('视频功能 · 正在收尾') >= 0,
+    textsOf(api.App.runBar.root).join(' | '));
+
 /* 引擎确认停止 */
 env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
     runId: api.Session.runId, status: 'stopped', phase: '', message: '已停止'
@@ -876,19 +905,152 @@ check('结论文案来自引擎上报',
     api.App.lastRunResult && api.App.lastRunResult.message.indexOf('完成:处理 2 个视频') === 0,
     api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
 
-/* ⑥ 引擎没写终态就没了 → 兜底判定(否则界面会永远卡在「运行中」) */
+/* ⑥ 引擎起了但一个字都不回 → 握手超时。
+ *    ★ 这和"引擎跑了以后被杀"是两件事:前者是压根没起来(要补写停止标志,
+ *      防止迟到的引擎醒来后照着本轮配置自己跑),后者是跑着跑着没了。 */
+api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
+const stalledRunId = api.Session.runId;
+env.flushIntervals();
+eq('握手期仍在运行', api.Session.running, true);
+eq('握手期一直在等确认', api.Session.awaitingAck, true);
+check('握手期状态条说「启动中」',
+    textsOf(api.App.runBar.root).indexOf('视频功能 · 启动中') >= 0,
+    textsOf(api.App.runBar.root).join(' | '));
+
+/* 慢但没死:越过软阈值只改文案 */
+api.Session.ackStartedAt = Date.now() - (api.Engine.T.startAckSoftMs + 500);
+env.flushIntervals();
+eq('超过软阈值仍在等(不判死)', api.Session.running, true);
+eq('软阈值只改文案', api.Session.ackSlow, true);
+
+api.Session.ackStartedAt = Date.now() - (api.Engine.T.startAckMaxMs + 1000);
+env.flushIntervals();
+eq('越过硬阈值后结束运行', api.Session.running, false);
+check('结论是「启动超时」而不是「引擎已退出」',
+    api.App.lastRunResult && api.App.lastRunResult.message.indexOf('启动超时') >= 0,
+    api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
+eq('握手超时后补写了停止标志(挡住迟到的引擎)',
+    env.memoryFiles[api.Engine.STOP_FLAG_PATH], stalledRunId);
+
+/* ⑦ 握手成功之后引擎被系统杀掉(没写终态)→ 心跳过期判死 */
 api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
-env.flushIntervals();                       /* 先让它看到过子引擎 */
+env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
+    v: 2, runId: api.Session.runId, status: 'running', phase: '搜索 fashion', done: 0, total: 5,
+    engine: { task: 'video', capabilities: { heartbeat: true } }
+});
+env.memoryFiles[api.Engine.BEAT_PATH] = JSON.stringify({
+    v: 2, runId: api.Session.runId, seq: 3, beatAt: Date.now(), stopSeenAt: 0
+});
+env.flushIntervals();
+eq('收到 run-state 与心跳后握手完成', api.Session.awaitingAck, false);
 eq('兜底判定前仍在运行', api.Session.running, true);
-env.engineState.children = [];              /* 子引擎消失 */
-api.Session.startedAt = Date.now() - 10000; /* 越过宽限期 */
+
+/* ★ S5:心跳新鲜时,`engines.all()` 说没有子引擎也**不能**判死。
+ *   这正是要防的误判 —— 引擎在做 25 秒的 watch,列表里看不见它很正常。 */
+env.engineState.children = [];
 env.flushIntervals();
-eq('引擎退出且无终态时结束运行', api.Session.running, false);
+eq('心跳新鲜时不因 engines.all() 说没有就判死', api.Session.running, true);
+
+/* 心跳停在同一时刻 → 过期 */
+env.memoryFiles[api.Engine.BEAT_PATH] = JSON.stringify({
+    v: 2, runId: api.Session.runId, seq: 3, beatAt: Date.now() - (api.Engine.T.beatDeadMs + 5000),
+    stopSeenAt: 0
+});
+env.flushIntervals();
+eq('心跳过期后结束运行', api.Session.running, false);
 check('兜底结论说明是引擎退出',
     api.App.lastRunResult && api.App.lastRunResult.message.indexOf('引擎已退出') >= 0,
     api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
 env.engineState.children = [{ id: 'child-1' }];
 
+/* ⑧ 协议 v2 的边角:每一条都是"不报错、只是结论错"的路口 */
+const P = api.Protocol;
+
+/* S3 `failed` 快速通道:看到就收,不等任何超时 */
+api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
+env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
+    v: 2, runId: api.Session.runId, status: 'failed', message: '配置无效:关键词为空'
+});
+env.flushIntervals();
+eq('failed 立刻结束运行', api.Session.running, false);
+check('failed 的结论文案原样带回',
+    api.App.lastRunResult && api.App.lastRunResult.message.indexOf('配置无效') >= 0,
+    api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
+
+/* S7' 上一轮的心跳不是本轮的证据:不降档,但也不能拿它判活 */
+api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
+env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
+    v: 2, runId: api.Session.runId, status: 'running', phase: '',
+    engine: { task: 'video', capabilities: { heartbeat: true } }
+});
+env.memoryFiles[api.Engine.BEAT_PATH] = JSON.stringify({
+    v: 2, runId: 'run-old-previous', seq: 9, beatAt: Date.now(), stopSeenAt: 0
+});
+env.flushIntervals();
+eq('上一轮的心跳不算本轮心跳', api.Session.beatAt, 0);
+eq('有 run-state 就算握手完成(心跳缺失不代表没起来)', api.Session.awaitingAck, false);
+eq('不因"还没有本轮心跳"立刻判死', api.Session.running, true);
+api.Session.ackedAt = Date.now() - (P.SPEC.thresholds.beatDeadMs + 1000);
+env.flushIntervals();
+eq('本轮始终没有心跳 → 到点才判死', api.Session.running, false);
+
+/* S8 未知状态 + 更高版本号:只读降级,绝不能据此结束运行 */
+api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
+env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
+    v: 99, runId: api.Session.runId, status: 'some-future-status', phase: ''
+});
+api.Session.ackedAt = Date.now();
+env.flushIntervals();
+eq('未知状态不结束运行', api.Session.running, true);
+eq('未知状态不当成终态', api.Engine.isTerminalStatus('some-future-status'), false);
+eq('未知状态也不冒充活跃态', api.Engine.isActiveStatus('some-future-status'), false);
+
+/* S9 半截 JSON:读成 null,且**任何状态都不改变**(尤其不得据此判死) */
+env.memoryFiles[api.Engine.STATE_PATH] = '{ "v": 2, "runId": "run-', /* 正好写到一半 */
+eq('半截 run-state 读成 null', api.Engine.readState(), null);
+env.memoryFiles[api.Engine.BEAT_PATH] = '{"v":2,"runId":';
+eq('半截心跳读成 null', api.Engine.readBeat(), null);
+env.flushIntervals();
+eq('半截状态不改变运行态', api.Session.running, true);
+
+/* S12 已确认收到停止,但一直不写终态 */
+api.App.stopRun();
+eq('停止请求已发出', api.Session.stopping, true);
+api.Session.stopAckedAt = Date.now() - (P.SPEC.thresholds.stopFinishMs + 1000);
+env.flushIntervals();
+eq('确认后迟迟不收尾也会被收口', api.Session.running, false);
+check('结论如实说明是"未确认收尾"',
+    api.App.lastRunResult && api.App.lastRunResult.message.indexOf('未确认收尾') >= 0,
+    api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
+
+/* S13 v1 回退:老引擎(run-state 里没有 v)仍然要能跑完、能被判死、能停 */
+api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
+env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
+    runId: api.Session.runId, status: 'running', phase: '搜索 fashion', done: 1, total: 5
+});
+env.flushIntervals();
+eq('无 v 的 run-state 判为 v1', api.App.engineTier(api.Engine.readState(), null), 'v1');
+eq('v1 也能完成握手', api.Session.awaitingAck, false);
+env.engineState.children = [];
+api.Session.startedAt = Date.now() - (api.App.CHILD_GRACE_MS + 1000);
+env.flushIntervals();
+eq('v1 口径下子引擎消失就判死(沿用旧的 4 秒)', api.Session.running, false);
+env.engineState.children = [{ id: 'child-1' }];
+
+/* v1 的停止仍是单段 8 秒 */
+api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
+env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
+    runId: api.Session.runId, status: 'running', phase: ''
+});
+env.flushIntervals();
+api.App.stopRun();
+api.Session.stopRequestedAt = Date.now() - (api.App.STOP_TIMEOUT_MS + 1000);
+env.flushIntervals();
+eq('v1 停止超时后收口', api.Session.running, false);
+check('v1 的结论沿用旧文案',
+    api.App.lastRunResult && api.App.lastRunResult.message.indexOf('未确认') >= 0,
+    api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
+
 /* ⑦ 运行前置校验顺序 —— **顺序本身就是规格**(§2.5):
  *    卡密 → 权限 → 已选脚本 → 引擎接入 → 是否已在跑
  * 所以这里逐条把"前一关"卡住,验证它确实**先**被拦下来。 */

+ 437 - 11
tiktok-auto/dist/tiktok-auto.js

@@ -1,6 +1,6 @@
 /*! tiktok-auto v1.0.0
- *  built 2026-10-29T10:43:50.864Z
- *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, nurture, parse, uidset, fingerprint, checkpoint, filters, profile, idsource, resolve, idflow, dm, dmflow, main
+ *  built 2026-11-01T06:48:56.247Z
+ *  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/ 后重新构建。
  */
 (function () {
@@ -85,6 +85,212 @@ TikTokAuto.logger = (function () {
 }());
 
 
+/* ============================== src/protocol.js ============================== */
+/* ===========================================================================
+ * protocol.js —— 界面 ↔ 引擎 的运行期协议(唯一事实来源)
+ *
+ * 这个文件解决的是"两边各写一套枚举、靠魔法数字猜死活"的老问题。
+ * 它只放**纯数据 + 纯判定**:不读文件、不写文件、不起线程、不看时间。
+ * 所以界面侧与引擎侧可以各存一份完整拷贝,行为完全一致。
+ *
+ * ★ 两份拷贝的纪律:`tiktok-app/src/protocol.js` 与
+ *   `tiktok-auto/src/protocol.js` **除第 1 行(挂载点)外必须逐字节相同**。
+ *   界面侧是 `var Protocol = …`,引擎侧是 `TikTokAuto.protocol = …`。
+ *   `tiktok-app/tools/protocol-check.js` 会剥掉第 1 行后逐字节比对,
+ *   任何一边单独改都会立刻被抓出来。
+ *
+ * ★ 为什么状态判定要放在这里而不是各自实现:
+ *   `isTerminalStatus` 曾经只存在于界面(engine.js),引擎侧自己写
+ *   `'finished' / 'stopped' / 'failed'` 三元推导(main.js)。两边漏改一个
+ *   就会出现"引擎以为结束了、界面还在等"这种零报错的悬挂。
+ * =========================================================================== */
+
+TikTokAuto.protocol = (function () {
+    'use strict';
+
+    /** 协议版本。加字段不改语义不用抬;改语义 / 改判定规则必须抬。 */
+    var VERSION = 2;
+
+    /* ------------------------------------------------------------ 状态枚举 */
+
+    var STATUS = {
+        STARTING: 'starting',
+        RUNNING: 'running',
+        STOPPING: 'stopping',
+        FINISHED: 'finished',
+        STOPPED: 'stopped',
+        FAILED: 'failed'
+    };
+
+    /** 终态:到了这里就没有出边(再回到活跃态一律算 regress)。 */
+    var TERMINAL = { finished: true, stopped: true, failed: true };
+
+    /** 活跃态:运行条要显示、判活逻辑要关心的三档。 */
+    var ACTIVE = { starting: true, running: true, stopping: true };
+
+    /**
+     * 合法迁移表:from → 允许去的 to。
+     *
+     * 空串代表"还没有状态"(本轮的第一次上报)。
+     * `starting` 的出边特别宽:配置无效会在启动瞬间直接 failed,
+     * 极短任务可能 starting 之后直接终态,这两种都是真实的。
+     */
+    var EDGES = {
+        '': { starting: true },
+        starting: {
+            starting: true,
+            running: true,
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        running: {
+            running: true,
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        stopping: {
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        finished: {},
+        stopped: {},
+        failed: {}
+    };
+
+    /* ------------------------------------------------------------ 阈值 */
+
+    /**
+     * 所有超时都在这里,界面侧从 `Protocol.SPEC.thresholds` 取,
+     * 引擎侧心跳间隔也从这里取 —— 不允许在业务代码里再写字面量。
+     *
+     * 语义补充(光看名字看不出来的):
+     *   · beatFreshMs  只用于"加速判死"(见界面 pollEngine),**不产生文案**
+     *   · startAckSoftMs 只是日志与文案降级(「启动中(较慢)」),不改状态
+     *   · stopAckMs / stopFinishMs 是两段:先等"看见了停止请求",再等"真的收完尾"
+     *   · legacy* 是无心跳(v1)引擎的回退值,就是改造前在用的数字
+     */
+    var THRESHOLDS = {
+        /* 心跳 */
+        beatIntervalMs: 2000,
+        beatFreshMs: 8000,
+        beatDeadMs: 15000,
+        /* 启动握手 */
+        startAckSoftMs: 6000,
+        startAckMaxMs: 15000,
+        /* 停止两段 */
+        stopAckMs: 3000,
+        stopFinishMs: 10000,
+        /* v1 回退组(老引擎 / 心跳线程起不来) */
+        legacyChildGraceMs: 4000,
+        legacyStopTimeoutMs: 8000,
+        legacyNoBeatDeadMs: 30000
+    };
+
+    var SPEC = {
+        version: VERSION,
+        status: STATUS,
+        terminal: TERMINAL,
+        active: ACTIVE,
+        edges: EDGES,
+        thresholds: THRESHOLDS
+    };
+
+    /* ------------------------------------------------------------ 工具 */
+
+    /* 单文件拼接后所有模块共享一个作用域,而 `Object.prototype.hasOwnProperty`
+     * 的 call 形式会命中引擎侧构建黑名单里的那种写法(连注释都会被扫到),
+     * 所以这里统一走 apply。 */
+    function owns(object, key) {
+        return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
+    }
+
+    /** 把任意输入变成字符串状态;null / undefined → 空串(= 还没有状态)。 */
+    function statusOf(value) {
+        if (value === undefined || value === null) {
+            return '';
+        }
+        return String(value);
+    }
+
+    /* ------------------------------------------------------------ 判定 */
+
+    /**
+     * 是否终态。
+     * ★ 未知状态一律返回 false —— 界面绝不能因为读到一个不认识的 status
+     *   就把正在跑的运行结束掉(前向兼容第一条)。
+     */
+    function isTerminal(status) {
+        var key = statusOf(status);
+        return owns(TERMINAL, key) === true && TERMINAL[key] === true;
+    }
+
+    /** 是否活跃态(运行条要显示的那三档)。未知状态同样返回 false。 */
+    function isActive(status) {
+        var key = statusOf(status);
+        return owns(ACTIVE, key) === true && ACTIVE[key] === true;
+    }
+
+    /**
+     * 三分类,给日志和排障用。
+     * @returns 'active' | 'terminal' | 'unknown'
+     */
+    function levelOf(status) {
+        var key = statusOf(status);
+        if (isTerminal(key)) {
+            return 'terminal';
+        }
+        if (isActive(key)) {
+            return 'active';
+        }
+        return 'unknown';
+    }
+
+    /**
+     * 迁移合法性。
+     *
+     * @returns 'ok'         表里有的合法迁移
+     *          'idempotent' 原地重复上报(引擎允许重复写同一个状态)
+     *          'regress'    从终态往外走 —— **必须拒绝**
+     *          'unknown'    表里没有的组合,多半是前向兼容来的新状态,
+     *                       调用方应当"放宽"(不据此结束运行),
+     *                       只在日志里留一句
+     */
+    function legality(from, to) {
+        var a = statusOf(from);
+        var b = statusOf(to);
+        if (a === b) {
+            return 'idempotent';
+        }
+        if (isTerminal(a)) {
+            return 'regress';
+        }
+        if (owns(EDGES, a) === true && owns(EDGES[a], b) === true) {
+            return 'ok';
+        }
+        return 'unknown';
+    }
+
+    /* ------------------------------------------------------------ 导出 */
+
+    return {
+        VERSION: VERSION,
+        STATUS: STATUS,
+        SPEC: SPEC,
+        statusOf: statusOf,
+        isTerminal: isTerminal,
+        isActive: isActive,
+        levelOf: levelOf,
+        legality: legality
+    };
+})();
+
+
 /* ============================== src/bridge.js ============================== */
 /**
  * bridge.js — 与「界面宿主」之间的桥
@@ -121,6 +327,9 @@ TikTokAuto.bridge = (function () {
     var DEFAULT_STATE = DEFAULT_DIR + '/run-state.json';
     var DEFAULT_STOP_FLAG = DEFAULT_DIR + '/stop.flag';
     var DEFAULT_LOG = DEFAULT_DIR + '/run-log.jsonl';
+    /* 心跳:独立文件、独立写者。和 run-state 分开是因为后者由主流程按条目写,
+     * 两个写者写同一个文件必然互相覆盖,而且频率差一个数量级。 */
+    var DEFAULT_BEAT = DEFAULT_DIR + '/engine-beat.json';
 
     /**
      * 检查停止标志的最小间隔。
@@ -129,17 +338,46 @@ TikTokAuto.bridge = (function () {
      */
     var STOP_POLL_MIN_MS = 500;
 
+    /** 心跳间隔的兜底值:protocol 模块没加载时也能跑(此时 v=0,宿主会走 v1 回退)。 */
+    var BEAT_INTERVAL_FALLBACK_MS = 2000;
+
     var state = {
         enabled: false,
         runId: '',
         statePath: DEFAULT_STATE,
         stopFlagPath: DEFAULT_STOP_FLAG,
         logPath: DEFAULT_LOG,
-        lastStopCheckMs: 0
+        beatPath: DEFAULT_BEAT,
+        lastStopCheckMs: 0,
+        /* 本轮开始的时刻:宿主拿它算"启动握手超时",与 run-state.updatedAt 无关 */
+        startedAt: 0,
+        /* run-state 的写入序号,单调递增。宿主据此判断"同一毫秒内的两次写" */
+        seq: 0
     };
 
     var last = null;
 
+    /**
+     * 心跳线程的运行态。
+     *
+     * ★ 三条硬规则(实现时踩过,写在这里防止被"优化"掉):
+     *   ① **不得调用 checkStop()** —— 它会在命中时 report(stopping) 并抛 StopSignal,
+     *      抛进心跳线程就让"收尾那 10 秒"再也没有人写心跳,而那正是最需要它的时候。
+     *   ② **不得调用 report()** —— report 的 `last` 是跨线程共享的可变对象、整份覆盖写,
+     *      心跳借道会把 phase 写回旧值。
+     *   ③ **用裸 sleep,不用 wait.tick()** —— 理由同 ①。
+     *      `wait.js` 头注释说"裸 sleep 只允许出现在本文件",这里是受控例外。
+     */
+    var beat = {
+        thread: null,
+        running: false,
+        seq: 0,
+        /* 看见停止请求的时刻。★ 一旦写入就**不再归零**(sticky):
+         * 心跳是整份覆盖写,如果每次都从 0 开始,2 秒后就把 ack 抹掉了,
+         * 而宿主是 1 Hz 轮询 —— 有概率整整错过一拍。 */
+        seenAt: 0
+    };
+
     /**
      * 「用户要求停止」。**刻意用异常抛出**:
      * 停止请求可能发生在任意深度的等待里(等页面就绪、等播放开始、等结果卡片……),
@@ -275,6 +513,105 @@ TikTokAuto.bridge = (function () {
         }
     }
 
+    /** 协议版本号。protocol 模块缺失时是 0 —— 宿主据此走 v1 回退,不会认错。 */
+    function protocolVersion() {
+        return TikTokAuto.protocol ? TikTokAuto.protocol.VERSION : 0;
+    }
+
+    function beatIntervalMs() {
+        var spec = TikTokAuto.protocol ? TikTokAuto.protocol.SPEC : null;
+        var ms = spec && spec.thresholds ? spec.thresholds.beatIntervalMs : 0;
+        return ms > 0 ? ms : BEAT_INTERVAL_FALLBACK_MS;
+    }
+
+    /**
+     * 写一拍心跳。
+     *
+     * 只做两件事:看一眼停止标志(记下"看见了")、整份重写心跳文件。
+     * 不 report、不抛异常、不碰 run-state。
+     */
+    function heartbeatOnce() {
+        var content = '';
+        try {
+            if (files.exists(state.stopFlagPath)) {
+                content = String(files.read(state.stopFlagPath)).replace(/\s/g, '');
+            }
+        } catch (error) {
+            /* 读不到就当"还没看见":下一拍再试,绝不因为一次读失败就清掉 seenAt */
+            content = '';
+        }
+
+        if (content !== '' && content === state.runId) {
+            /* sticky:只在第一次记时刻 */
+            if (beat.seenAt === 0) {
+                beat.seenAt = Date.now();
+            }
+        }
+
+        beat.seq += 1;
+        try {
+            files.write(state.beatPath, JSON.stringify({
+                v: protocolVersion(),
+                runId: state.runId,
+                seq: beat.seq,
+                beatAt: Date.now(),
+                stopSeenAt: beat.seenAt
+            }));
+        } catch (error) {
+            /* 心跳写不进去不该影响流程:宿主那边会表现为"心跳过期",
+             * 它有自己的兜底(判死 → 结束运行),不需要引擎来救 */
+        }
+    }
+
+    /**
+     * 起心跳线程。
+     * @returns 是否真的起来了 —— 起不来时把它写进 capabilities,宿主会退回 v1 阈值
+     */
+    function heartbeatStart() {
+        if (!state.enabled) {
+            return false;
+        }
+        if (beat.running) {
+            return true;
+        }
+        beat.running = true;
+        beat.seq = 0;
+        beat.seenAt = 0;
+
+        /* 先同步写一拍:宿主在启动握手阶段就有东西可读,不用等 2 秒 */
+        heartbeatOnce();
+
+        var interval = beatIntervalMs();
+        try {
+            beat.thread = threads.start(function () {
+                while (beat.running) {
+                    /* 先睡再写:开场那一拍上面已经写过,避免同一毫秒写两次 */
+                    sleep(interval);
+                    if (!beat.running) {
+                        break;
+                    }
+                    heartbeatOnce();
+                }
+            });
+        } catch (error) {
+            beat.running = false;
+            beat.thread = null;
+            log('warn', '心跳线程启动失败,宿主将退回无心跳判定:' + error);
+            return false;
+        }
+        return true;
+    }
+
+    /** 停心跳。只是放下旗子,线程自己在下一拍前后退出(不强制中断)。 */
+    function heartbeatStop() {
+        beat.running = false;
+        beat.thread = null;
+    }
+
+    function isHeartbeatUp() {
+        return beat.running === true;
+    }
+
     /** 打开桥。没有 runId 时**不启用**——runId 是停止标志比对的依据,缺了它无法判定是谁的请求。 */
     function enable(options) {
         state.enabled = true;
@@ -282,7 +619,10 @@ TikTokAuto.bridge = (function () {
         state.statePath = options.statePath || DEFAULT_STATE;
         state.stopFlagPath = options.stopFlagPath || DEFAULT_STOP_FLAG;
         state.logPath = options.logPath || DEFAULT_LOG;
+        state.beatPath = options.beatPath || DEFAULT_BEAT;
         state.lastStopCheckMs = 0;
+        state.startedAt = Date.now();
+        state.seq = 0;
 
         /* ★ 清空上一轮的日志:界面在结束时整份读走,
          *   不清的话 D3 上会混进上一次运行的行(而且越跑越长)。 */
@@ -295,16 +635,43 @@ TikTokAuto.bridge = (function () {
             TikTokAuto.logger.setSink(appendLog);
         }
 
+        /* ★ 顺序:先起心跳、拿结果,再写第一次状态。
+         *   反过来会先告诉宿主"有心跳"再改成"没有",宿主可能在那两秒里按心跳判活,
+         *   而实际上根本没有心跳线程。 */
+        var heartbeatUp = heartbeatStart();
+
         last = {
+            v: protocolVersion(),
             runId: state.runId,
             status: 'starting',
             phase: '',
             done: 0,
             total: 0,
-            updatedAt: 0
+            seq: 0,
+            startedAt: state.startedAt,
+            updatedAt: 0,
+            /* task 此刻还不知道(要等 config.resolve),main 会调 setTask 补上 */
+            engine: { task: '', capabilities: { heartbeat: heartbeatUp } }
         };
         report({});
-        log('info', '已连接到界面宿主,runId=' + state.runId);
+        log('info', '已连接到界面宿主,runId=' + state.runId +
+            ' / 心跳=' + (heartbeatUp ? '开' : '关'));
+    }
+
+    /**
+     * 告诉宿主"这次跑的是哪个任务"。
+     * 只是补一个字段并立刻落盘一次:宿主用它做排障展示,
+     * 也让"引擎起来了但配置是错的"这种情况在状态里看得见。
+     */
+    function setTask(task) {
+        if (!state.enabled || !last) {
+            return;
+        }
+        if (!last.engine) {
+            last.engine = { task: '', capabilities: { heartbeat: isHeartbeatUp() } };
+        }
+        last.engine.task = String(task || '');
+        report({});
     }
 
     function isEnabled() {
@@ -324,6 +691,10 @@ TikTokAuto.bridge = (function () {
                 last[key] = patch[key];
             }
         }
+        state.seq += 1;
+        last.v = protocolVersion();
+        last.seq = state.seq;
+        last.startedAt = state.startedAt;
         last.runId = state.runId;
         last.updatedAt = Date.now();
 
@@ -496,6 +867,7 @@ TikTokAuto.bridge = (function () {
         DEFAULT_STATE: DEFAULT_STATE,
         DEFAULT_STOP_FLAG: DEFAULT_STOP_FLAG,
         DEFAULT_LOG: DEFAULT_LOG,
+        DEFAULT_BEAT: DEFAULT_BEAT,
         StopSignal: StopSignal,
         isStopSignal: isStopSignal,
         readExecArgv: readExecArgv,
@@ -504,6 +876,14 @@ TikTokAuto.bridge = (function () {
         isEnabled: isEnabled,
         checkStop: checkStop,
         currentRunId: currentRunId,
+        protocolVersion: protocolVersion,
+        setTask: setTask,
+        heartbeatStart: heartbeatStart,
+        heartbeatStop: heartbeatStop,
+        isHeartbeatUp: isHeartbeatUp,
+        /* 单独导出"写一拍":PC 自检要能在不真起线程的情况下驱动心跳,
+         * 排障时也想手动补一拍。业务代码**不应该**调它(会打乱 seq 的含义)。 */
+        heartbeatOnce: heartbeatOnce,
         report: report,
         finish: finish,
         describe: describe,
@@ -745,7 +1125,12 @@ TikTokAuto.config = (function () {
         runtime: {
             configPath: DEFAULT_DEVICE_PATH,
             maxRuntimeMs: 0,
-            dryRun: false
+            dryRun: false,
+            /* 本轮运行的标识。界面写进 engine-config.json,引擎把命令行里的
+             * `--run-id` 拿来比对 —— 用来挡住"上一次的引擎迟到启动、
+             * 却读到这一轮的配置并真的跑起来"。
+             * 独立运行(没有宿主)时为 '',校验会自动跳过。 */
+            runId: ''
         }
     };
 
@@ -940,6 +1325,34 @@ TikTokAuto.config = (function () {
         return config;
     }
 
+    /**
+     * 配置与本次运行必须对得上。
+     *
+     * 挡的是这一种真实故障:上一轮的引擎因为冷编译慢、迟到启动,
+     * 醒来时读到的已经是**这一轮**刚写的 engine-config —— 它照新配置跑起来,
+     * 而界面以为这一轮还没起来(于是两份流程同时操作一台手机)。
+     * 配置里绑一个 runId 就能让它当场收手。
+     *
+     * ★ 只在**双方都非空**时比较:独立运行时命令行没有 `--run-id`,
+     *   `bridge.currentRunId()` 是空串,此时必须放行,否则引擎永远跑不起来。
+     */
+    function assertRunIdMatches(config) {
+        var wanted = config && config.runtime ? String(config.runtime.runId || '') : '';
+        var current = '';
+        if (TikTokAuto.bridge && TikTokAuto.bridge.currentRunId) {
+            current = String(TikTokAuto.bridge.currentRunId() || '');
+        }
+        if (wanted === '' || current === '') {
+            return;
+        }
+        if (wanted !== current) {
+            throw new ConfigError('配置属于另一次运行(runId 不匹配)', {
+                configRunId: wanted,
+                engineRunId: current
+            });
+        }
+    }
+
     /**
      * 解析最终生效的配置。
      *
@@ -954,6 +1367,7 @@ TikTokAuto.config = (function () {
         var config = merge(merge(fromDefaults, fromDevice), injected || {});
 
         validate(config);
+        assertRunIdMatches(config);
         if (config.task === 'id') {
             logger.info('config', '生效配置(ID提取):' + JSON.stringify({
                 sources: config.id.sources.length,
@@ -10100,6 +10514,10 @@ TikTokAuto.main = (function () {
                 return summary;
             }
 
+            /* 配置里带着"这次属于哪个 runId",校验它对不对是 config.resolve 的事;
+             * 这里只把它登记到状态里,让宿主在排障时一眼看出跑的是哪个任务。 */
+            bridge.setTask(config.task);
+
             bridge.report({ phase: '启动应用', total: config.task === 'id' ? 0 : config.target.maxVideos });
 
             guardState = TikTokAuto.popups.startGuard(config, log);
@@ -10137,11 +10555,19 @@ TikTokAuto.main = (function () {
             log.result(summary);
 
             /* 先把终态交给宿主,再收线程:顺序反了的话,界面可能永远等不到"结束",
-             * 状态条会一直显示"运行中"。 */
-            bridge.finish(
-                stoppedByUser ? 'stopped' : (summary.ok ? 'finished' : 'failed'),
-                summary
-            );
+             * 状态条会一直显示"运行中"。
+             *
+             * ★ finish 必须包 try/finally:它自己一旦抛错(summary 结构异常之类),
+             *   心跳线程就永远不被收掉 —— 而这是 finally 里,没有下一层兜底,
+             *   `shutDownAll` 与 `__END__` 都会被一起吞掉,宿主只能等 15 秒判死。 */
+            try {
+                bridge.finish(
+                    stoppedByUser ? 'stopped' : (summary.ok ? 'finished' : 'failed'),
+                    summary
+                );
+            } finally {
+                bridge.heartbeatStop();
+            }
 
             // 顺序很重要:先让日志刷出去,再收线程,最后打结束标记
             try {

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

@@ -33,6 +33,9 @@ TikTokAuto.bridge = (function () {
     var DEFAULT_STATE = DEFAULT_DIR + '/run-state.json';
     var DEFAULT_STOP_FLAG = DEFAULT_DIR + '/stop.flag';
     var DEFAULT_LOG = DEFAULT_DIR + '/run-log.jsonl';
+    /* 心跳:独立文件、独立写者。和 run-state 分开是因为后者由主流程按条目写,
+     * 两个写者写同一个文件必然互相覆盖,而且频率差一个数量级。 */
+    var DEFAULT_BEAT = DEFAULT_DIR + '/engine-beat.json';
 
     /**
      * 检查停止标志的最小间隔。
@@ -41,17 +44,46 @@ TikTokAuto.bridge = (function () {
      */
     var STOP_POLL_MIN_MS = 500;
 
+    /** 心跳间隔的兜底值:protocol 模块没加载时也能跑(此时 v=0,宿主会走 v1 回退)。 */
+    var BEAT_INTERVAL_FALLBACK_MS = 2000;
+
     var state = {
         enabled: false,
         runId: '',
         statePath: DEFAULT_STATE,
         stopFlagPath: DEFAULT_STOP_FLAG,
         logPath: DEFAULT_LOG,
-        lastStopCheckMs: 0
+        beatPath: DEFAULT_BEAT,
+        lastStopCheckMs: 0,
+        /* 本轮开始的时刻:宿主拿它算"启动握手超时",与 run-state.updatedAt 无关 */
+        startedAt: 0,
+        /* run-state 的写入序号,单调递增。宿主据此判断"同一毫秒内的两次写" */
+        seq: 0
     };
 
     var last = null;
 
+    /**
+     * 心跳线程的运行态。
+     *
+     * ★ 三条硬规则(实现时踩过,写在这里防止被"优化"掉):
+     *   ① **不得调用 checkStop()** —— 它会在命中时 report(stopping) 并抛 StopSignal,
+     *      抛进心跳线程就让"收尾那 10 秒"再也没有人写心跳,而那正是最需要它的时候。
+     *   ② **不得调用 report()** —— report 的 `last` 是跨线程共享的可变对象、整份覆盖写,
+     *      心跳借道会把 phase 写回旧值。
+     *   ③ **用裸 sleep,不用 wait.tick()** —— 理由同 ①。
+     *      `wait.js` 头注释说"裸 sleep 只允许出现在本文件",这里是受控例外。
+     */
+    var beat = {
+        thread: null,
+        running: false,
+        seq: 0,
+        /* 看见停止请求的时刻。★ 一旦写入就**不再归零**(sticky):
+         * 心跳是整份覆盖写,如果每次都从 0 开始,2 秒后就把 ack 抹掉了,
+         * 而宿主是 1 Hz 轮询 —— 有概率整整错过一拍。 */
+        seenAt: 0
+    };
+
     /**
      * 「用户要求停止」。**刻意用异常抛出**:
      * 停止请求可能发生在任意深度的等待里(等页面就绪、等播放开始、等结果卡片……),
@@ -187,6 +219,105 @@ TikTokAuto.bridge = (function () {
         }
     }
 
+    /** 协议版本号。protocol 模块缺失时是 0 —— 宿主据此走 v1 回退,不会认错。 */
+    function protocolVersion() {
+        return TikTokAuto.protocol ? TikTokAuto.protocol.VERSION : 0;
+    }
+
+    function beatIntervalMs() {
+        var spec = TikTokAuto.protocol ? TikTokAuto.protocol.SPEC : null;
+        var ms = spec && spec.thresholds ? spec.thresholds.beatIntervalMs : 0;
+        return ms > 0 ? ms : BEAT_INTERVAL_FALLBACK_MS;
+    }
+
+    /**
+     * 写一拍心跳。
+     *
+     * 只做两件事:看一眼停止标志(记下"看见了")、整份重写心跳文件。
+     * 不 report、不抛异常、不碰 run-state。
+     */
+    function heartbeatOnce() {
+        var content = '';
+        try {
+            if (files.exists(state.stopFlagPath)) {
+                content = String(files.read(state.stopFlagPath)).replace(/\s/g, '');
+            }
+        } catch (error) {
+            /* 读不到就当"还没看见":下一拍再试,绝不因为一次读失败就清掉 seenAt */
+            content = '';
+        }
+
+        if (content !== '' && content === state.runId) {
+            /* sticky:只在第一次记时刻 */
+            if (beat.seenAt === 0) {
+                beat.seenAt = Date.now();
+            }
+        }
+
+        beat.seq += 1;
+        try {
+            files.write(state.beatPath, JSON.stringify({
+                v: protocolVersion(),
+                runId: state.runId,
+                seq: beat.seq,
+                beatAt: Date.now(),
+                stopSeenAt: beat.seenAt
+            }));
+        } catch (error) {
+            /* 心跳写不进去不该影响流程:宿主那边会表现为"心跳过期",
+             * 它有自己的兜底(判死 → 结束运行),不需要引擎来救 */
+        }
+    }
+
+    /**
+     * 起心跳线程。
+     * @returns 是否真的起来了 —— 起不来时把它写进 capabilities,宿主会退回 v1 阈值
+     */
+    function heartbeatStart() {
+        if (!state.enabled) {
+            return false;
+        }
+        if (beat.running) {
+            return true;
+        }
+        beat.running = true;
+        beat.seq = 0;
+        beat.seenAt = 0;
+
+        /* 先同步写一拍:宿主在启动握手阶段就有东西可读,不用等 2 秒 */
+        heartbeatOnce();
+
+        var interval = beatIntervalMs();
+        try {
+            beat.thread = threads.start(function () {
+                while (beat.running) {
+                    /* 先睡再写:开场那一拍上面已经写过,避免同一毫秒写两次 */
+                    sleep(interval);
+                    if (!beat.running) {
+                        break;
+                    }
+                    heartbeatOnce();
+                }
+            });
+        } catch (error) {
+            beat.running = false;
+            beat.thread = null;
+            log('warn', '心跳线程启动失败,宿主将退回无心跳判定:' + error);
+            return false;
+        }
+        return true;
+    }
+
+    /** 停心跳。只是放下旗子,线程自己在下一拍前后退出(不强制中断)。 */
+    function heartbeatStop() {
+        beat.running = false;
+        beat.thread = null;
+    }
+
+    function isHeartbeatUp() {
+        return beat.running === true;
+    }
+
     /** 打开桥。没有 runId 时**不启用**——runId 是停止标志比对的依据,缺了它无法判定是谁的请求。 */
     function enable(options) {
         state.enabled = true;
@@ -194,7 +325,10 @@ TikTokAuto.bridge = (function () {
         state.statePath = options.statePath || DEFAULT_STATE;
         state.stopFlagPath = options.stopFlagPath || DEFAULT_STOP_FLAG;
         state.logPath = options.logPath || DEFAULT_LOG;
+        state.beatPath = options.beatPath || DEFAULT_BEAT;
         state.lastStopCheckMs = 0;
+        state.startedAt = Date.now();
+        state.seq = 0;
 
         /* ★ 清空上一轮的日志:界面在结束时整份读走,
          *   不清的话 D3 上会混进上一次运行的行(而且越跑越长)。 */
@@ -207,16 +341,43 @@ TikTokAuto.bridge = (function () {
             TikTokAuto.logger.setSink(appendLog);
         }
 
+        /* ★ 顺序:先起心跳、拿结果,再写第一次状态。
+         *   反过来会先告诉宿主"有心跳"再改成"没有",宿主可能在那两秒里按心跳判活,
+         *   而实际上根本没有心跳线程。 */
+        var heartbeatUp = heartbeatStart();
+
         last = {
+            v: protocolVersion(),
             runId: state.runId,
             status: 'starting',
             phase: '',
             done: 0,
             total: 0,
-            updatedAt: 0
+            seq: 0,
+            startedAt: state.startedAt,
+            updatedAt: 0,
+            /* task 此刻还不知道(要等 config.resolve),main 会调 setTask 补上 */
+            engine: { task: '', capabilities: { heartbeat: heartbeatUp } }
         };
         report({});
-        log('info', '已连接到界面宿主,runId=' + state.runId);
+        log('info', '已连接到界面宿主,runId=' + state.runId +
+            ' / 心跳=' + (heartbeatUp ? '开' : '关'));
+    }
+
+    /**
+     * 告诉宿主"这次跑的是哪个任务"。
+     * 只是补一个字段并立刻落盘一次:宿主用它做排障展示,
+     * 也让"引擎起来了但配置是错的"这种情况在状态里看得见。
+     */
+    function setTask(task) {
+        if (!state.enabled || !last) {
+            return;
+        }
+        if (!last.engine) {
+            last.engine = { task: '', capabilities: { heartbeat: isHeartbeatUp() } };
+        }
+        last.engine.task = String(task || '');
+        report({});
     }
 
     function isEnabled() {
@@ -236,6 +397,10 @@ TikTokAuto.bridge = (function () {
                 last[key] = patch[key];
             }
         }
+        state.seq += 1;
+        last.v = protocolVersion();
+        last.seq = state.seq;
+        last.startedAt = state.startedAt;
         last.runId = state.runId;
         last.updatedAt = Date.now();
 
@@ -408,6 +573,7 @@ TikTokAuto.bridge = (function () {
         DEFAULT_STATE: DEFAULT_STATE,
         DEFAULT_STOP_FLAG: DEFAULT_STOP_FLAG,
         DEFAULT_LOG: DEFAULT_LOG,
+        DEFAULT_BEAT: DEFAULT_BEAT,
         StopSignal: StopSignal,
         isStopSignal: isStopSignal,
         readExecArgv: readExecArgv,
@@ -416,6 +582,14 @@ TikTokAuto.bridge = (function () {
         isEnabled: isEnabled,
         checkStop: checkStop,
         currentRunId: currentRunId,
+        protocolVersion: protocolVersion,
+        setTask: setTask,
+        heartbeatStart: heartbeatStart,
+        heartbeatStop: heartbeatStop,
+        isHeartbeatUp: isHeartbeatUp,
+        /* 单独导出"写一拍":PC 自检要能在不真起线程的情况下驱动心跳,
+         * 排障时也想手动补一拍。业务代码**不应该**调它(会打乱 seq 的含义)。 */
+        heartbeatOnce: heartbeatOnce,
         report: report,
         finish: finish,
         describe: describe,

+ 35 - 1
tiktok-auto/src/config.js

@@ -230,7 +230,12 @@ TikTokAuto.config = (function () {
         runtime: {
             configPath: DEFAULT_DEVICE_PATH,
             maxRuntimeMs: 0,
-            dryRun: false
+            dryRun: false,
+            /* 本轮运行的标识。界面写进 engine-config.json,引擎把命令行里的
+             * `--run-id` 拿来比对 —— 用来挡住"上一次的引擎迟到启动、
+             * 却读到这一轮的配置并真的跑起来"。
+             * 独立运行(没有宿主)时为 '',校验会自动跳过。 */
+            runId: ''
         }
     };
 
@@ -425,6 +430,34 @@ TikTokAuto.config = (function () {
         return config;
     }
 
+    /**
+     * 配置与本次运行必须对得上。
+     *
+     * 挡的是这一种真实故障:上一轮的引擎因为冷编译慢、迟到启动,
+     * 醒来时读到的已经是**这一轮**刚写的 engine-config —— 它照新配置跑起来,
+     * 而界面以为这一轮还没起来(于是两份流程同时操作一台手机)。
+     * 配置里绑一个 runId 就能让它当场收手。
+     *
+     * ★ 只在**双方都非空**时比较:独立运行时命令行没有 `--run-id`,
+     *   `bridge.currentRunId()` 是空串,此时必须放行,否则引擎永远跑不起来。
+     */
+    function assertRunIdMatches(config) {
+        var wanted = config && config.runtime ? String(config.runtime.runId || '') : '';
+        var current = '';
+        if (TikTokAuto.bridge && TikTokAuto.bridge.currentRunId) {
+            current = String(TikTokAuto.bridge.currentRunId() || '');
+        }
+        if (wanted === '' || current === '') {
+            return;
+        }
+        if (wanted !== current) {
+            throw new ConfigError('配置属于另一次运行(runId 不匹配)', {
+                configRunId: wanted,
+                engineRunId: current
+            });
+        }
+    }
+
     /**
      * 解析最终生效的配置。
      *
@@ -439,6 +472,7 @@ TikTokAuto.config = (function () {
         var config = merge(merge(fromDefaults, fromDevice), injected || {});
 
         validate(config);
+        assertRunIdMatches(config);
         if (config.task === 'id') {
             logger.info('config', '生效配置(ID提取):' + JSON.stringify({
                 sources: config.id.sources.length,

+ 17 - 5
tiktok-auto/src/main.js

@@ -85,6 +85,10 @@ TikTokAuto.main = (function () {
                 return summary;
             }
 
+            /* 配置里带着"这次属于哪个 runId",校验它对不对是 config.resolve 的事;
+             * 这里只把它登记到状态里,让宿主在排障时一眼看出跑的是哪个任务。 */
+            bridge.setTask(config.task);
+
             bridge.report({ phase: '启动应用', total: config.task === 'id' ? 0 : config.target.maxVideos });
 
             guardState = TikTokAuto.popups.startGuard(config, log);
@@ -122,11 +126,19 @@ TikTokAuto.main = (function () {
             log.result(summary);
 
             /* 先把终态交给宿主,再收线程:顺序反了的话,界面可能永远等不到"结束",
-             * 状态条会一直显示"运行中"。 */
-            bridge.finish(
-                stoppedByUser ? 'stopped' : (summary.ok ? 'finished' : 'failed'),
-                summary
-            );
+             * 状态条会一直显示"运行中"。
+             *
+             * ★ finish 必须包 try/finally:它自己一旦抛错(summary 结构异常之类),
+             *   心跳线程就永远不被收掉 —— 而这是 finally 里,没有下一层兜底,
+             *   `shutDownAll` 与 `__END__` 都会被一起吞掉,宿主只能等 15 秒判死。 */
+            try {
+                bridge.finish(
+                    stoppedByUser ? 'stopped' : (summary.ok ? 'finished' : 'failed'),
+                    summary
+                );
+            } finally {
+                bridge.heartbeatStop();
+            }
 
             // 顺序很重要:先让日志刷出去,再收线程,最后打结束标记
             try {

+ 203 - 0
tiktok-auto/src/protocol.js

@@ -0,0 +1,203 @@
+/* ===========================================================================
+ * protocol.js —— 界面 ↔ 引擎 的运行期协议(唯一事实来源)
+ *
+ * 这个文件解决的是"两边各写一套枚举、靠魔法数字猜死活"的老问题。
+ * 它只放**纯数据 + 纯判定**:不读文件、不写文件、不起线程、不看时间。
+ * 所以界面侧与引擎侧可以各存一份完整拷贝,行为完全一致。
+ *
+ * ★ 两份拷贝的纪律:`tiktok-app/src/protocol.js` 与
+ *   `tiktok-auto/src/protocol.js` **除第 1 行(挂载点)外必须逐字节相同**。
+ *   界面侧是 `var Protocol = …`,引擎侧是 `TikTokAuto.protocol = …`。
+ *   `tiktok-app/tools/protocol-check.js` 会剥掉第 1 行后逐字节比对,
+ *   任何一边单独改都会立刻被抓出来。
+ *
+ * ★ 为什么状态判定要放在这里而不是各自实现:
+ *   `isTerminalStatus` 曾经只存在于界面(engine.js),引擎侧自己写
+ *   `'finished' / 'stopped' / 'failed'` 三元推导(main.js)。两边漏改一个
+ *   就会出现"引擎以为结束了、界面还在等"这种零报错的悬挂。
+ * =========================================================================== */
+
+TikTokAuto.protocol = (function () {
+    'use strict';
+
+    /** 协议版本。加字段不改语义不用抬;改语义 / 改判定规则必须抬。 */
+    var VERSION = 2;
+
+    /* ------------------------------------------------------------ 状态枚举 */
+
+    var STATUS = {
+        STARTING: 'starting',
+        RUNNING: 'running',
+        STOPPING: 'stopping',
+        FINISHED: 'finished',
+        STOPPED: 'stopped',
+        FAILED: 'failed'
+    };
+
+    /** 终态:到了这里就没有出边(再回到活跃态一律算 regress)。 */
+    var TERMINAL = { finished: true, stopped: true, failed: true };
+
+    /** 活跃态:运行条要显示、判活逻辑要关心的三档。 */
+    var ACTIVE = { starting: true, running: true, stopping: true };
+
+    /**
+     * 合法迁移表:from → 允许去的 to。
+     *
+     * 空串代表"还没有状态"(本轮的第一次上报)。
+     * `starting` 的出边特别宽:配置无效会在启动瞬间直接 failed,
+     * 极短任务可能 starting 之后直接终态,这两种都是真实的。
+     */
+    var EDGES = {
+        '': { starting: true },
+        starting: {
+            starting: true,
+            running: true,
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        running: {
+            running: true,
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        stopping: {
+            stopping: true,
+            finished: true,
+            stopped: true,
+            failed: true
+        },
+        finished: {},
+        stopped: {},
+        failed: {}
+    };
+
+    /* ------------------------------------------------------------ 阈值 */
+
+    /**
+     * 所有超时都在这里,界面侧从 `Protocol.SPEC.thresholds` 取,
+     * 引擎侧心跳间隔也从这里取 —— 不允许在业务代码里再写字面量。
+     *
+     * 语义补充(光看名字看不出来的):
+     *   · beatFreshMs  只用于"加速判死"(见界面 pollEngine),**不产生文案**
+     *   · startAckSoftMs 只是日志与文案降级(「启动中(较慢)」),不改状态
+     *   · stopAckMs / stopFinishMs 是两段:先等"看见了停止请求",再等"真的收完尾"
+     *   · legacy* 是无心跳(v1)引擎的回退值,就是改造前在用的数字
+     */
+    var THRESHOLDS = {
+        /* 心跳 */
+        beatIntervalMs: 2000,
+        beatFreshMs: 8000,
+        beatDeadMs: 15000,
+        /* 启动握手 */
+        startAckSoftMs: 6000,
+        startAckMaxMs: 15000,
+        /* 停止两段 */
+        stopAckMs: 3000,
+        stopFinishMs: 10000,
+        /* v1 回退组(老引擎 / 心跳线程起不来) */
+        legacyChildGraceMs: 4000,
+        legacyStopTimeoutMs: 8000,
+        legacyNoBeatDeadMs: 30000
+    };
+
+    var SPEC = {
+        version: VERSION,
+        status: STATUS,
+        terminal: TERMINAL,
+        active: ACTIVE,
+        edges: EDGES,
+        thresholds: THRESHOLDS
+    };
+
+    /* ------------------------------------------------------------ 工具 */
+
+    /* 单文件拼接后所有模块共享一个作用域,而 `Object.prototype.hasOwnProperty`
+     * 的 call 形式会命中引擎侧构建黑名单里的那种写法(连注释都会被扫到),
+     * 所以这里统一走 apply。 */
+    function owns(object, key) {
+        return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
+    }
+
+    /** 把任意输入变成字符串状态;null / undefined → 空串(= 还没有状态)。 */
+    function statusOf(value) {
+        if (value === undefined || value === null) {
+            return '';
+        }
+        return String(value);
+    }
+
+    /* ------------------------------------------------------------ 判定 */
+
+    /**
+     * 是否终态。
+     * ★ 未知状态一律返回 false —— 界面绝不能因为读到一个不认识的 status
+     *   就把正在跑的运行结束掉(前向兼容第一条)。
+     */
+    function isTerminal(status) {
+        var key = statusOf(status);
+        return owns(TERMINAL, key) === true && TERMINAL[key] === true;
+    }
+
+    /** 是否活跃态(运行条要显示的那三档)。未知状态同样返回 false。 */
+    function isActive(status) {
+        var key = statusOf(status);
+        return owns(ACTIVE, key) === true && ACTIVE[key] === true;
+    }
+
+    /**
+     * 三分类,给日志和排障用。
+     * @returns 'active' | 'terminal' | 'unknown'
+     */
+    function levelOf(status) {
+        var key = statusOf(status);
+        if (isTerminal(key)) {
+            return 'terminal';
+        }
+        if (isActive(key)) {
+            return 'active';
+        }
+        return 'unknown';
+    }
+
+    /**
+     * 迁移合法性。
+     *
+     * @returns 'ok'         表里有的合法迁移
+     *          'idempotent' 原地重复上报(引擎允许重复写同一个状态)
+     *          'regress'    从终态往外走 —— **必须拒绝**
+     *          'unknown'    表里没有的组合,多半是前向兼容来的新状态,
+     *                       调用方应当"放宽"(不据此结束运行),
+     *                       只在日志里留一句
+     */
+    function legality(from, to) {
+        var a = statusOf(from);
+        var b = statusOf(to);
+        if (a === b) {
+            return 'idempotent';
+        }
+        if (isTerminal(a)) {
+            return 'regress';
+        }
+        if (owns(EDGES, a) === true && owns(EDGES[a], b) === true) {
+            return 'ok';
+        }
+        return 'unknown';
+    }
+
+    /* ------------------------------------------------------------ 导出 */
+
+    return {
+        VERSION: VERSION,
+        STATUS: STATUS,
+        SPEC: SPEC,
+        statusOf: statusOf,
+        isTerminal: isTerminal,
+        isActive: isActive,
+        levelOf: levelOf,
+        legality: legality
+    };
+})();

+ 18 - 0
tiktok-auto/tools/build.js

@@ -38,6 +38,12 @@ const VERSION = '1.0.0';
  */
 const MODULE_ORDER = [
     'logger',
+    /* 运行期协议:状态枚举 / 迁移表 / 阈值。
+     * 与 tiktok-app/src/protocol.js 是同一份内容(只挂载点不同),
+     * 由 tiktok-app/tools/protocol-check.js 逐字节比对。
+     * 放在 bridge 之前:bridge 在**函数体内**才取 TikTokAuto.protocol,
+     * 但让它先存在能省掉一类加载期顺序问题。 */
+    'protocol',
     'bridge',
     'config',
     'wait',
@@ -156,10 +162,22 @@ function build(options = {}) {
 
     assertSyntax(source);
     assertNoDangerousCalls(source);
+    assertProtocolPresent(source);
 
     return { source, modules };
 }
 
+/**
+ * 协议模块必须真的进了产物。
+ * ★ `build()` 只遍历 `MODULE_ORDER` —— 新建 src/protocol.js 却忘了加进清单时,
+ *   构建会**成功**,产物里没有它,直到运行期取 `TikTokAuto.protocol` 才炸。
+ */
+function assertProtocolPresent(source) {
+    if (source.indexOf('TikTokAuto.protocol = (function') < 0) {
+        throw new Error('构建产物里没有 TikTokAuto.protocol:src/protocol.js 没进 MODULE_ORDER?');
+    }
+}
+
 function assertSyntax(source) {
     try {
         // 只为语法检查,不执行

+ 313 - 0
tiktok-auto/tools/pc-selftest-bridge.js

@@ -0,0 +1,313 @@
+#!/usr/bin/env node
+
+/* ===========================================================================
+ * pc-selftest-bridge.js —— 引擎侧「桥 + 心跳 + 配置绑定」的 PC 自检
+ *
+ * 跑法:cd tiktok-auto && $NODE tools/pc-selftest-bridge.js
+ * 期望最后一行:BRIDGE_SELFTEST_PASS
+ *
+ * 为什么单独一个文件:`tiktok-auto` 的产物是"加载即执行"的(尾部就是
+ * `TikTokAuto.main.run(...)`),在 PC 上 require 它会直接跑去开 TikTok。
+ * 所以这里跟 `pc-selftest-id.js` 一样,把 **src 源码**拼进一个 vm 沙箱执行,
+ * 只挑纯逻辑模块:protocol / bridge / config / wait / logger。
+ *
+ * ★ 线程桩必须"只登记、不执行":
+ *   心跳是个 `while (running) { sleep(...) }` 的死循环,
+ *   照抄 `android-stub.js` 里 `start: fn => fn()` 的写法会把 PC 进程挂死。
+ *   这里只把函数存起来,心跳的每一拍由用例显式调 `heartbeatOnce()` 驱动。
+ * =========================================================================== */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const SRC = path.join(__dirname, '..', 'src');
+const MODULES = ['logger', 'protocol', 'bridge', 'config', 'wait'];
+
+const RUN_ID = 'run-自检-1';
+const STATE = '/sdcard/tiktok_app/run-state.json';
+const BEAT = '/sdcard/tiktok_app/engine-beat.json';
+const STOP = '/sdcard/tiktok_app/stop.flag';
+const LOG = '/sdcard/tiktok_app/run-log.jsonl';
+const CONF = '/sdcard/Download/tiktok-auto/config.json';
+
+let passed = 0;
+const failures = [];
+
+function check(name, condition, detail) {
+    if (condition) {
+        passed += 1;
+        return;
+    }
+    failures.push(name + (detail ? '  → ' + detail : ''));
+}
+
+function eq(name, actual, expected) {
+    check(name, actual === expected, `期望 ${expected},实际 ${actual}`);
+}
+
+/* ------------------------------------------------------------ 沙箱 */
+
+/**
+ * 造一个隔离的沙箱。
+ * 每个沙箱有自己的内存文件系统与线程桩 —— 需要验"全新进程"的用例
+ * (比如命令行根本没传 --run-id)必须用新沙箱:bridge 一旦 enable,
+ * 进程内就没有"取消启用"这回事。
+ */
+function buildJail(argv) {
+    const mem = {};
+    const threadsState = { started: [], shutDown: 0 };
+    const jail = {
+        mem: mem,
+        threads: threadsState,
+        throwOnStart: false
+    };
+
+    const box = {
+        console: { log: () => { } },
+        Date: Date, Math: Math, JSON: JSON,
+        String: String, Number: Number, Object: Object,
+        Array: Array, RegExp: RegExp, Boolean: Boolean,
+        isNaN: isNaN, parseInt: parseInt, parseFloat: parseFloat,
+        TikTokAuto: {}
+    };
+
+    box.files = {
+        exists: (p) => Object.prototype.hasOwnProperty.apply(mem, [p]),
+        read: (p) => (mem[p] === undefined ? '' : mem[p]),
+        write: (p, content) => { mem[p] = String(content); },
+        /* bridge 的日志落盘用的是 append,少了它 enable 会静默失败 */
+        append: (p, content) => { mem[p] = (mem[p] || '') + String(content); },
+        ensureDir: () => true
+    };
+
+    /* ★ 只登记不执行 */
+    box.threads = {
+        start: (fn) => {
+            if (jail.throwOnStart) {
+                throw new Error('自检:模拟线程起不来');
+            }
+            threadsState.started.push(fn);
+            return { id: 'th-' + threadsState.started.length };
+        },
+        shutDownAll: () => { threadsState.shutDown += 1; }
+    };
+    box.sleep = () => { /* 心跳循环不会在自检里真跑,这里只是占位 */ };
+    box.engines = { myEngine: () => ({ execArgv: argv }) };
+
+    vm.createContext(box);
+
+    let src = 'var TikTokAuto = this.TikTokAuto;\n';
+    for (const name of MODULES) {
+        src += fs.readFileSync(path.join(SRC, `${name}.js`), 'utf8') + '\n';
+    }
+    vm.runInContext(src, box);
+
+    jail.api = box.TikTokAuto;
+    return jail;
+}
+
+const jail = buildJail(['--run-id', RUN_ID, '--state', STATE, '--stop-flag', STOP, '--log', LOG, '--config', CONF]);
+const memFiles = jail.mem;
+const threadsState = jail.threads;
+
+const T = jail.api;
+const bridge = T.bridge;
+const config = T.config;
+
+/* logger 用真的,但把行收集起来(bridge.enable 会 setSink 到 files.append) */
+check('沙箱里加载出了 protocol / bridge / config',
+    !!T.protocol && !!bridge && !!config);
+
+const readJson = (p) => {
+    try { return JSON.parse(memFiles[p] || ''); } catch (e) { return null; }
+};
+
+/* ------------------------------------------------------------ 1. 启动握手 */
+
+const injected = bridge.fromExecArgv(null);
+eq('argv 里的配置路径被交给 config.resolve', injected.runtime.configPath, CONF);
+eq('enable 之后桥是启用的', bridge.isEnabled(), true);
+eq('runId 与命令行一致', bridge.currentRunId(), RUN_ID);
+
+let state = readJson(STATE);
+check('第一次上报就写出了 run-state', !!state);
+eq('run-state 带协议版本 v=2', state && state.v, 2);
+eq('第一个状态是 starting', state && state.status, 'starting');
+eq('runId 写进了状态', state && state.runId, RUN_ID);
+eq('心跳线程被真的请求了一次', threadsState.started.length, 1);
+check('run-state 里声明了"有心跳"',
+    !!(state && state.engine && state.engine.capabilities && state.engine.capabilities.heartbeat === true),
+    JSON.stringify(state && state.engine));
+
+let beat = readJson(BEAT);
+check('enable 时已经同步写了第一拍心跳', !!beat, '没写 beat 文件');
+eq('心跳带协议版本', beat && beat.v, 2);
+eq('心跳 runId 与本轮一致', beat && beat.runId, RUN_ID);
+eq('心跳停止确认初始为 0(还没人点停止)', beat && beat.stopSeenAt, 0);
+check('心跳序号从 1 开始', beat && beat.seq >= 1, String(beat && beat.seq));
+
+/* ------------------------------------------------------------ 2. 序号单调 */
+
+bridge.report({ phase: '启动应用' });
+state = readJson(STATE);
+const seq1 = state.seq;
+bridge.report({ status: 'running', phase: '干活' });
+state = readJson(STATE);
+check('report 的 seq 单调递增', state.seq > seq1, `${seq1} → ${state.seq}`);
+eq('report 带着 startedAt', typeof state.startedAt, 'number');
+eq('report 后的状态是 running', state.status, 'running');
+check('report 不清掉 engine.task 之类的已写字段',
+    state.engine && typeof state.engine === 'object');
+
+bridge.setTask('video');
+state = readJson(STATE);
+eq('setTask 把任务名写进 engine.task', state.engine && state.engine.task, 'video');
+
+/* ------------------------------------------------------------ 3. 心跳 sticky */
+
+const firstBeatAt = readJson(BEAT).beatAt;
+bridge.heartbeatOnce();
+beat = readJson(BEAT);
+check('再写一拍:beatAt 前进', beat.beatAt >= firstBeatAt);
+check('再写一拍:seq 前进', beat.seq >= 2, String(beat.seq));
+
+/* 别人的 runId 写在 stop.flag 里 → 不算我的停止请求 */
+memFiles[STOP] = 'run-别的运行';
+bridge.heartbeatOnce();
+eq('别人的停止标志不会被当成自己的', readJson(BEAT).stopSeenAt, 0);
+
+/* 我自己的 runId → 记下时刻,而且**后续每一拍都要带着** */
+memFiles[STOP] = RUN_ID;
+bridge.heartbeatOnce();
+const seen1 = readJson(BEAT).stopSeenAt;
+check('本轮停止标志被心跳看见', seen1 > 0, String(seen1));
+bridge.heartbeatOnce();
+const seen2 = readJson(BEAT).stopSeenAt;
+eq('停止确认是 sticky 的(不会被下一拍抹掉)', seen2, seen1);
+bridge.heartbeatOnce();
+eq('再跳几拍也还是同一个时刻', readJson(BEAT).stopSeenAt, seen1);
+
+/* ------------------------------------------------------------ 4. 停止信号 */
+
+let stopped = false;
+let reportedStopping = false;
+try {
+    bridge.checkStop();
+} catch (error) {
+    stopped = bridge.isStopSignal(error);
+}
+eq('checkStop 命中时抛 StopSignal', stopped, true);
+state = readJson(STATE);
+reportedStopping = state.status === 'stopping';
+eq('抛之前先把 stopping 上报给宿主', reportedStopping, true);
+
+/* 空内容的 flag 不该触发(残留文件的兜底) */
+memFiles[STOP] = '   ';
+let quiet = true;
+try { bridge.checkStop(); } catch (e) { quiet = false; }
+eq('stop.flag 只有空白时不触发停止', quiet, true);
+memFiles[STOP] = RUN_ID;
+
+/* ------------------------------------------------------------ 5. 配置绑定 */
+
+/* 双方都非空且不等 → 拒绝(挡住迟到的旧引擎读到新配置就开跑) */
+memFiles[CONF] = JSON.stringify({ runtime: { runId: 'run-另一轮' } });
+let mismatch = null;
+try {
+    config.resolve({ runtime: { configPath: CONF } });
+} catch (error) {
+    mismatch = error;
+}
+check('配置属于另一次运行时被拒绝', !!mismatch && mismatch.name === 'ConfigError',
+    mismatch ? String(mismatch.message) : '没有抛错');
+
+/* 配置里没有 runId(独立运行/老界面)→ 必须放行,否则引擎永远起不来 */
+memFiles[CONF] = JSON.stringify({});
+let standalone = null;
+try {
+    config.resolve({ runtime: { configPath: CONF } });
+} catch (error) {
+    standalone = error;
+}
+eq('配置里没写 runId 时放行(独立运行)', standalone, null);
+
+/* 本轮没有 runId(命令行没传,比如直接在 AutoX 里打开产物)→ 同样放行。
+ * 这种"进程从没 enable 过"的状态只能在新沙箱里验:enable 一旦发生就无法撤销。 */
+const noArgvJail = buildJail([]);
+noArgvJail.mem[CONF] = JSON.stringify({ runtime: { runId: 'run-另一轮' } });
+let noArgvError = null;
+try {
+    noArgvJail.api.config.resolve({ runtime: { configPath: CONF } });
+} catch (error) {
+    noArgvError = error;
+}
+eq('引擎自己没 runId 时放行(命令行直跑)', noArgvError, null);
+
+/* 一致时正常通过 */
+memFiles[CONF] = JSON.stringify({ runtime: { runId: RUN_ID } });
+let okError = null;
+let resolved = null;
+try {
+    resolved = config.resolve({ runtime: { configPath: CONF } });
+} catch (error) {
+    okError = error;
+}
+eq('runId 一致时正常解析', okError, null);
+eq('解析结果里带着 runId', resolved && resolved.runtime.runId, RUN_ID);
+
+/* 配置文件本身是坏的 → 用默认值继续(不能让引擎直接不跑) */
+memFiles[CONF] = '{ 半截 JSON';
+let brokenError = null;
+try {
+    resolved = config.resolve({ runtime: { configPath: CONF } });
+} catch (error) {
+    brokenError = error;
+}
+eq('配置文件坏了也不拦启动(退回默认值)', brokenError, null);
+
+/* ------------------------------------------------------------ 6. 收尾 */
+
+bridge.finish('finished', { ok: true, videos: 2 });
+state = readJson(STATE);
+eq('finish 写出终态', state.status, 'finished');
+check('finish 带上了 summary', !!state.summary);
+check('终态里 message 是人话', typeof state.message === 'string' && state.message.length > 0,
+    state.message);
+
+bridge.heartbeatStop();
+eq('heartbeatStop 之后心跳标记为停', bridge.isHeartbeatUp(), false);
+const seqAfterStop = readJson(BEAT).seq;
+eq('停心跳后不会自己再写(线程桩本来也没跑)', readJson(BEAT).seq, seqAfterStop);
+
+/* 心跳线程起不来 → 明确告诉宿主,让它退回无心跳阈值 */
+jail.throwOnStart = true;
+const up = bridge.heartbeatStart();
+eq('线程起不来时 heartbeatStart 返回 false', up, false);
+eq('起不来时 isHeartbeatUp 是 false', bridge.isHeartbeatUp(), false);
+jail.throwOnStart = false;
+
+/* 没启用的桥(独立运行):所有上报都是空操作,一个文件都不写 */
+const idleJail = buildJail([]);
+idleJail.api.bridge.report({ status: 'running' });
+idleJail.api.bridge.finish('finished', { ok: true });
+idleJail.api.bridge.heartbeatStart();
+check('未启用的桥不会凭空写 run-state', idleJail.mem[STATE] === undefined);
+check('未启用的桥不会凭空写心跳', idleJail.mem[BEAT] === undefined);
+eq('未启用的桥 heartbeatStart 返回 false',
+    idleJail.api.bridge.heartbeatStart(), false);
+
+/* ------------------------------------------------------------ 汇总 */
+
+console.log('');
+console.log(`通过 ${passed} 项`);
+if (failures.length > 0) {
+    console.log(`失败 ${failures.length} 项:`);
+    failures.forEach((f) => console.log('  ✗ ' + f));
+    console.log('');
+    console.log('BRIDGE_SELFTEST_FAILED');
+    process.exit(1);
+}
+console.log('BRIDGE_SELFTEST_PASS');