346 lines
15 KiB
Markdown
346 lines
15 KiB
Markdown
# §15 — Compact 检测方案修正
|
||
|
||
> 状态:✅ 已完成(gateway log + jsonl 配对)
|
||
> 作者:庞统
|
||
> 日期:2026-06-11(v4),2026-06-13(v5)
|
||
> 框架:基于 §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: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. 实验验证(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 log(precheck 开始标记)+ jsonl(compaction 结束标记)配对 → 仲达评审通过后实现,PR #48 Review 驳回 M1/M2 修正后合并
|
||
|
||
> ⚠️ **v4 已 deprecated**。v4 的 trajectory prompt.submitted 方案未实施,最终实施的是 v5。v4 的分析(skipPromptSubmission 路径、实测数据)仍有参考价值。
|