Files
sanguo_moziplus_v2/docs/design/24-compact-detection-fix.md
T
cfdaily 3c2c0f3175
CI / lint (pull_request) Failing after 7s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 0s
fix(spawner): §24 v4 compact检测 - trajectory prompt.submitted 替换 gateway rotation
2026-06-11 23:57:09 +08:00

260 lines
10 KiB
Markdown
Raw 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.
# §24 — Compact 检测方案修正
> 状态:v4trajectory 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: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. 评审记录
- **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 是否正常"