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

346 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# §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):
```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 个 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 核心方法
```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 不存在 | 返回 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-precheck``route=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_locked`lock 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.213``rotated active transcript`(结束)
- `sessions.compact 238358ms` ≈ 3 分 58 秒
- 结论相同
## 9. 评审记录
- **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 是否正常"
- **v5**gateway logprecheck 开始标记)+ jsonlcompaction 结束标记)配对 → 仲达评审通过后实现,PR #48 Review 驳回 M1/M2 修正后合并
> ⚠️ **v4 已 deprecated**。v4 的 trajectory prompt.submitted 方案未实施,最终实施的是 v5。v4 的分析(skipPromptSubmission 路径、实测数据)仍有参考价值。