# §24 — Compact 检测方案修正 > 状态:v4(trajectory prompt.submitted),待实施 > 作者:庞统 > 日期:2026-06-11 > 框架:基于 §07 Spawner Acquire-First > 评审:仲达 4 轮评审(v1 trajectory → v2 gateway precheck → v3 rotation-only → v4 prompt.submitted) > 备选方案:B(内存 flag + sessions.json status),见 §2B ## 1. 问题 ### 1.1 现象 2026-06-11 14:02,pangtong main session 正在做 compaction(13: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. 方案 A:Trajectory 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): ```javascript 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 个 turn:34 有 prompt.submitted(正常),5 无 - 4 个 tool loop 子迭代(compactionCount=0, <1s, gateway 无 compact 事件) - 1 个 context-overflow precheck 触发 compact - simayi 24 个 turn:23 有,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 → 清 flag(compact 结束) - 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 核心方法 ```python 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 集成 ```python # 在 _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 不存在 | 返回 False(fallback) | 安全 | | 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 Optimization(compact_hanging 处理) ## 8. 评审记录 - **v1**:trajectory jsonl 间接推断 → 仲达指出 trajectoryPath 不可用、需多文件等 3 个问题 - **v2**:gateway 日志 precheck 开始标志 → 仲达指出开始标志覆盖率仅 30%,建议 rotation-only - **v3**:rotation-only + 120s 窗口 → 合并 PR #36,但实测 51 分钟 compact loop 无法覆盖 - **v4**:trajectory prompt.submitted → 仲达背靠背验证(源码 7 条 skipPromptSubmission 路径 + 实际数据 ~8% 假阳性但方向安全)→ 修正检测目标为"session 是否正常"