# §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 是否正常"
