Files
sanguo_moziplus_v2/docs/design/24-compact-detection-fix.md
T
cfdaily bcb8ced17a
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
fix(spawner): address PR#36 review feedback (M1+M2+S1+S2)
2026-06-11 21:40:09 +08:00

7.8 KiB
Raw Blame History

§24 — Compact 检测方案修正

状态:v3rotation-only),待实施 作者:庞统 日期:2026-06-11 框架:基于 §07 Spawner Acquire-First 评审:仲达 3 轮评审(v1 trajectory → v2 gateway precheck → v3 rotation-only

1. 问题

1.1 现象

2026-06-11 14:02pangtong main session 正在做 compaction13:59:26 开始,14:06:00 结束,耗时 ~6.5 分钟),但 spawner Phase 2 检查时 compact=False,仍然 spawn 了新进程处理 Mail,导致两个 agent turn 撞车。

1.2 根因

当前 compact 检测方法 _check_recent_compaction_jsonl 扫描 session jsonl,查找 type == "compaction" 事件。这是 compact 完成后才写入的摘要记录,compact 进行中时不存在 → 漏检。

同时 Gateway 触发 compact 时先把 session 标为 done,所以 status=running + lock_pid_alive 检查也无效。14:02:11 实际状态:status=done lock_pid_alive=False compact=False——三个检查全部漏过。

2. 方案:Rotation-Only 检测(v3

2.1 核心洞察(仲达 v2 评审)

v2 方案依赖 [context-overflow-precheck] route=compact 作为开始标志。但实测数据:

Agent Rotation 事件 有 Precheck 无 Precheck
pangtong 7 3 4
simayi 3 0 3

10 次 compact 只有 3 次有 precheck,覆盖率 30%。 原因:post-compact retry 触发的后续 compact 不经过 precheck 日志路径。

结论:开始标志不可靠。反转检测逻辑——只用可靠的 rotation 事件作为信号。

2.2 Rotation 事件

Gateway 日志中 [compaction] rotated active transcript after compaction (sessionKey=...) 事件:

  • 100% 覆盖率:全天 10 次 compact 全部有 rotation 事件
  • 含 sessionKey:可以精确匹配目标 session
  • JSON 格式:易解析

2.3 检测逻辑

1. 读 gateway 日志(当天 + 昨天尾部)
2. 按目标 sessionKey 过滤 compact 相关事件
3. 从后往前找最后一条 rotation 事件:
   a. 如果 rotation 事件在窗口内(< 120s)→ compact=True
      (刚完成一轮 compact,可能还在 post-compact retry 循环中)
   b. 无 rotation 事件或超出时间窗口 → compact=False

**注意:此方案仅检查 rotation 事件,不检查 model.completed 等其他事件。**
这是有意为之的保守策略:不检查正常 turn 事件意味着 compact 完成后的
120s 内都可能被误判为 compact 进行中,但误判代价低(仅 skip 一轮 ticker),
宁可多拦也不漏放。

为什么 rotation + 时间窗口就够了?

  • compact 后 Gateway 会 retry prompt
  • 如果 retry 又触发 overflow → 又一轮 compact → 又一个 rotation 事件
  • 如果 retry 成功 → 正常 turn → 新的 session.started / model.completed 事件
  • 所以「最近一个事件是 rotation 且时间很近」= compact 循环还在进行

2.4 时间窗口选择

compact 通常耗时 1-10 分钟。post-compact retry 如果又触发 compact,间隔通常 <60 秒。

  • 窗口太短(如 30s:可能漏掉 compact 结束后正在 retry 但还没触发下一轮的场景
  • 窗口太长(如 900s:compact 完成后正常工作很久了还误判
  • 推荐 120scompact 循环中两次 rotation 间隔通常 <60s120s 有足够余量

误判代价低(skip 一轮 ticker),所以宁可多拦也不漏放。

3. 改动范围

文件 改动 行数估计
spawner.py 新增 _check_compact_in_progress_gateway() ~40 行
spawner.py _check_session_state() 调用新方法,替换旧方法 ~5 行
spawner.py 日志路径配置化 ~5 行
docs/design/07-spawner-acquire-first.md §4.5 O5 更新 ~10 行
docs/design/24-compact-detection-fix.md 本文档 已有

总计 ~60 行代码改动。

4. 实现细节

4.1 核心方法

def _check_compact_in_progress_gateway(self, session_key: str, window_seconds: int = 120) -> bool:
    """检查 gateway 日志,判断指定 session 是否刚完成 compact(可能在 retry 循环中)。
    
    检测逻辑:如果目标 session 最近一个事件是 rotation 且在窗口内,视为 compact 进行中。
    """
    log_paths = self._get_recent_gateway_logs()
    if not log_paths:
        return False
    
    now = datetime.now(timezone.utc)
    window_start = now - timedelta(seconds=window_seconds)
    
    last_rotation_time = None
    
    for log_path in log_paths:
        if not os.path.exists(log_path):
            continue
        
        with open(log_path, 'rb') as f:
            # 读尾部 2MB
            f.seek(0, 2)
            size = f.tell()
            f.seek(max(0, size - 2 * 1024 * 1024))
            
            for raw_line in f:
                try:
                    obj = json.loads(raw_line)
                except (json.JSONDecodeError, ValueError):
                    continue
                
                msg = obj.get("message", "")
                ts_str = obj.get("time", "")
                
                # 只看包含目标 sessionKey 的事件
                if session_key not in msg:
                    continue
                
                # rotation 事件
                if "[compaction] rotated active transcript" in msg:
                    try:
                        event_time = datetime.fromisoformat(ts_str)
                        if last_rotation_time is None or event_time > last_rotation_time:
                            last_rotation_time = event_time
                    except (ValueError, TypeError):
                        continue
    
    if last_rotation_time is not None:
        return last_rotation_time >= window_start
    
    return False

4.2 日志路径

def _get_recent_gateway_logs(self) -> list:
    """获取当天和昨天的 gateway 日志路径"""
    log_dir = os.environ.get("OPENCLAW_LOG_DIR", "/tmp/openclaw")
    today = datetime.now().strftime("%Y-%m-%d")
    yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
    paths = []
    for d in [today, yesterday]:
        p = os.path.join(log_dir, f"openclaw-{d}.log")
        if os.path.exists(p):
            paths.append(p)
    return paths

4.3 Phase 2 集成

# 在 _check_session_state 中,不依赖 status,直接检查
compact = self._check_compact_in_progress_gateway(session_key)
if not compact:
    compact = self._check_recent_compaction_jsonl(...)  # fallback
    
if compact:
    blockers.append(("session_compacting", None))

5. 边界情况

边界情况 处理
日志文件不存在 返回 Falsefallback 到旧方法)
跨天 compact 同时检查昨天日志尾部
compact 失败(无 rotation rotation 事件不会出现 → 检测不到 → 回退到旧方法
误判(compact 完成后正常工作中) 时间窗口 120s 内可能被误判,但代价低(skip 一轮 ticker)。不检查正常 turn 事件,是保守策略

6. 测试验证

6.1 单元测试

  • _check_compact_in_progress_gateway
    • rotation 事件在窗口内 → True
    • rotation 事件超出窗口 → False
    • 无 rotation 事件 → False
    • 日志不存在 → False
    • sessionKey 不匹配 → False

6.2 集成验证

  • pytest -m "not e2e" 全量测试

7. 关联设计

  • §07 Spawner Acquire-First(§4.5 O5 compact 扫描条件收紧)
  • §08 Classify Outcome Optimizationcompact_hanging 处理)

8. 评审记录

  • v1trajectory jsonl 间接推断 → 仲达指出 trajectoryPath 不可用、需多文件等 3 个问题
  • v2gateway 日志 precheck 开始标志 → 仲达指出开始标志覆盖率仅 30%,建议 rotation-only
  • v3rotation-only(当前版本)→ 仲达已确认方向,待代码实现后再审