Files
sanguo_moziplus_v2/docs/design/15-compact-detection-fix.md
cfdaily eccb4d2723
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
docs: 设计文档编号重排(20→14, 24→15) + 已完成文档状态标注更新
2026-06-13 10:12:39 +08:00

15 KiB
Raw Permalink Blame History

§15 — Compact 检测方案修正

状态: 已完成(gateway log + jsonl 配对) 作者:庞统 日期:2026-06-11v4),2026-06-13v5 框架:基于 §07 Spawner Acquire-First 评审:仲达 4+2 轮评审 备选方案:B(内存 flag + sessions.json status),见 §2B


0. v5 方案(已实现)

0.1 方案概述

gateway log 开始标记(precheck route=compact_then_truncate+ jsonl 结束标记(type: "compaction" entry)配对

  • 开始标记:扫描 gateway 日志,找含目标 agent sessionKey 且 route=compact_then_truncate 的 precheck 日志行,提取时间戳。
  • 结束标记:扫描 session jsonl,找开始时间之后的 type: "compaction" entry。
  • 判定逻辑:有开始无结束 → compact 进行中 → skip ticker;有开始有结束 → compact 已完成 → 不 skip。
  • 超时兜底:开始标记超过 15 分钟仍未结束 → 自动忽略(防止死锁)。

0.2 三种 Compact 触发路径分析

Gateway 的 compact 有多种触发路径,日志表现不同:

触发路径 有开始标记? 有 sessionKey 有 compaction 结束标记? 检测策略
overflow 有(attempting auto-compaction 不含 依赖 precheck 覆盖
timeout 有([timeout-compaction] + attempting 推测不含 依赖 precheck 覆盖
precheck 有([context-overflow-precheck] + route=compact_then_truncate 直接检测
threshold 无(静默执行) counter+lock+status 保护
manual 无(静默执行) counter+lock+status 保护

0.3 为什么只依赖 precheck 标记

  1. overflow/timeout 标记不含 sessionKey:实测证实 overflow 标记(context overflow detected; attempting auto-compaction for zhipu/glm-5.1)不包含 agent:xxx:main 格式的 sessionKey,被前置 session_key not in msg 过滤跳过,是死代码。
  2. precheck 总在 overflow 之前触发:同一 compact 事件中,precheck route=compact_then_truncate 先检测到,overflow 是 fallback。所以 precheck 已覆盖 overflow 场景。
  3. threshold/manual 无开始标记:这两种是静默执行,没有 gateway 日志标记。它们依赖 counter+lock+status 三重保护(见 §07),不需要 gateway 日志检测。

注意route=truncate_tool_results_only 的 precheck 不触发 compact 检测,只有 route=compact_then_truncate 才触发。

0.4 超时兜底

15 分钟超时窗口:如果 compact 开始标记超过 15 分钟仍无结束标记,自动忽略该开始标记。这覆盖了:

  • daemon 重启后残留的开始标记
  • 极端长时间的 compact(正常 compact 通常 < 7 分钟)
  • 日志轮转导致的结束标记丢失

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. 方案 ATrajectory prompt.submitted 检测(v4,主选方案)

2.1 方案演进

版本 方案 问题
v1 trajectory jsonl 间接推断 trajectoryPath 不可用,需多文件
v2 gateway precheck 开始标志 覆盖率仅 30%post-compact retry 无开始标志
v3 rotation-only + 120s 窗口 120s 覆盖不了多轮 compact loop(实测 pangtong 13:59→14:50 共 5 轮 rotation,总耗时 ~51 分钟,PR #36 已合并但无法覆盖)
v4 trajectory prompt.submitted 源码+数据双重验证,仲达背靠背确认

2.2 核心洞察

源码证据selection-But6hGR0.js L14040-14085):

if (preemptiveCompaction?.shouldCompact) {
    skipPromptSubmission = true;  // ← compact 时跳过 prompt.submitted
}
if (!skipPromptSubmission) {
    trajectoryRecorder?.recordEvent("prompt.submitted", { ... });
}

当 context-overflow 触发 compact 时,Gateway 跳过 prompt.submitted 事件。 正常 turn 一定有 prompt.submitted

仲达背靠背验证skipPromptSubmission 有 7 条路径(不只 compact),但仲达指出: 检测目标不是"是否在 compact",而是"session 是否处于正常状态"。 所有跳过 prompt.submitted 的场景(compact/timeout/hook block/session 结束) 都是不应该 spawn ticker 的状态,误判方向安全。

实测数据(仲达背靠背重新验证,2026-06-11):

  • pangtong 39 个 turn34 有 prompt.submitted(正常),5 无
    • 4 个 tool loop 子迭代(compactionCount=0, <1s, gateway 无 compact 事件)
    • 1 个 context-overflow precheck 触发 compact
  • simayi 24 个 turn23 有,1 无(tool-result truncation succeeded
  • 合计 6/63 = ~9.5% 无 prompt.submitted,其中真正 compact 仅 1 例
  • 所有无 prompt.submitted 的场景都是不应 spawn ticker 的状态,方向安全

2.3 检测逻辑

1. 构造 trajectory jsonl 路径:{sessionFile}.trajectory.jsonl
2. 读文件尾部,按 session.started 分组找最后一个完整 turn
3. 如果该 turn 有 prompt.submitted → 正常 turn → 不 skip
4. 如果该 turn 有 prompt.skipped → 空白 prompt → 不 skip
5. 如果两者都无 → 非正常状态 → skip ticker
6. 超过 30min 没有新事件 → 兜底放行

为什么不需要 gateway 日志?

  • trajectory jsonl 已经包含了完整的 turn 生命周期
  • prompt.submitted 是 turn 级别的标志,不需要匹配开始/结束
  • 不需要维护跨 tick 的内存状态

2.4 为什么不用 session jsonl 的 type: "compaction" 事件?

每轮 compact 结束,session jsonl 确实会写入 type: "compaction" 摘要事件。 但 compact 后 Gateway 会 rotate transcript(创建新 session file), compaction 事件写在旧 session jsonl 里(变成 .reset 文件), 当前 main session 指向的 jsonl 中没有这些事件。

这就是现有 _check_recent_compaction_jsonl 检测不到的根本原因。

2B. 备选方案 B:内存 flag + sessions.json status

如果方案 A 在实际使用中不够,可补充方案 B。

1. gateway 日志发现 rotation 或 precheck → 设置内存 flag: compacting=True
2. 每个 ticker 检查:
   - flag=True + sessions.json status=running → 清 flagcompact 结束)
   - flag=True + 超过 30min → 清 flag(兜底放行)
   - flag=True → skip ticker
3. daemon 重启会丢失 flag(可接受,重启后状态已刷新)

优点:精确检测 compact 结束(status 恢复 running 缺点:需要维护内存状态、依赖两个数据源、daemon 重启丢失状态 触发条件:仅在方案 A 实际运行中发现不足时实施

3. 改动范围(方案 A

文件 改动 行数估计
spawner.py 新增 _check_compact_in_progress_trajectory() ~50 行
spawner.py _check_session_state() 调用新方法,替换旧方法 ~5 行
tests/test_spawner_compact.py 更新单元测试 ~30 行

总计 ~85 行代码改动。

4. 实现细节(方案 A

4.1 核心方法

def _check_compact_in_progress_trajectory(self, session_file: str, timeout_minutes: int = 30) -> bool:
    """检查 trajectory jsonl 尾部,判断 session 是否处于非正常状态。
    
    检测逻辑:最后一个完整 turn 没有 prompt.submitted → 非正常状态 → skip ticker。
    覆盖:compact、timeout、hook block、session 结束等所有非正常状态。
    """
    traj_path = f"{session_file}.trajectory.jsonl"
    if not os.path.exists(traj_path):
        return False
    
    # 读尾部 500KB
    with open(traj_path, 'rb') as f:
        f.seek(0, 2)
        size = f.tell()
        f.seek(max(0, size - 500 * 1024))
        tail_lines = f.readlines()
    
    # 按 session.started 分组,找最后一个完整 turn
    last_turn_events = []
    current_turn = []
    for raw_line in tail_lines:
        try:
            obj = json.loads(raw_line)
        except (json.JSONDecodeError, ValueError):
            continue
        
        event_type = obj.get("type", "")
        
        if event_type == "session.started":
            if current_turn:
                last_turn_events = current_turn
            current_turn = [obj]
        else:
            current_turn.append(obj)
    
    if current_turn:
        last_turn_events = current_turn
    
    if not last_turn_events:
        return False
    
    # 30min 兜底:最后一个事件超过 30min → 放行
    last_ts = None
    for evt in reversed(last_turn_events):
        ts = evt.get("ts") or evt.get("timestamp")
        if ts:
            last_ts = ts
            break
    
    if last_ts:
        try:
            from datetime import datetime, timezone
            # trajectory 时间是 ISO UTC
            if last_ts.endswith('Z'):
                last_dt = datetime.fromisoformat(last_ts.replace('Z', '+00:00'))
            else:
                last_dt = datetime.fromisoformat(last_ts)
            age = datetime.now(timezone.utc) - last_dt
            if age.total_seconds() > timeout_minutes * 60:
                return False  # 超时放行
        except (ValueError, TypeError):
            pass
    
    # 检查最后一个 turn 是否有 prompt.submitted
    has_prompt_submitted = any(
        evt.get("type") == "prompt.submitted" for evt in last_turn_events
    )
    has_prompt_skipped = any(
        evt.get("type") == "prompt.skipped" for evt in last_turn_events
    )
    
    if has_prompt_submitted or has_prompt_skipped:
        return False  # 正常 turn
    
    # 既无 submitted 也无 skipped → 非正常状态 → skip
    return True

4.2 Phase 2 集成

# 在 _check_session_state 中替换旧方法
compact = self._check_compact_in_progress_trajectory(session_file)
if not compact:
    compact = self._check_recent_compaction_jsonl(...)  # fallback
    
if compact:
    blockers.append(("session_compacting", None))

4.3 trajectory 路径构造

trajectory jsonl 路径 = {sessionFile}.trajectory.jsonl,其中 sessionFile 来自 sessions.json。

实测验证:

  • ~/.openclaw/agents/pangtong-fujunshi/sessions/745b35bb-...-e8e8988d.jsonl
  • → trajectory: ~/.openclaw/agents/pangtong-fujunshi/sessions/745b35bb-...-e8e8988d.trajectory.jsonl

5. 边界情况

边界情况 处理 误判方向
trajectory 不存在 返回 Falsefallback 安全
tool loop 子迭代 无 prompt.submitted → skip 保守但安全(~8%
timeout turn 无 prompt.submitted → skip 安全(timeout 也不该 spawn
hook block 无 prompt.submitted → skip 安全
truncation 成功 无 prompt.submitted → skip 安全(后面会 retry
session 结束空 turn 无 prompt.submitted → skip 安全
空白 prompt 有 prompt.skipped → 不 skip 正确区分
30min 无新事件 兜底放行 防死锁
compact 后 transcript rotate 读当前 sessionFile 对应的 trajectory 路径正确
budget compact 有 prompt.submitted → 不 skip 正确(budget compact 不阻止 spawn

6. 测试验证

6.1 单元测试(更新 test_spawner_compact.py

  • _check_compact_in_progress_trajectory
    • 正常 turn(有 prompt.submitted)→ False
    • compact turn(无 prompt.submitted)→ True
    • 空白 prompt(有 prompt.skipped)→ False
    • 超过 30min 兜底 → False
    • trajectory 不存在 → False
    • 空 trajectory → False
    • 多 turn 尾部只看最后一个 → 正确

6.2 集成验证

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

7. 关联设计

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

8. 实验验证(2026-06-13

8.1 Manual Compact 实验

jiangwei-infra 执行 /compact,每秒采样 4 个维度,完整时间线:

时间 事件 维度
08:08:57 基线:lock=0, status=done, compact=2, jsonl=1942 全部
08:09:22 Lock file 出现 + JSONL +1 行 lock, jsonl
08:09:22~08:12:31 Lock 持有中,status=done 不变 lock
08:12:32 Lock 释放 + compaction+1 + GW 2 条新日志 全部
08:12:32.035 GW: [compaction] rotated active transcript gateway log
08:12:32.057 GW: ⇄ res ✓ sessions.compact 191267ms gateway log

Compact 耗时191267ms ≈ 3 分 11 秒。

8.2 关键结论

  1. Lock file 是最通用的 compact 运行时标记manual compact 开始时立即出现,结束时释放,覆盖全程。
  2. Gateway log 无开始标记manual compact 不产生 context-overflow-precheckroute=compact_then_truncate,只有结束标记(rotated active transcript + sessions.compact WS 响应)。
  3. sessions.json status 不变:全程 done,不出现 compacting 之类的中间状态。
  4. JSONL compaction entry 是可靠的结束标记:所有路径(precheck/manual/threshold)完成后都会写入。
  5. V5 安全性manual/threshold 无开始标记 → V5 不会误判为进行中 → 不会在完成后误阻塞。Phase 2 的 session_lockedlock file 检查)覆盖了 compact 期间的阻塞需求。

8.3 实际防御体系

Phase 2 检查 检测信号 覆盖路径
session_compacting Gateway log precheck 开始标记 + jsonl 结束标记 precheck
session_locked Lock file 存在 + PID 活 全部(precheck + manual + threshold

两条路径互补,先命中哪个就 skip。V5 compact 检测是附加保险,不是唯一防线。

8.4 自身 compact 验证(庞统)

同日 08:00 对庞统自身执行 /compact 的结果一致:

  • Gateway log 无开始标记,只有 08:00:12.213rotated active transcript(结束)
  • sessions.compact 238358ms ≈ 3 分 58 秒
  • 结论相同

9. 评审记录

  • v1trajectory jsonl 间接推断 → 仲达指出 trajectoryPath 不可用、需多文件等 3 个问题
  • v2gateway 日志 precheck 开始标志 → 仲达指出开始标志覆盖率仅 30%,建议 rotation-only
  • v3rotation-only + 120s 窗口 → 合并 PR #36,但实测 51 分钟 compact loop 无法覆盖
  • v4trajectory prompt.submitted → 仲达背靠背验证(源码 7 条 skipPromptSubmission 路径 + 实际数据 ~8% 假阳性但方向安全)→ 修正检测目标为"session 是否正常"
  • v5gateway logprecheck 开始标记)+ jsonlcompaction 结束标记)配对 → 仲达评审通过后实现,PR #48 Review 驳回 M1/M2 修正后合并

⚠️ v4 已 deprecated。v4 的 trajectory prompt.submitted 方案未实施,最终实施的是 v5。v4 的分析(skipPromptSubmission 路径、实测数据)仍有参考价值。