开发 Nemu 的第四天:真实 Beta 如何把一帧判断改造成状态机

第三天,Nemu 已经能把钉钉单聊的本地画面变化变成受控候选。但真实 Beta 很快指出一个问题:桌面行为不是一组彼此独立的截图,而是一段有前因后果的连续状态。
前三篇分别记录了 8 月 26、27、28 日。8 月 29--30 日是周末,main 没有新提交,所以第四篇继续按"有效开发日"编号,记录下一个有实现进入可信主线的工作日:2026 年 8 月 31 日。
这一天只有两个合并提交,但它们都来自真实验收的反例:
- B1 暴露了"重置计数"和"重新绑定"并没有形成一条原子边界;
- B3 暴露了"当前帧没有输入变化"不等于"用户已经结束输入"。
最终,原本偏瞬时的判断被改造成了两套明确的状态机制:Beta Epoch 与 Stateful Input Guard。

B1:界面上的"清零",为什么不一定代表新一轮测试
第三天已经有一组 Session-only Beta 指标:本地采样、Monitor 事件、ASK、AUTO、Vision 尝试,以及人工标记的正常命中、误触、重复和漏检。
最初的"重置计数"只是把 Main 进程里的显示数字变成零。但 Core 仍然可能保留:
- 已累计的
local_sample_count; - 有界事件队列里的旧事件;
- 尚未成熟的候选;
- 已准备但未执行的 Vision Candidate;
- 正在进行中的异步 Vision 请求。
这样得到的"新一轮"其实混入了上一轮状态。即使界面显示为零,旧候选也可能随后返回,旧采样总数也会让测试人员误以为新一轮已经发生了大量采样。
问题的本质不是 UI 计数器,而是缺少一个跨 Core 与 Electron Main 的测试轮次边界。
Beta Epoch:清理瞬态工作,但保留用户确认的绑定
第四天为此增加了 reset-beta-epoch。一次重置不再是本地变量赋零,而是一条明确的策略边界:

这里有一个刻意保留的状态:当前不可变 Direct Binding 不会因为重置测试数据而丢失。
重置 Beta 轮次和重新授权一个聊天不是同一件事。前者只清理瞬态测试状态,后者才改变用户批准的会话边界。如果每次重置都强制重新绑定,测试流程会变得笨重;如果重置保留旧候选,又会污染新一轮数据。

图中蓝色锚定关系表示仍然有效的 Direct Binding;越过重置边界的旧候选、旧事件和旧 Vision 结果则全部失效。这里的"清除"只指本地待处理状态,已经发送到云端的数据仍然无法撤回。
为什么要同时有 Core Generation 和 Main Epoch
Core 与 Main 各自拥有不同的异步工作:
- Core 的
generation用于让重置期间已经开始的采样结果失效; - Main 的
betaEpoch用于忽略重置前已经开始、重置后才返回的 Vision 成功或失败回调。
text
executionEpoch != current betaEpoch
↓
ignore stale completion
↓
do not increment new-round metrics
do not reopen old Portal activity
只有两端都切断旧异步结果,新一轮计数才真正干净。
为什么事件计数必须由 Main 单调持有
Core 的事件列表是有界诊断队列,最多只保留有限数量的近期事件。它适合读取最新候选,不适合反推整轮测试到底发生过多少事件。
如果用"当前队列长度"充当累计计数,就会出现:
text
产生 40 个事件
Core 队列最多保留 32 个
界面错误显示:32
第四天把 Beta 事件计数改为 Main 拥有的单调 Session Counter。Core 事件被 Main 首次消费时计数一次,之后即使 Core 队列滚动淘汰旧事件,累计值也不会倒退。
本地采样则使用另一种语义:重置时读取一份 fresh Core Status,把当时的 local_sample_count 记录为 local_sample_baseline,界面只显示:
text
本轮采样 = 当前 Core 累计采样 - 本轮基线
这既不要求 Core 破坏自己的运行累计值,也能让测试人员看到真正从零开始的一轮。
重新绑定:旧 Binding 不能掩盖一次新失败
B1 还暴露了一个更隐蔽的问题:用户点击"重新绑定"后,新绑定可能因为前台焦点没有及时回到钉钉而失败,但旧 binding_ref 仍然存在。
旧逻辑主要通过"是否存在 Binding"计算 needs_direct_binding,因此可能得到一个矛盾状态:
text
旧 binding_ref 仍存在
新绑定返回 FOREGROUND_WINDOW_MUST_BE_DINGTALK
↓
needs_direct_binding = false
↓
重试循环误以为已经成功
修复后,只要当前存在未解决的 bindFailureCode,状态就继续要求绑定。只有一次新的绑定真正成功,失败码才会被清除。旧绑定不能替新操作"冒充成功"。
六秒截止时间必须覆盖整条调用链
第三天已经为焦点交接设计了"最多等待 6 秒,每 500 ms 重试"。但仅在外层写一个重试循环还不够。
一次绑定前可能先后执行:
- 获取 Core 状态;
- 必要时同步 Consent;
- 读取旧事件,避免回放;
- 请求绑定当前 Direct 会话。
如果其中任意一个 HTTP 请求仍使用自己的 8 秒超时,所谓"6 秒截止"就只是表面约束。用户可能等到 8 秒甚至更久,外层才得到控制权。
第四天改为传递同一个绝对 deadlineMs。每个 Loopback 请求都会计算剩余时间,并用 AbortController 在截止点真实终止:

状态、授权同步或绑定中任何一段卡住,都会得到有界的 DINGTALK_DIRECT_BIND_DEADLINE_EXCEEDED,而不是无限延长交接窗口。
B3:为什么"这一帧没在输入"还会误触发
输入抑制的第一版逻辑看起来合理:如果输入区发生变化,就把当前帧标记为 user_is_typing,不产生候选。
问题在于,真实编辑会影响不止一帧:
- 输入文字让输入框变高,聊天区域发生重排;
- 删除草稿让输入框恢复,消息列表再次位移;
- 光标、候选框或微小渲染变化可能短暂出现;
- 输入区先稳定,聊天区可能晚一帧才稳定。
因此会出现这样的序列:
text
Frame A:输入区变化 → 正确抑制
Frame B:输入区暂时不变,但聊天区仍在回流
Frame C:旧逻辑把回流误认为新消息
单帧布尔值无法表达"用户刚刚输入过,界面还在恢复"。
Stateful Input Guard:输入结束后仍有隔离期
第四天加入了有状态的输入 Guard。只要检测到输入活动,它会:
- 立即清除尚未成熟的消息候选;
- 进入
input_guard_active; - 记录最后输入时间;
- 同时收集聊天区和输入区的恢复帧;
- 把输入引起的布局变化刷新为 Guard Baseline。
输入变化消失后,Monitor 也不会立刻恢复候选判断。它必须同时满足:
- 至少 2 秒没有新的输入活动;
- 聊天区域连续稳定;
- 输入区域连续稳定。

恢复成功的那一帧仍然只返回 USER_TYPING_RECOVERED,不会顺便成熟候选。只有之后真正出现的新变化,才重新进入消息判定链。
这相当于把输入及其布局余震放进一个短暂隔离区。
Fail closed 也不能变成"永远锁死"
状态化 Guard 的另一个风险,是一次细小的输入区渲染噪声可能让 Monitor 永久停在恢复状态。
因此测试不仅覆盖"输入不能误触发",也覆盖相反方向:一个微小像素噪声触发 Guard 后,只要经过安静、稳定恢复,之后的真实对方消息仍然必须恰好产生一个 ASK 事件。
这体现了 fail closed 的准确含义:不确定时暂缓,而不是发生一次不确定后永久失能。
重置测试轮次,也必须重置状态机
B1 与 B3 最终在 reset_beta_epoch 汇合。
新一轮场景开始时,Core 会:
- 增加 Generation,使正在采集的旧帧失效;
- 清空旧事件和 Candidate Plan;
- 清空输入 Guard 及恢复帧;
- 在仍可信的 Live Tail 上刷新基线;
- 保留当前不可变 Direct Binding;
- 返回
BETA_SCENARIO_EPOCH_RESET。
同时,正在执行的 Vision 会被 best-effort 取消,Main 也会通过 Epoch 拒绝旧回调。这样测试人员点击"重置"后,看到的才是一条真正干净的场景边界。
这一天增加了哪些回归证据
新增测试专门覆盖过去容易被"看起来没问题"掩盖的序列:
| 回归场景 | 预期结果 |
|---|---|
| 草稿输入、聊天重排、清空草稿 | 整段都不成熟候选 |
| 已有候选后开始输入 | 旧候选立即隔离并清除 |
| 输入结束后的恢复帧 | 必须安静且双区域稳定,仍不触发 |
| 恢复后出现新的对方消息 | 恰好产生一次 ASK |
| 微小输入区噪声 | 可以恢复,不永久锁死 Monitor |
| Reset 发生在采样进行中 | 旧采样结果失效,Binding 保留 |
| Reset 后出现新的真实消息 | 新一轮仍能正常检测 |
| Reset 前已有 Candidate / Vision | 新一轮不可观察到旧结果 |
| 状态、配置或绑定请求挂起 | 在统一截止时间内被真实 Abort |
| 旧 Binding 存在但重新绑定失败 | 继续显示需要绑定并继续有界重试 |
合并提交中还保留了两份 Windows CI 证据:B1 修复对应 CI #287 SUCCESS,B3 修复对应 Windows Nemu CI #298 SUCCESS。它们证明提交当时通过了项目 CI,但仍不等于 B1--B10 的完整真实钉钉验收。
第四天的代码变化
从第三天日终 fee425d 到第四天日终 28a7364:
- 2 个合并提交;
- 13 个文件变化;
- 966 行新增;
- 105 行删除;
- 日终快照仍有 214 个受 Git 跟踪文件;
- Python 测试文件 45 个;
- Python
test_*函数从 311 增至 318。
两个提交分别是:
text
d5ba42f fix: 修复钉钉 B1 诊断计数与重新绑定边界 (#80)
28a7364 fix: 加固 B3 输入抑制与 Beta 测试轮次重置 (#83)
这一天没有增加新的 Screenshot 范围、Cloud Egress 类型或自动操作权限。变化集中在已有 Direct Beta 内部的状态一致性和诊断可信度。
在第四天日终快照上重新验证
本文的 fresh 验证固定在 2026 年 8 月 31 日最后一个 main 提交:
text
28a73646f64903dc5c9f9d6f07328ed2c56b4ab9
fix: 加固 B3 输入抑制与 Beta 测试轮次重置 (#83)
独立 Worktree 的结果:
text
Python / pytest
344 collected
343 passed
1 skipped
1 warning
Desktop
Renderer TypeScript typecheck: PASS
Electron Main TypeScript typecheck: PASS
Vite production build: PASS
DingTalk Vision spike
6 tests
6 passed
0 failed
与第三天日终相比,Python 测试集合从 335 项增加到 344 项。唯一跳过项仍来自既有 Windows 文件系统能力测试;唯一 warning 仍是测试依赖中的 Starlette / httpx 弃用提醒。
第四天最重要的四个结论
1. 桌面感知必须理解时间,而不只是理解当前帧
输入、布局回流和稳定恢复是一段过程。只把 user_is_typing 当成当前帧布尔值,会在下一帧重新制造误判。
2. "重置"是跨进程策略边界,不是 UI 赋零
Core Candidate、采样 Generation、Main Counter 和异步 Vision 回调必须一起切换 Epoch,新一轮数据才可信。
3. 有界截止时间必须传到最内层 I/O
外层重试 6 秒、内层请求却能挂 8 秒,不是真正的 6 秒限制。Deadline 必须沿整条调用链传播,并能真实 Abort。
4. Fail closed 的另一面是可恢复
不确定时不触发,但安静稳定后必须恢复观察。安全机制如果永久锁死产品,也不能算正确实现。
验收状态仍然没有改变
第四天显著收紧了 B1 和 B3,但不能据此把钉钉能力标记为已支持。正式状态仍是:
BETA GATE / NOT SUPPORTED
B1 与 B3 的修复证明真实测试正在产生价值,却也证明单一视觉通道仍然容易受到窗口布局、输入重排和时间连续性的影响。只有 B1--B10 在真实 Windows 环境全部满足零误触、零重复和受控场景零漏检,才能讨论 SUPPORTED。
下一篇:B5 为什么漏掉了真正的新回复
解决"不要把用户输入当成来信"之后,下一轮真实测试会走向相反的问题:用户没有输入,对方确实发来了一条消息,为什么 Nemu 仍可能没有产生 B5 事件?
这会把问题继续推进到帧连续性、变化方向和安全诊断轨迹:不仅要知道"没有触发",还要知道候选在哪一道本地 Gate 消失了。