阅读时间 7 分钟

ResumeGovernor: Root Cause Analysis of an Auto-Pause That Refused to Resume

一个自动恢复机制显示'允许恢复',另一个显示'继续暂停'。它们读的是同一个数据库。问题出在哪里?

你的交易系统暂停了。不是因为你按了暂停键,而是因为风控系统检测到了一些不对的信号,自动进入了保护模式。

这是预期行为。好的交易系统应该会在不确定时停下来。

但问题来了:你准备恢复交易时,Dashboard 告诉你"继续暂停",CLI 工具告诉你"状态未知"。两个组件读的是同一个数据库,跑的是同一个项目的代码,但给出了不同的答案。

这不是"系统有 bug"这么简单。这是一个代码一致性分叉——分布式系统中,不同组件对"当前状态"的理解出现了分歧。

这篇文章是 research only,not financial advice;backtests are not live performance;not an instruction to trade。

如果你刚来 ProBitForge,可以先看系统定位:<https://www.probitforge.com/what-probitforge-is-building/>

1) 症状:两个组件说了不同的话

我们的交易系统中有一个叫 Resume Governor 的组件。它的职责很简单:在系统暂停后,判断是否满足恢复条件。

Resume Governor 不是拍脑袋说"可以恢复了"。它检查一组 guard conditions:

  • Evolution Gate 状态(证据完整性是否达标)
  • 最后一次慢道建议(哨兵系统的最新评估)
  • Freqtrade 当前状态(paused / running)
  • 证据完整率(当前 0.9476 vs 阈值 0.95)

在一次例行检查中,我们发现了不一致:

| 组件 | 返回值 | 含义 | | --- | --- | --- | | Dashboard API | status=BLOCK | 阻断,不允许恢复 | | CLI 工具 | status=UNKNOWN | 无法判断 |

两个组件都指向同一个 SQLite 数据库。但一个说 BLOCK,一个说 UNKNOWN。

2) 根因:一个函数消失了

排查过程遵循标准的 RCA(Root Cause Analysis)方法论:先看差异在哪,再追溯差异的来源。

差异的源头是一个叫 compute_evolution_gate_status() 的函数。这个函数读取哨兵记忆库(sentinel_memory.db),输出三个信息:

  • status(BLOCK / READY / UNKNOWN)
  • blocked_reasons(阻断原因列表)
  • metrics(当前指标值和阈值)

Dashboard 进程中,这个函数存在并且正常运行。它读取数据库,计算证据完整率 0.9476,发现低于阈值 0.95,返回 BLOCK + evidence_incomplete

但在 CLI 工具调用的线上脚本中,这个函数缺失了。不是被删除——是在某次代码同步时没有被包含进去。CLI 工具调用了一个不存在的函数,异常被捕获后返回了 UNKNOWN

根因:本地开发环境和线上服务器的代码出现了分叉。mutation_engine.py 在线上版本缺少了 compute_evolution_gate_status() 函数。

3) 修复:恢复函数,不放宽门禁

修复方案很直接:把缺失的函数恢复到线上脚本中。

但这里有一个关键区分:修复代码一致性 ≠ 放宽门禁。

修复前,CLI 返回 UNKNOWN。修复后,CLI 返回 BLOCK——和 Dashboard 一致了。门禁仍然在阻断,因为 evidence_complete_rate=0.9476 确实低于 min_evidence_complete_rate=0.95

修复后的完整返回:

| 指标 | 值 | | --- | --- | | decision | HOLD | | resume_allowed | false | | evolution gate status | BLOCK | | blocked_reasons | evidence_incomplete | | evidence_complete_rate | 0.9476 | | min_evidence_complete_rate | 0.95 | | freqtrade state | paused | | freqtrade runmode | dry_run | | last slow recommendation | UPGRADE_ALERT |

注意两个阻断条件同时存在:evolution gate BLOCK(证据不完整)+ 慢道建议 UPGRADE_ALERT(哨兵认为系统还需要升级)。两个条件都满足才能恢复,当前两个都不满足。

4) 验证:三端一致性

修复后,我们在三个端点验证了一致性:

本地编译验证mutation_engine.pyresume_governor.py 都通过 Python 编译检查。

线上编译验证:服务器上的相同文件也通过编译检查。

API 端点验证:Dashboard API 的 /api/gate_status/api/resume_governor 都返回 BLOCK / HOLD,指向同一个指标。

CLI 验证resume_governor.py --source codex_final_verify 返回 decision=HOLDresume_allowed=false

三个端点现在给出了一致的答案:不允许恢复。 这是预期结果——不是修复失败,是门禁在正确工作。

5) 为什么"UNKNOWN"比"BLOCK"更危险

表面上看,CLI 返回 UNKNOWN 似乎只是一个显示问题。但在自动化交易系统中,UNKNOWNBLOCK 更危险。

想象这个场景:一个自动恢复脚本读取 CLI 的返回值来决定是否恢复交易。

  • 如果返回 BLOCK:脚本知道不该恢复,等待。
  • 如果返回 UNKNOWN:脚本可能把它解读为"没有明确阻断",然后尝试恢复。

UNKNOWN 不是安全的默认值。它是一个信息黑洞——你不知道该怎么做,但你必须做点什么。 在交易系统中,"必须做点什么"往往导致做错了什么。

正确的默认值应该是 BLOCK——如果你不确定是否该恢复,那就不要恢复。这就是 fail-closed 原则:不确定时,进入最保守的状态。

6) 更深层的教训:代码一致性是分布式系统的基础设施

这个 bug 的根因不是"某个函数写错了"。它是"代码在不同环境中不一致"。

在单体应用中,代码一致性不是问题——你部署一个版本,所有组件都是同一个版本。但在分布式系统中(即使只是"Dashboard 进程 + CLI 工具 + 线上脚本"这种轻度分布式),代码一致性变成了一个需要主动管理的基础设施。

我们的经验教训:

教训 1:共享逻辑必须单一来源。

compute_evolution_gate_status() 不应该在两个文件中各写一份。它应该在一个模块中定义,Dashboard 和 CLI 都 import 同一个模块。

教训 2:同步机制需要验证。

"把本地文件同步到线上"不是一个原子操作。你需要验证同步后的文件确实包含了所有预期的函数。一个简单的 py_compile + 关键接口 smoke test 就能发现这个问题。

教训 3:状态端点需要一致性审计。

定期比较不同端点对同一状态的返回值。如果 Dashboard 说 BLOCK、CLI 说 UNKNOWN,这个差异本身就是一个告警信号。

7) 给做分布式交易系统的人

如果你的交易系统有多个组件读取同一份数据:

1. 共享逻辑单一来源:状态计算函数只写一次,所有组件 import 同一个模块。 2. 同步后验证:每次部署或文件同步后,跑关键接口的 smoke test。 3. fail-closed 默认值:不确定时返回最保守的状态(BLOCK),不返回 UNKNOWN。 4. 一致性审计:定期比较多个端点的返回值,差异本身就是告警。 5. 修复 ≠ 放宽:修复代码一致性问题时,明确区分"让组件说同一句话"和"改变它们说的话"。 6. 记录安全边界:每次修复后明确声明"本次没有修改交易配置、没有触发交易恢复、没有写入交易数据库"。

8) 再次强调

这篇文章是 research only,not financial advice;backtests are not live performance;not an instruction to trade。

resume_allowed=false 在这篇文章中是预期结果,不是修复失败。系统暂停是因为证据完整率差一点点(0.9476 vs 0.95),哨兵建议 UPGRADE_ALERT。这些条件需要被满足,而不是被绕过。

在交易系统中,"不允许恢复"经常是正确的答案。你的工作不是让系统恢复,而是让系统有资格恢复。