diff --git a/docs/design/07-spawner-acquire-first.md b/docs/design/07-spawner-acquire-first.md index a77a889..eac43f7 100644 --- a/docs/design/07-spawner-acquire-first.md +++ b/docs/design/07-spawner-acquire-first.md @@ -244,7 +244,7 @@ def _revive_session(agent_id: str) -> bool: ```python # §24 v3: compact 检测优先用 gateway 日志 rotation 事件 -if result["status"] not in ("done", "idle", "unknown", None): +if result["status"] not in ("idle", "unknown", None): session_key = f"agent:{agent_id}:main" result["recent_compact"] = AgentSpawner._check_compact_in_progress_gateway( session_key) diff --git a/docs/design/24-compact-detection-fix.md b/docs/design/24-compact-detection-fix.md index 799fd4e..a27aa54 100644 --- a/docs/design/24-compact-detection-fix.md +++ b/docs/design/24-compact-detection-fix.md @@ -45,11 +45,15 @@ Gateway 日志中 `[compaction] rotated active transcript after compaction (sess ``` 1. 读 gateway 日志(当天 + 昨天尾部) 2. 按目标 sessionKey 过滤 compact 相关事件 -3. 从后往前找最后一条相关事件: - a. 如果是 rotation 且 < N 秒(建议 120s)→ compact=True +3. 从后往前找最后一条 rotation 事件: + a. 如果 rotation 事件在窗口内(< 120s)→ compact=True (刚完成一轮 compact,可能还在 post-compact retry 循环中) - b. 如果是 model.completed 或其他正常事件 → compact=False - c. 超出时间窗口 → compact=False + b. 无 rotation 事件或超出时间窗口 → compact=False + +**注意:此方案仅检查 rotation 事件,不检查 model.completed 等其他事件。** +这是有意为之的保守策略:不检查正常 turn 事件意味着 compact 完成后的 +120s 内都可能被误判为 compact 进行中,但误判代价低(仅 skip 一轮 ticker), +宁可多拦也不漏放。 ``` **为什么 rotation + 时间窗口就够了?** @@ -97,8 +101,7 @@ def _check_compact_in_progress_gateway(self, session_key: str, window_seconds: i now = datetime.now(timezone.utc) window_start = now - timedelta(seconds=window_seconds) - last_event_type = None - last_event_time = None + last_rotation_time = None for log_path in log_paths: if not os.path.exists(log_path): @@ -127,14 +130,13 @@ def _check_compact_in_progress_gateway(self, session_key: str, window_seconds: i if "[compaction] rotated active transcript" in msg: try: event_time = datetime.fromisoformat(ts_str) - if event_time > last_event_time if last_event_time else True: - last_event_time = event_time - last_event_type = "rotation" + if last_rotation_time is None or event_time > last_rotation_time: + last_rotation_time = event_time except (ValueError, TypeError): continue - if last_event_type == "rotation" and last_event_time: - return last_event_time >= window_start + if last_rotation_time is not None: + return last_rotation_time >= window_start return False ``` @@ -174,7 +176,7 @@ if compact: | 日志文件不存在 | 返回 False(fallback 到旧方法) | | 跨天 compact | 同时检查昨天日志尾部 | | compact 失败(无 rotation) | rotation 事件不会出现 → 检测不到 → 回退到旧方法 | -| 误判(compact 完成后正常工作中) | 时间窗口 120s 内正常 turn 的 model.completed 事件会覆盖 rotation | +| 误判(compact 完成后正常工作中) | 时间窗口 120s 内可能被误判,但代价低(skip 一轮 ticker)。不检查正常 turn 事件,是保守策略 | ## 6. 测试验证 diff --git a/tests/test_spawner_compact.py b/tests/test_spawner_compact.py new file mode 100644 index 0000000..dec7dad --- /dev/null +++ b/tests/test_spawner_compact.py @@ -0,0 +1,92 @@ +"""单元测试:§24 v3 rotation-only compact 检测 + +测试 _get_recent_gateway_logs 和 _check_compact_in_progress_gateway。 +用 tmp_path 构造 mock gateway 日志文件。 +""" + +import json +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from src.daemon.spawner import AgentSpawner + + +# ── helpers ── + +_SESSION_KEY = "agent:pangtong-fujunshi:main" +_TODAY_STR = datetime.now().strftime("%Y-%m-%d") + + +def _make_rotation_event(session_key: str, ts: datetime) -> dict: + """构造一条 rotation 日志事件""" + return { + "time": ts.isoformat(), + "message": f"[compaction] rotated active transcript after compaction (sessionKey={session_key})", + } + + +def _make_other_event(session_key: str, ts: datetime, msg: str = "something else") -> dict: + """构造一条普通日志事件""" + return { + "time": ts.isoformat(), + "message": f"{msg} (sessionKey={session_key})", + } + + +def _write_log(tmp_path: Path, date_str: str, lines: list[dict]): + """写 mock 日志文件""" + log_file = tmp_path / f"openclaw-{date_str}.log" + with open(log_file, "w") as f: + for obj in lines: + f.write(json.dumps(obj, ensure_ascii=False) + "\n") + + +@pytest.fixture(autouse=True) +def _set_log_dir(tmp_path, monkeypatch): + """每个测试自动设置 OPENCLAW_LOG_DIR 到 tmp_path""" + monkeypatch.setenv("OPENCLAW_LOG_DIR", str(tmp_path)) + + +# ── 测试用例 ── + + +class TestCheckCompactInProgress: + """§24 v3: _check_compact_in_progress_gateway 单元测试""" + + def test_rotation_within_window_returns_true(self, tmp_path): + """TC1: rotation 事件在窗口内 → True""" + now = datetime.now(timezone.utc) + recent = now - timedelta(seconds=30) + _write_log(tmp_path, _TODAY_STR, [_make_rotation_event(_SESSION_KEY, recent)]) + assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is True + + def test_rotation_outside_window_returns_false(self, tmp_path): + """TC2: rotation 事件超出窗口 → False""" + now = datetime.now(timezone.utc) + old = now - timedelta(seconds=200) + _write_log(tmp_path, _TODAY_STR, [_make_rotation_event(_SESSION_KEY, old)]) + assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False + + def test_no_rotation_event_returns_false(self, tmp_path): + """TC3: 无 rotation 事件 → False""" + now = datetime.now(timezone.utc) + _write_log(tmp_path, _TODAY_STR, [ + _make_other_event(_SESSION_KEY, now, "model.completed"), + ]) + assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False + + def test_log_file_not_exists_returns_false(self, tmp_path): + """TC4: 日志文件不存在 → False""" + # tmp_path 为空目录,无日志文件 + assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False + + def test_session_key_mismatch_returns_false(self, tmp_path): + """TC5: sessionKey 不匹配 → False""" + now = datetime.now(timezone.utc) + recent = now - timedelta(seconds=10) + other_key = "agent:simayi-challenger:main" + _write_log(tmp_path, _TODAY_STR, [_make_rotation_event(other_key, recent)]) + assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False