# 界面 ↔ 引擎 运行期协议(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` 替换挂载行即可),不要两边手改。