206 lines
7.8 KiB
Markdown
206 lines
7.8 KiB
Markdown
# §24 — Compact 检测方案修正
|
||
|
||
> 状态:v3(rotation-only),待实施
|
||
> 作者:庞统
|
||
> 日期:2026-06-11
|
||
> 框架:基于 §07 Spawner Acquire-First
|
||
> 评审:仲达 3 轮评审(v1 trajectory → v2 gateway precheck → v3 rotation-only)
|
||
|
||
## 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. 方案: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 完成后正常工作很久了还误判
|
||
- **推荐 120s**:compact 循环中两次 rotation 间隔通常 <60s,120s 有足够余量
|
||
|
||
误判代价低(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 核心方法
|
||
|
||
```python
|
||
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 日志路径
|
||
|
||
```python
|
||
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 集成
|
||
|
||
```python
|
||
# 在 _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. 边界情况
|
||
|
||
| 边界情况 | 处理 |
|
||
|---------|------|
|
||
| 日志文件不存在 | 返回 False(fallback 到旧方法) |
|
||
| 跨天 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 Optimization(compact_hanging 处理)
|
||
|
||
## 8. 评审记录
|
||
|
||
- **v1**:trajectory jsonl 间接推断 → 仲达指出 trajectoryPath 不可用、需多文件等 3 个问题
|
||
- **v2**:gateway 日志 precheck 开始标志 → 仲达指出开始标志覆盖率仅 30%,建议 rotation-only
|
||
- **v3**:rotation-only(当前版本)→ 仲达已确认方向,待代码实现后再审
|