AgentEvalTool/tests/unit/test_intelligent_eval_watchdog_characterization.py
sinohqb 58c2ad0227
All checks were successful
CI / test (push) Successful in 3m10s
refactor(intelligent-eval): unify watchdog failures behind fail_eval seam
架构审查候选③(状态机归一):_TRANSITIONS 成为评估状态机的唯一真相。

三个 watchdog(planning 双闸 / executing 兜底 / 触发失败闸门)原先直接
row.status = FAILED 绕过转换表、手工重复写字段。收编到新的公开接缝
fail_eval(session, eval_id, reason, decision_type, context):表校验 →
repo CAS 条件写(status/plan_feedback/updated_at/completed_at 一条 SQL)
→ append_decision_log 留痕。

CAS 冲突(如用户在扫描间隙抢先取消)跳过并留日志,不当故障。API 路径
本已在表内,不动。新增 4 个契约测试,870 tests passed,零行为变化。
2026-08-21 03:35:05 +08:00

571 lines
20 KiB
Python
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.

"""Watchdog contract tests for ADR-0011 (原 Phase 0 刻画测试).
Phase 0 时本文件锁定「无 watchdog」的旧行为Phase 1 全部落地后均为新契约:
- 会话过期expire_stale_running_sessions60min 无新轮次 → expired
- planning 双闸enforce_planning_gates触发 ≥5 次或 ≥30min → failed
- executing 兜底enforce_executing_ceiling + analyst 催促):超窗判败。
"""
import json
from datetime import timedelta
from agenteval.intelligent_eval.lifecycle import (
PLANNING_MAX_ATTEMPTS,
SESSION_IDLE_EXPIRE_MINUTES,
enforce_executing_ceiling,
enforce_planning_gates,
enforce_trigger_failure_gates,
expire_stale_running_sessions,
)
from agenteval.intelligent_eval.models import IntelligentEvalStatus
from agenteval.intelligent_eval.task_queue import (
requeue_stale_assigned_tasks,
scan_and_enqueue_tasks,
settle_tasks_for_finished_evals,
)
from agenteval.storage.db import (
IntelligentEvalDB,
IntelligentEvalDecisionLogDB,
IntelligentEvalMessageDB,
IntelligentEvalSessionDB,
utc_now,
)
from sqlmodel import Session, select
def _run_scan_cycle(session: Session) -> None:
"""Simulate one platform scan iteration (与 app.py 扫描循环同序)。"""
requeue_stale_assigned_tasks(session)
expire_stale_running_sessions(session)
enforce_planning_gates(session)
enforce_executing_ceiling(session)
enforce_trigger_failure_gates(session)
scan_and_enqueue_tasks(session)
settle_tasks_for_finished_evals(session)
def _make_executing_eval(db_session: Session, **overrides) -> IntelligentEvalDB:
ev = IntelligentEvalDB(
name=overrides.pop("name", "wd-eval"),
target_id="t1",
status=IntelligentEvalStatus.EXECUTING.value,
started_at=overrides.pop("started_at", utc_now() - timedelta(hours=3)),
**overrides,
)
db_session.add(ev)
db_session.commit()
return ev
def test_planning_eval_stuck_too_long_fails(db_session: Session):
"""ADR-0011 闸二planning 超 30 分钟未提交粗计划 → failed原因落 plan_feedback"""
ev = IntelligentEvalDB(
name="stuck-planning",
target_id="t1",
status=IntelligentEvalStatus.PLANNING.value,
created_at=utc_now() - timedelta(hours=5),
updated_at=utc_now() - timedelta(hours=5),
)
db_session.add(ev)
db_session.commit()
_run_scan_cycle(db_session)
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.FAILED.value
assert "30" in ev.plan_feedback
assert ev.completed_at is not None
logs = db_session.exec(
select(IntelligentEvalDecisionLogDB).where(
IntelligentEvalDecisionLogDB.eval_id == ev.id,
IntelligentEvalDecisionLogDB.decision_type == "planning_gate_failed",
)
).all()
assert len(logs) == 1
def test_planning_eval_fails_at_max_planner_triggers(db_session: Session):
"""ADR-0011 闸一planner 触发次数达上限仍未提交 → failed即使时长未到"""
from agenteval.intelligent_eval.lifecycle import record_planner_triggers
ev = IntelligentEvalDB(
name="attempt-gated",
target_id="t1",
status=IntelligentEvalStatus.PLANNING.value,
created_at=utc_now(),
updated_at=utc_now(),
)
db_session.add(ev)
db_session.commit()
for _ in range(PLANNING_MAX_ATTEMPTS):
assert record_planner_triggers(db_session) == 1
# 触发冷却 10min把刚落账的日志时间改老模拟跨冷却的多次触发
logs = db_session.exec(
select(IntelligentEvalDecisionLogDB).where(
IntelligentEvalDecisionLogDB.eval_id == ev.id,
IntelligentEvalDecisionLogDB.decision_type == "planner_trigger",
)
).all()
for log in logs:
log.created_at = utc_now() - timedelta(minutes=30)
db_session.commit()
_run_scan_cycle(db_session)
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.FAILED.value
assert "触发" in ev.plan_feedback
def test_planner_trigger_respects_cooldown(db_session: Session):
"""ADR-0011 孤儿 agent 双管:同一评估 10min 冷却内不重复触发 planner。"""
from agenteval.intelligent_eval.lifecycle import record_planner_triggers
ev = IntelligentEvalDB(
name="cooldown-planning",
target_id="t1",
status=IntelligentEvalStatus.PLANNING.value,
created_at=utc_now(),
updated_at=utc_now(),
)
db_session.add(ev)
db_session.commit()
assert record_planner_triggers(db_session) == 1
db_session.commit()
# 冷却期内:不再计次数(返回 0 → 平台不触发 docker exec
assert record_planner_triggers(db_session) == 0
def test_fresh_planning_eval_not_gated(db_session: Session):
"""刚进 planning 且触发次数未达上限的评估不受影响。"""
ev = IntelligentEvalDB(
name="fresh-planning",
target_id="t1",
status=IntelligentEvalStatus.PLANNING.value,
created_at=utc_now(),
updated_at=utc_now(),
)
db_session.add(ev)
db_session.commit()
_run_scan_cycle(db_session)
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.PLANNING.value
def test_executing_eval_past_window_stays_executing(db_session: Session):
"""CURRENT BEHAVIOR: an executing eval far beyond time_window_hours is never
force-finished by the platform (ADR-0011 adds the +2h ceiling)."""
ev = IntelligentEvalDB(
name="overdue-executing",
target_id="t1",
status=IntelligentEvalStatus.EXECUTING.value,
time_window_hours=1,
started_at=utc_now() - timedelta(hours=10),
plan=json.dumps({"time_distribution": [{"time_slot": "0-1h", "sessions": 1}]}),
)
db_session.add(ev)
db_session.commit()
_run_scan_cycle(db_session)
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.FAILED.value
assert "宽限" in ev.plan_feedback
assert ev.completed_at is not None
logs = db_session.exec(
select(IntelligentEvalDecisionLogDB).where(
IntelligentEvalDecisionLogDB.eval_id == ev.id,
IntelligentEvalDecisionLogDB.decision_type == "executing_timeout",
)
).all()
assert len(logs) == 1
def test_executing_within_window_plus_grace_not_gated(db_session: Session):
"""时间窗 + 宽限内的 executing 评估不受影响。"""
ev = IntelligentEvalDB(
name="in-window",
target_id="t1",
status=IntelligentEvalStatus.EXECUTING.value,
time_window_hours=24,
started_at=utc_now() - timedelta(hours=10),
plan=json.dumps({"time_distribution": [{"time_slot": "0-1h", "sessions": 1}]}),
)
db_session.add(ev)
db_session.commit()
_run_scan_cycle(db_session)
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.EXECUTING.value
def _make_terminal_session(
db_session: Session,
eval_id: str,
*,
status: str = "completed",
closed_minutes_ago: int = 30,
) -> IntelligentEvalSessionDB:
row = IntelligentEvalSessionDB(
eval_id=eval_id,
target_id="t1",
status=status,
goal="完成退货",
created_at=utc_now() - timedelta(minutes=closed_minutes_ago + 10),
closed_at=utc_now() - timedelta(minutes=closed_minutes_ago),
)
db_session.add(row)
db_session.commit()
return row
def test_analyst_nudge_when_all_sessions_terminal(db_session: Session):
"""ADR-0011全部会话终态满 10min 且评估仍 executing → 需要 analyst 催促。"""
from agenteval.intelligent_eval.lifecycle import evals_needing_analyst_nudge
ev = _make_executing_eval(db_session)
_make_terminal_session(db_session, ev.id, closed_minutes_ago=30)
assert evals_needing_analyst_nudge(db_session) == [ev.id]
def test_analyst_nudge_skipped_before_delay(db_session: Session):
"""末会话终态未满 10min 不催促。"""
from agenteval.intelligent_eval.lifecycle import evals_needing_analyst_nudge
ev = _make_executing_eval(db_session)
_make_terminal_session(db_session, ev.id, closed_minutes_ago=5)
assert evals_needing_analyst_nudge(db_session) == []
def test_analyst_nudge_skipped_with_running_session(db_session: Session):
"""存在非终态会话时不催促worker 还在干活)。"""
from agenteval.intelligent_eval.lifecycle import evals_needing_analyst_nudge
ev = _make_executing_eval(db_session)
_make_terminal_session(db_session, ev.id, closed_minutes_ago=30)
_make_terminal_session(db_session, ev.id, status="running", closed_minutes_ago=0)
assert evals_needing_analyst_nudge(db_session) == []
def test_analyst_nudge_skipped_while_future_slot_pending(db_session: Session):
"""冒烟教训:窗口内还有未到期时段(会话数未达计划)时不催 analyst。"""
from agenteval.intelligent_eval.lifecycle import evals_needing_analyst_nudge
ev = _make_executing_eval(
db_session,
started_at=utc_now() - timedelta(minutes=20),
plan=json.dumps(
{
"estimated_sessions": 2,
"time_distribution": [
{"time_slot": "0-30min", "sessions": 1},
{"time_slot": "30-60min", "sessions": 1},
],
}
),
)
_make_terminal_session(db_session, ev.id, closed_minutes_ago=15)
assert evals_needing_analyst_nudge(db_session) == []
def test_analyst_nudge_fires_after_window_elapsed_with_deficit(db_session: Session):
"""窗口已结束但会话欠账:不再等未来时段,催促 analyst 按现有证据收敛。"""
from agenteval.intelligent_eval.lifecycle import evals_needing_analyst_nudge
ev = _make_executing_eval(
db_session,
started_at=utc_now() - timedelta(minutes=70),
plan=json.dumps(
{
"estimated_sessions": 2,
"time_distribution": [
{"time_slot": "0-30min", "sessions": 1},
{"time_slot": "30-60min", "sessions": 1},
],
}
),
)
_make_terminal_session(db_session, ev.id, closed_minutes_ago=30)
assert evals_needing_analyst_nudge(db_session) == [ev.id]
def test_analyst_nudge_capped_and_cooled_down(db_session: Session):
"""催促上限 3 次;两次催促间隔未满 10min 不重复。"""
from agenteval.intelligent_eval.lifecycle import (
ANALYST_NUDGE_MAX,
evals_needing_analyst_nudge,
record_analyst_nudge,
)
ev = _make_executing_eval(db_session)
_make_terminal_session(db_session, ev.id, closed_minutes_ago=30)
record_analyst_nudge(db_session, ev.id)
db_session.commit()
# 冷却期内不再催促
assert evals_needing_analyst_nudge(db_session) == []
# 直接把已有催促日志时间改老,模拟冷却结束,补满到上限
logs = db_session.exec(
select(IntelligentEvalDecisionLogDB).where(
IntelligentEvalDecisionLogDB.eval_id == ev.id,
IntelligentEvalDecisionLogDB.decision_type == "analyst_nudge",
)
).all()
for log in logs:
log.created_at = utc_now() - timedelta(minutes=30)
db_session.commit()
for _ in range(ANALYST_NUDGE_MAX - 1):
assert evals_needing_analyst_nudge(db_session) == [ev.id]
record_analyst_nudge(db_session, ev.id)
db_session.commit()
more = db_session.exec(
select(IntelligentEvalDecisionLogDB).where(
IntelligentEvalDecisionLogDB.eval_id == ev.id,
IntelligentEvalDecisionLogDB.decision_type == "analyst_nudge",
)
).all()
for log in more:
log.created_at = utc_now() - timedelta(minutes=30)
db_session.commit()
# 达到上限后不再催促(等待 executing 超窗兜底判败)
assert evals_needing_analyst_nudge(db_session) == []
def test_idle_running_session_expires(db_session: Session):
"""ADR-0011running 会话 60 分钟无新轮次 → expired终态写 closed_at"""
ev = _make_executing_eval(db_session)
session_row = IntelligentEvalSessionDB(
eval_id=ev.id,
target_id="t1",
status="running",
goal="完成退货",
created_at=utc_now() - timedelta(minutes=SESSION_IDLE_EXPIRE_MINUTES + 30),
)
db_session.add(session_row)
db_session.commit()
_run_scan_cycle(db_session)
db_session.refresh(session_row)
assert session_row.status == "expired"
assert session_row.closed_at is not None
logs = db_session.exec(
select(IntelligentEvalDecisionLogDB).where(
IntelligentEvalDecisionLogDB.eval_id == ev.id,
IntelligentEvalDecisionLogDB.decision_type == "session_expired",
)
).all()
assert len(logs) == 1
def test_active_running_session_not_expired(db_session: Session):
"""最近有消息的 running 会话不过期(按最后一条消息时间判定)。"""
ev = _make_executing_eval(db_session)
session_row = IntelligentEvalSessionDB(
eval_id=ev.id,
target_id="t1",
status="running",
goal="咨询物流",
turn_count=3,
created_at=utc_now() - timedelta(hours=5),
)
db_session.add(session_row)
db_session.commit()
db_session.add(
IntelligentEvalMessageDB(
session_id=session_row.id,
role="assistant",
content="在的",
created_at=utc_now() - timedelta(minutes=5),
)
)
db_session.commit()
_run_scan_cycle(db_session)
db_session.refresh(session_row)
assert session_row.status == "running"
def test_expired_session_unblocks_submit_report(db_session: Session):
"""ADR-0011会话过期后全部终态submit_report 不再被拒。"""
from agenteval.intelligent_eval import lifecycle
ev = _make_executing_eval(db_session)
session_row = IntelligentEvalSessionDB(
eval_id=ev.id,
target_id="t1",
status="running",
goal="完成退货",
created_at=utc_now() - timedelta(minutes=SESSION_IDLE_EXPIRE_MINUTES + 30),
)
db_session.add(session_row)
db_session.commit()
_run_scan_cycle(db_session)
done = lifecycle.submit_report(db_session, ev.id, {"summary": "done"})
assert done.status is IntelligentEvalStatus.COMPLETED
def _record_failures(db_session: Session, eval_id: str, channel: str, count: int) -> None:
from agenteval.intelligent_eval.lifecycle import record_trigger_failures
for i in range(count):
record_trigger_failures(
db_session, channel=channel, eval_ids=[eval_id], error=f"模拟失败 {i + 1}"
)
db_session.commit()
def test_planning_eval_fails_after_repeated_planner_trigger_failures(db_session: Session):
"""ADR-0011planner 连续触发失败 3 次 → planning 评估判失败。"""
ev = IntelligentEvalDB(
name="planner-fail",
target_id="t1",
status=IntelligentEvalStatus.PLANNING.value,
created_at=utc_now(),
updated_at=utc_now(),
)
db_session.add(ev)
db_session.commit()
_record_failures(db_session, ev.id, "planner", 3)
_run_scan_cycle(db_session)
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.FAILED.value
assert "触发失败" in ev.plan_feedback
def test_executing_eval_fails_after_repeated_worker_trigger_failures(db_session: Session):
"""ADR-0011worker 连续触发失败 3 次且期间无任务被认领 → 评估判失败。"""
ev = _make_executing_eval(db_session)
_record_failures(db_session, ev.id, "worker", 3)
_run_scan_cycle(db_session)
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.FAILED.value
assert "worker" in ev.plan_feedback
def test_trigger_failures_below_threshold_keep_eval_alive(db_session: Session):
"""失败次数未达上限不判死。"""
ev = _make_executing_eval(db_session)
_record_failures(db_session, ev.id, "worker", 2)
_run_scan_cycle(db_session)
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.EXECUTING.value
def test_task_pickup_breaks_consecutive_worker_failures(db_session: Session):
"""失败之后有任务被认领 → 「连续」被打断,不判死。"""
from agenteval.storage.db import IntelligentEvalTaskQueueDB
ev = _make_executing_eval(db_session)
_record_failures(db_session, ev.id, "worker", 3)
# 失败落账之后任务被认领(触发实际生效过)→ 连续性中断
db_session.add(
IntelligentEvalTaskQueueDB(
eval_id=ev.id,
status="completed",
priority=1,
reason="slot_due",
assigned_cron_id="manual-run-ok",
assigned_at=utc_now(),
)
)
db_session.commit()
_run_scan_cycle(db_session)
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.EXECUTING.value
# ---------------------------------------------------------------------------
# fail_eval 统一接缝契约状态机归一watchdog 判失败必须穿过表校验 + CAS
# ---------------------------------------------------------------------------
def test_fail_eval_writes_fields_and_log(db_session: Session):
"""fail_evalCAS 条件写 status/plan_feedback/completed_at + 决策日志留痕。"""
from agenteval.intelligent_eval.lifecycle import fail_eval
ev = _make_executing_eval(db_session)
assert fail_eval(db_session, ev.id, "测试判败", "executing_timeout", {"k": 1}) is True
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.FAILED.value
assert ev.plan_feedback == "测试判败"
assert ev.completed_at is not None
logs = db_session.exec(
select(IntelligentEvalDecisionLogDB).where(IntelligentEvalDecisionLogDB.eval_id == ev.id)
).all()
assert len(logs) == 1
assert logs[0].decision_type == "executing_timeout"
assert logs[0].cron_id == "platform"
def test_fail_eval_blocked_by_transition_table(db_session: Session):
"""终态评估不允许再判 failed——_TRANSITIONS 是唯一真相。"""
from agenteval.intelligent_eval.lifecycle import fail_eval
ev = _make_executing_eval(db_session)
ev.status = IntelligentEvalStatus.COMPLETED.value
db_session.add(ev)
db_session.commit()
assert fail_eval(db_session, ev.id, "不该生效", "executing_timeout", {}) is False
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.COMPLETED.value
logs = db_session.exec(
select(IntelligentEvalDecisionLogDB).where(IntelligentEvalDecisionLogDB.eval_id == ev.id)
).all()
assert logs == []
def test_fail_eval_skips_on_cas_conflict(db_session: Session, monkeypatch):
"""CAS 冲突(用户抢先取消等并发收敛)→ 跳过不当故障,不写日志。"""
from agenteval.intelligent_eval.lifecycle import fail_eval
from agenteval.intelligent_eval.repository import (
CompareAndSetResult,
CompareAndSetStatus,
IntelligentEvalRepository,
)
ev = _make_executing_eval(db_session)
monkeypatch.setattr(
IntelligentEvalRepository,
"_compare_and_set_fields",
lambda self, eval_id, *, expected_status, values: CompareAndSetResult(status=CompareAndSetStatus.CONFLICT),
)
assert fail_eval(db_session, ev.id, "竞争失败", "executing_timeout", {}) is False
db_session.refresh(ev)
assert ev.status == IntelligentEvalStatus.EXECUTING.value
def test_fail_eval_skips_unknown_eval(db_session: Session):
"""评估不存在 → 跳过NOT_FOUND 语义)。"""
from agenteval.intelligent_eval.lifecycle import fail_eval
assert fail_eval(db_session, "no-such-eval", "r", "executing_timeout", {}) is False