AgentEvalTool/backend/agenteval/intelligent_eval/lifecycle.py
sinohqb da7dd434dd perf(intelligent-eval): 修复 N+1 查询和参数名混淆
- expire_stale_running_sessions: 使用单次 JOIN 查询替代 N+1 查询
  将每个会话单独查询最后消息时间改为一次性获取所有 running 会话及其最后消息时间

- submit_report/evals_needing_analyst_nudge: 消除 session/sessions 参数名混淆
  将局部变量 sessions 重命名为 eval_sessions,避免与数据库会话参数 session 混淆

这些改进提升了查询性能并增强了代码可读性。
2026-08-24 01:52:05 +08:00

867 lines
34 KiB
Python
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.

"""Intelligent eval lifecycle (状态机 + 领域操作).
状态机:
draft → planning → pending_approval → executing → completed
→ cancelled
→ failed
pending_approval 可打回 → planning附反馈
executing 可取消 → cancelled
completed / cancelled / failed 可删除 → deleted逻辑删除
非法转换抛 IntelligentEvalTransitionError路由层映射为 409。
"""
import logging
from datetime import timedelta
from typing import Any, Optional
from sqlmodel import Session, func, select
from agenteval.channels.base import ExchangeStatus, SendResult
from agenteval.channels.factory import ChannelFactory
from agenteval.config import get_settings
from agenteval.intelligent_eval.decision_logs import append_decision_log, count_decisions
from agenteval.intelligent_eval.domain import parse_time_slot
from agenteval.intelligent_eval.models import (
IntelligentEval,
IntelligentEvalMessage,
IntelligentEvalSession,
IntelligentEvalSessionStatus,
IntelligentEvalStatus,
)
from agenteval.intelligent_eval.repository import (
CompareAndSetStatus,
IntelligentEvalMessageRepository,
IntelligentEvalRepository,
IntelligentEvalSessionRepository,
)
from agenteval.storage.db import (
IntelligentEvalDB,
IntelligentEvalDecisionLogDB,
IntelligentEvalMessageDB,
IntelligentEvalSessionDB,
IntelligentEvalTaskQueueDB,
as_utc,
utc_now,
)
from agenteval.storage.repository import TargetRepository
# 合法转换表:当前状态 → 允许的目标状态集合
_TRANSITIONS: dict[IntelligentEvalStatus, set[IntelligentEvalStatus]] = {
IntelligentEvalStatus.DRAFT: {IntelligentEvalStatus.PLANNING},
# PLANNING → FAILED 仅由 planning 双闸 watchdog 触发ADR-0011非用户操作
IntelligentEvalStatus.PLANNING: {IntelligentEvalStatus.PENDING_APPROVAL, IntelligentEvalStatus.FAILED},
IntelligentEvalStatus.PENDING_APPROVAL: {
IntelligentEvalStatus.EXECUTING,
IntelligentEvalStatus.PLANNING,
IntelligentEvalStatus.CANCELLED,
},
IntelligentEvalStatus.EXECUTING: {
IntelligentEvalStatus.COMPLETED,
IntelligentEvalStatus.CANCELLED,
IntelligentEvalStatus.FAILED,
},
IntelligentEvalStatus.COMPLETED: {IntelligentEvalStatus.DELETED},
IntelligentEvalStatus.CANCELLED: {IntelligentEvalStatus.DELETED},
IntelligentEvalStatus.FAILED: {IntelligentEvalStatus.DELETED},
IntelligentEvalStatus.DELETED: set(),
}
class IntelligentEvalNotFoundError(Exception):
pass
class IntelligentEvalTransitionError(Exception):
def __init__(self, reason: str):
self.reason = reason
super().__init__(reason)
class IntelligentEvalChannelError(Exception):
pass
def _get_or_raise(repo: IntelligentEvalRepository, eval_id: str) -> IntelligentEval:
ev = repo.get(eval_id)
if ev is None:
raise IntelligentEvalNotFoundError(f"intelligent eval {eval_id} not found")
return ev
def _resolve_write(
eval_id: str,
result,
*,
expected: IntelligentEvalStatus,
target: IntelligentEvalStatus,
) -> IntelligentEval:
if result.status is CompareAndSetStatus.NOT_FOUND:
raise IntelligentEvalNotFoundError(f"intelligent eval {eval_id} not found")
if result.status is CompareAndSetStatus.CONFLICT:
raise IntelligentEvalTransitionError(
f"cannot transition from {expected.value} to {target.value}; state changed concurrently"
)
if result.evaluation is None:
raise RuntimeError(f"lifecycle write returned no evaluation: {eval_id}")
return result.evaluation
def _transition(repo: IntelligentEvalRepository, ev: IntelligentEval, target: IntelligentEvalStatus) -> IntelligentEval:
allowed = _TRANSITIONS.get(ev.status, set())
if target not in allowed:
raise IntelligentEvalTransitionError(f"cannot transition from {ev.status.value} to {target.value}")
result = repo._compare_and_set_status(
ev.id,
expected_status=ev.status,
new_status=target,
)
return _resolve_write(ev.id, result, expected=ev.status, target=target)
def create_eval(
session: Session,
*,
name: str,
target_id: str,
goal: str,
seeds: dict[str, Any],
intent: str,
role_description: str,
time_window_hours: int = 24,
) -> IntelligentEval:
"""创建智能评估并直接进入 planning 状态draft → planning 一步完成)。"""
from agenteval.intelligent_eval.config_snapshot import save_snapshot
if TargetRepository(session).get(target_id) is None:
raise IntelligentEvalNotFoundError(f"target {target_id} not found")
repo = IntelligentEvalRepository(session)
ev = repo._create(
IntelligentEval(
name=name,
target_id=target_id,
status=IntelligentEvalStatus.PLANNING,
goal=goal,
seeds=seeds,
intent=intent,
role_description=role_description,
time_window_hours=time_window_hours,
created_at=utc_now(),
updated_at=utc_now(),
)
)
# Save config snapshot
eval_db = session.get(IntelligentEvalDB, ev.id)
if eval_db:
save_snapshot(eval_db, "created", "user", session)
return ev
def submit_plan(session: Session, eval_id: str, plan: dict[str, Any]) -> IntelligentEval:
"""OpenClaw 提交粗计划planning → pending_approval。"""
from agenteval.intelligent_eval.config_snapshot import save_snapshot
repo = IntelligentEvalRepository(session)
result = repo._submit_plan_if_planning(eval_id, plan)
ev = _resolve_write(
eval_id,
result,
expected=IntelligentEvalStatus.PLANNING,
target=IntelligentEvalStatus.PENDING_APPROVAL,
)
# Save config snapshot
eval_db = session.get(IntelligentEvalDB, eval_id)
if eval_db:
save_snapshot(eval_db, "plan_submitted", "openclaw", session)
return ev
def approve(session: Session, eval_id: str) -> IntelligentEval:
"""用户批准pending_approval → executing。"""
repo = IntelligentEvalRepository(session)
ev = _get_or_raise(repo, eval_id)
return _transition(repo, ev, IntelligentEvalStatus.EXECUTING)
def reject(session: Session, eval_id: str, feedback: str) -> IntelligentEval:
"""用户打回pending_approval → planning附反馈"""
repo = IntelligentEvalRepository(session)
result = repo._reject_plan_if_pending(eval_id, feedback)
return _resolve_write(
eval_id,
result,
expected=IntelligentEvalStatus.PENDING_APPROVAL,
target=IntelligentEvalStatus.PLANNING,
)
def cancel(session: Session, eval_id: str) -> IntelligentEval:
"""用户取消pending_approval / executing → cancelled。"""
repo = IntelligentEvalRepository(session)
ev = _get_or_raise(repo, eval_id)
return _transition(repo, ev, IntelligentEvalStatus.CANCELLED)
def delete_eval(session: Session, eval_id: str) -> IntelligentEval:
"""逻辑删除completed / cancelled / failed → deleted。幂等已删除直接返回。"""
repo = IntelligentEvalRepository(session)
ev = repo.get_including_deleted(eval_id)
if ev is None:
raise IntelligentEvalNotFoundError(f"intelligent eval {eval_id} not found")
if ev.status is IntelligentEvalStatus.DELETED:
return ev
return _transition(repo, ev, IntelligentEvalStatus.DELETED)
def submit_report(session: Session, eval_id: str, report: dict[str, Any]) -> IntelligentEval:
"""OpenClaw 提交结构化报告executing → completed。
报告结构校验在路由层pydantic此处只负责落库与状态迁移。提交前必须
满足存在会话时须全部到达终态completed/failed/expired——否则拒绝
杜绝"评估 completed 但 completed_sessions=0进度 0%"的不一致。
ADR-0011卡死的 running 会话由 expire_stale_running_sessions 置 expired
(不完整证据),不再阻塞报告提交。
"""
repo = IntelligentEvalRepository(session)
eval_sessions = IntelligentEvalSessionRepository(session).list_by_eval(eval_id)
if any(s.status == IntelligentEvalSessionStatus.RUNNING for s in eval_sessions):
raise IntelligentEvalTransitionError("存在进行中的会话,不能提交报告")
# ADR-0011submit 边界把 scores 归一到 {overall, dimensions} 单一规范结构
if report.get("scores"):
from agenteval.intelligent_eval.report import normalize_scores
report = {**report, "scores": normalize_scores(report["scores"])}
result = repo._submit_report_if_executing(eval_id, report)
return _resolve_write(
eval_id,
result,
expected=IntelligentEvalStatus.EXECUTING,
target=IntelligentEvalStatus.COMPLETED,
)
def get_eval(session: Session, eval_id: str) -> IntelligentEval:
repo = IntelligentEvalRepository(session)
return _get_or_raise(repo, eval_id)
def list_evals(session: Session) -> list[IntelligentEval]:
return IntelligentEvalRepository(session).list_all()
def list_evals_page(
session: Session, offset: int, limit: int, status: Optional[str] = None
) -> tuple[list[IntelligentEval], int, dict[str, int]]:
"""Return one page of evals (newest first), total count, and per-status counts."""
repo = IntelligentEvalRepository(session)
return repo.list_page(offset, limit, status), repo.count(), repo.count_by_status()
def eval_status_counts(session: Session) -> dict[str, int]:
"""Count evaluations per status (for the list page stat bar)."""
return IntelligentEvalRepository(session).count_by_status()
def _get_session_or_raise(repo: IntelligentEvalSessionRepository, session_id: str) -> IntelligentEvalSession:
obj = repo.get(session_id)
if obj is None:
raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found")
return obj
def open_session(
session: Session,
*,
eval_id: str,
persona: dict[str, Any],
goal: str,
dimension: str | None = None,
) -> IntelligentEvalSession:
"""创建虚拟用户会话:仅 executing 状态且未超粗计划预算的评估可创建。"""
eval_repo = IntelligentEvalRepository(session)
ev = _get_or_raise(eval_repo, eval_id)
if ev.status != IntelligentEvalStatus.EXECUTING:
raise IntelligentEvalTransitionError(f"评估不在执行中(当前 {ev.status.value}),无法创建会话")
# 预算硬闸门ADR-0011会话数不得超过粗计划虚拟用户数
budget = (ev.plan or {}).get("estimated_sessions", 0)
if isinstance(budget, int) and budget > 0:
opened = session.exec(
select(func.count())
.select_from(IntelligentEvalSessionDB)
.where(IntelligentEvalSessionDB.eval_id == ev.id)
).one()
if opened >= budget:
raise IntelligentEvalTransitionError(f"会话数已达粗计划上限({budget} 个虚拟用户),超出预算")
repo = IntelligentEvalSessionRepository(session)
status, created = repo._create_if_executing(
IntelligentEvalSession(
eval_id=ev.id,
target_id=ev.target_id,
persona=persona,
goal=goal,
dimension=dimension,
)
)
if status is CompareAndSetStatus.CONFLICT:
raise IntelligentEvalTransitionError("评估不在执行中,会话创建被拒绝")
if status is CompareAndSetStatus.NOT_FOUND or created is None:
raise IntelligentEvalNotFoundError(f"intelligent eval {eval_id} not found")
return created
async def conduct_turn(session: Session, *, eval_id: str, session_id: str, content: str) -> dict[str, Any]:
"""一轮完整问答:状态检查 → 通道往返 → 双条消息落库 → 轮次自增。"""
repo = IntelligentEvalSessionRepository(session)
obj = _get_owned_session_or_raise(repo, eval_id, session_id)
if obj.status != IntelligentEvalSessionStatus.RUNNING:
raise IntelligentEvalTransitionError("会话不在进行中,拒收消息")
target = TargetRepository(session).get(obj.target_id)
if not target:
raise IntelligentEvalNotFoundError("session target not found")
message_repo = IntelligentEvalMessageRepository(session)
sent_at = utc_now()
channel = ChannelFactory.create(target)
async def record_sent(_send_result: SendResult) -> None:
message = IntelligentEvalMessage(
session_id=obj.id,
role="user",
content=content,
created_at=sent_at,
)
status = message_repo._create_user_and_increment(message)
if status is CompareAndSetStatus.NOT_FOUND:
raise IntelligentEvalNotFoundError(f"intelligent eval session {obj.id} not found")
if status is CompareAndSetStatus.CONFLICT:
raise IntelligentEvalTransitionError("会话不在进行中,拒收消息")
outcome = await channel.exchange(
content,
timeout=get_settings().poll_reply_timeout,
on_sent=record_sent,
)
if outcome.status is ExchangeStatus.SEND_FAILED:
raise IntelligentEvalChannelError(f"评测对象通道发送失败: {outcome.reason}")
if outcome.status is ExchangeStatus.POLL_FAILED:
raise IntelligentEvalChannelError(f"等待评测对象回复失败: {outcome.reason}")
if outcome.status is ExchangeStatus.REPLY_TIMEOUT:
raise IntelligentEvalChannelError("等待评测对象回复超时")
received_at = utc_now()
latency_ms = (
outcome.latency_ms if outcome.latency_ms is not None else int((received_at - sent_at).total_seconds() * 1000)
)
reply_text = outcome.reply_text or ""
message_repo._create(
IntelligentEvalMessage(
session_id=obj.id,
role="assistant",
content=reply_text,
latency_ms=latency_ms,
created_at=received_at,
)
)
return {"reply": reply_text, "latency_ms": latency_ms, "turn_count": obj.turn_count + 1}
def close_session(
session: Session,
*,
eval_id: str,
session_id: str,
verdict: dict[str, Any],
) -> IntelligentEvalSession:
"""关闭会话并记录结论verdict仅 running 会话可关闭。"""
repo = IntelligentEvalSessionRepository(session)
obj = _get_owned_session_or_raise(repo, eval_id, session_id)
status, closed = repo._close_if_running(obj.id, verdict)
if status is CompareAndSetStatus.NOT_FOUND:
raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found")
if status is CompareAndSetStatus.CONFLICT:
raise IntelligentEvalTransitionError("会话不在进行中,无法关闭")
if closed is None:
raise RuntimeError(f"session close returned no session: {session_id}")
return closed
def _get_owned_session_or_raise(
repo: IntelligentEvalSessionRepository,
eval_id: str,
session_id: str,
) -> IntelligentEvalSession:
obj = _get_session_or_raise(repo, session_id)
if obj.eval_id != eval_id:
raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found")
return obj
def list_sessions(session: Session, eval_id: str) -> list[IntelligentEvalSession]:
eval_repo = IntelligentEvalRepository(session)
_get_or_raise(eval_repo, eval_id)
return IntelligentEvalSessionRepository(session).list_by_eval(eval_id)
def list_messages(session: Session, *, eval_id: str, session_id: str) -> list[IntelligentEvalMessage]:
_get_owned_session_or_raise(IntelligentEvalSessionRepository(session), eval_id, session_id)
return IntelligentEvalMessageRepository(session).list_by_session(session_id)
# ---------------------------------------------------------------------------
# 会话过期 watchdogADR-0011running 会话 60 分钟无新轮次 → expired
# ---------------------------------------------------------------------------
SESSION_IDLE_EXPIRE_MINUTES = 60
def expire_stale_running_sessions(session: Session) -> int:
"""把「60 分钟无新轮次」的 running 会话置为 expired。
worker/agent 中断会让会话永久卡在 runningconduct_turn 拒收非 running
会话,而 submit_report 又要求全部终态——不过期的话评估永远无法收尾。
活动判定取会话内最后一条消息的 created_at无消息则退回会话 created_at。
expired 是终态closed_at 一并写入),报告中按不完整证据对待。
Returns:
过期的会话数。
"""
now = utc_now()
# SQLite 读出为 naive datetime阈值须同为 naive 才能在 Python 侧比较
threshold = now.replace(tzinfo=None) - timedelta(minutes=SESSION_IDLE_EXPIRE_MINUTES)
# 单次查询获取所有 running 会话及其最后消息时间(避免 N+1 查询)
stmt = (
select(
IntelligentEvalSessionDB,
func.max(IntelligentEvalMessageDB.created_at).label("last_message_at"),
)
.outerjoin(
IntelligentEvalMessageDB,
IntelligentEvalMessageDB.session_id == IntelligentEvalSessionDB.id,
)
.where(IntelligentEvalSessionDB.status == "running")
.group_by(IntelligentEvalSessionDB.id)
)
rows = session.exec(stmt).all()
expired = 0
for session_row, last_message_at in rows:
last_activity = last_message_at or session_row.created_at
if last_activity is None or last_activity >= threshold:
continue
session_row.status = IntelligentEvalSessionStatus.EXPIRED.value
session_row.closed_at = now
expired += 1
append_decision_log(
session_row.eval_id,
"session_expired",
f"平台兜底:会话 {SESSION_IDLE_EXPIRE_MINUTES} 分钟无新轮次,置为过期(不完整证据)",
"platform",
{"platform_supplemented": True, "session_id": session_row.id, "turn_count": session_row.turn_count},
session,
)
if expired:
session.commit()
return expired
# ---------------------------------------------------------------------------
# planning 双闸 watchdogADR-0011触发 ≥5 次或 ≥30 分钟无提交 → failed
# ---------------------------------------------------------------------------
PLANNING_MAX_ATTEMPTS = 5
PLANNING_TIMEOUT_MINUTES = 30
# ADR-0011 孤儿 agent 双管之一同一评估的触发冷却agent 进程最长跑
# `timeout 600`60s 扫描节奏下不冷却会堆叠并发 agent
TRIGGER_COOLDOWN_MINUTES = 10
def _last_decision_at(session: Session, eval_id: str, decision_type: str):
"""该评估某类决策日志的最近时间(无则 None"""
return session.exec(
select(func.max(IntelligentEvalDecisionLogDB.created_at)).where(
IntelligentEvalDecisionLogDB.eval_id == eval_id,
IntelligentEvalDecisionLogDB.decision_type == decision_type,
)
).one()
def _in_trigger_cooldown(session: Session, eval_id: str, decision_type: str, now) -> bool:
last = _last_decision_at(session, eval_id, decision_type)
return last is not None and (now - last).total_seconds() < TRIGGER_COOLDOWN_MINUTES * 60
def record_planner_triggers(session: Session) -> int:
"""为「冷却期外」的 planning 评估补一条 planner_trigger 决策日志。
触发次数即日志条数含触发失败——attempt 在触发动作发生前落账),供
enforce_planning_gates 计闸,也让决策过程页可见平台尝试过多少次。
ADR-0011同一评估 10min 冷却内的扫描节拍不重复触发(孤儿 agent 双管),
冷却中的评估不计次数。
Returns:
本次实际触发覆盖的评估数0 表示无需触发 planner
"""
now = utc_now().replace(tzinfo=None)
planning = session.exec(
select(IntelligentEvalDB).where(IntelligentEvalDB.status == IntelligentEvalStatus.PLANNING.value)
).all()
served = 0
for row in planning:
if _in_trigger_cooldown(session, row.id, "planner_trigger", now):
continue
attempt = count_decisions(row.id, "planner_trigger", session) + 1
append_decision_log(
row.id,
"planner_trigger",
f"平台第 {attempt} 次触发 planner 生成粗计划",
"platform",
{"platform_supplemented": True, "attempt": attempt},
session,
)
served += 1
return served
def worker_trigger_candidates(session: Session) -> list[str]:
"""有待认领 worker 任务且冷却期外的评估 id本次 worker 触发的服务对象)。"""
now = utc_now().replace(tzinfo=None)
return [
eval_id
for eval_id in eval_ids_with_pending_worker_tasks(session)
if not _in_trigger_cooldown(session, eval_id, "worker_trigger", now)
]
def record_worker_triggers(session: Session, eval_ids: list[str]) -> None:
"""为本次 worker 触发覆盖的评估落账 worker_trigger供冷却与可见性"""
for eval_id in eval_ids:
attempt = count_decisions(eval_id, "worker_trigger", session) + 1
append_decision_log(
eval_id,
"worker_trigger",
f"平台第 {attempt} 次触发 worker 执行会话/分析",
"platform",
{"platform_supplemented": True, "attempt": attempt},
session,
)
def enforce_planning_gates(session: Session) -> int:
"""planning 双闸:触发次数或时长超限仍未提交粗计划 → 评估置 failed。
闸一planner_trigger 决策日志 ≥ PLANNING_MAX_ATTEMPTSplanner 反复
触发却交不出计划,视为确定性失败,不再烧触发)。
闸二:进入 planning 超 PLANNING_TIMEOUT_MINUTESupdated_at 在进入
planning/被打回时刷新,即"最近一次进入 planning 的时刻")。
失败原因写入 plan_feedback前端可见并补 planning_gate_failed 决策日志。
Returns:
被判失败的评估数。
"""
now = utc_now().replace(tzinfo=None)
planning = session.exec(
select(IntelligentEvalDB).where(IntelligentEvalDB.status == IntelligentEvalStatus.PLANNING.value)
).all()
failed = 0
for row in planning:
attempts = count_decisions(row.id, "planner_trigger", session)
entered_at = row.updated_at or row.created_at
age_minutes = (now - entered_at).total_seconds() / 60 if entered_at else 0
if attempts >= PLANNING_MAX_ATTEMPTS:
reason = f"planner 已触发 {attempts} 次仍未提交粗计划,按 ADR-0011 双闸判失败"
elif age_minutes >= PLANNING_TIMEOUT_MINUTES:
reason = f"planning 超过 {PLANNING_TIMEOUT_MINUTES} 分钟未提交粗计划,按 ADR-0011 双闸判失败"
else:
continue
if fail_eval(
session,
row.id,
reason,
"planning_gate_failed",
{"attempts": attempts, "age_minutes": int(age_minutes)},
):
failed += 1
return failed
# ---------------------------------------------------------------------------
# executing 兜底 watchdogADR-0011超窗判败 + analyst 催促
# ---------------------------------------------------------------------------
EXECUTING_OVERRUN_GRACE_HOURS = 2
ANALYST_NUDGE_DELAY_MINUTES = 10
ANALYST_NUDGE_MAX = 3
_TERMINAL_SESSION_STATUSES = ("completed", "failed", "expired")
def enforce_executing_ceiling(session: Session) -> int:
"""executing 总时长超过 time_window_hours + 宽限 → 评估置 failed。
所有自愈手段任务重试、会话过期、analyst 催促)都失败后的最终收敛:
评估绝不无声卡死在 executing。原因写 plan_feedback + executing_timeout
决策日志,前端执行过程页可见。
Returns:
被判失败的评估数。
"""
now = utc_now().replace(tzinfo=None)
executing = session.exec(
select(IntelligentEvalDB).where(IntelligentEvalDB.status == IntelligentEvalStatus.EXECUTING.value)
).all()
failed = 0
for row in executing:
if row.started_at is None:
continue
deadline = row.started_at + timedelta(
hours=row.time_window_hours + EXECUTING_OVERRUN_GRACE_HOURS
)
if now < deadline:
continue
reason = (
f"executing 超过 {row.time_window_hours}h 时间窗 + {EXECUTING_OVERRUN_GRACE_HOURS}h 宽限"
"仍未完成,按 ADR-0011 判失败"
)
if fail_eval(
session,
row.id,
reason,
"executing_timeout",
{"time_window_hours": row.time_window_hours},
):
failed += 1
return failed
def _window_has_pending_future_slots(eval_db, sessions, now) -> bool:
"""窗口未结束且会话数未达计划 → 未来时段还要建会话,不该催 analyst。"""
if not eval_db.plan or not eval_db.started_at:
return False
plan = eval_db.get_plan()
slots = plan.get("time_distribution") or []
end_hours: list[float] = []
planned = 0
for slot in slots:
parsed = parse_time_slot(slot.get("time_slot", ""))
if parsed is None:
continue
end_hours.append(parsed[1])
planned += int(slot.get("sessions", 0) or 0)
if not end_hours:
return False
window_end = (as_utc(eval_db.started_at) + timedelta(hours=max(end_hours))).replace(tzinfo=None)
if now >= window_end:
return False
expected = max(planned, int(plan.get("estimated_sessions", 0) or 0))
return len(sessions) < expected
def evals_needing_analyst_nudge(session: Session) -> list[str]:
"""返回需要平台催促 analyst 的 executing 评估 id 列表。
条件ADR-0011存在会话且全部终态、时间窗口内无未到期时段欠账、
末会话终态已满 ANALYST_NUDGE_DELAY_MINUTES、催促次数 < ANALYST_NUDGE_MAX、
距上次催促已满 ANALYST_NUDGE_DELAY_MINUTES冷却避免每分钟连发
"""
now = utc_now().replace(tzinfo=None)
executing = session.exec(
select(IntelligentEvalDB).where(IntelligentEvalDB.status == IntelligentEvalStatus.EXECUTING.value)
).all()
needing: list[str] = []
for row in executing:
eval_sessions = session.exec(
select(IntelligentEvalSessionDB).where(IntelligentEvalSessionDB.eval_id == row.id)
).all()
if not eval_sessions or any(s.status not in _TERMINAL_SESSION_STATUSES for s in eval_sessions):
continue
# 冒烟教训:窗口未结束且会话数未达计划时,未来时段到期后还要建会话,
# 此时催促 analyst 会让报告提前收敛(漏掉后续时段的证据)
if _window_has_pending_future_slots(row, eval_sessions, now):
continue
closed_moments = [s.closed_at for s in eval_sessions if s.closed_at is not None]
if not closed_moments:
continue
last_closed = max(closed_moments)
if (now - last_closed).total_seconds() < ANALYST_NUDGE_DELAY_MINUTES * 60:
continue
if count_decisions(row.id, "analyst_nudge", session) >= ANALYST_NUDGE_MAX:
continue
# 冷却复用ADR-0011最近的 analyst_nudge 或 worker_trigger 都会
# 唤起 agent最长 10min冷却期内不再催促以免堆叠孤儿 agent
recent_triggers = []
for dtype in ("analyst_nudge", "worker_trigger"):
last = _last_decision_at(session, row.id, dtype)
if last is not None:
recent_triggers.append(last)
if recent_triggers and (now - max(recent_triggers)).total_seconds() < ANALYST_NUDGE_DELAY_MINUTES * 60:
continue
needing.append(row.id)
return needing
def record_analyst_nudge(session: Session, eval_id: str) -> None:
"""落账一次 analyst 催促analyst_nudge 决策日志attempt 递增供上限计数)。"""
attempt = count_decisions(eval_id, "analyst_nudge", session) + 1
append_decision_log(
eval_id,
"analyst_nudge",
f"平台第 {attempt} 次催促 analyst 汇总报告(所有会话已终态)",
"platform",
{"platform_supplemented": True, "attempt": attempt},
session,
)
# ---------------------------------------------------------------------------
# 触发失败持久化ADR-0011连续 3 次触发失败 → 评估 failed
# ---------------------------------------------------------------------------
TRIGGER_FAILURE_MAX = 3
def eval_ids_with_pending_worker_tasks(session: Session) -> list[str]:
"""当前有待认领 worker 任务的评估 id触发失败的受影响方"""
rows = session.exec(
select(IntelligentEvalTaskQueueDB.eval_id).where(IntelligentEvalTaskQueueDB.status == "pending")
).all()
return sorted(set(rows))
def record_trigger_failures(session: Session, *, channel: str, eval_ids: list[str], error: str) -> int:
"""触发失败落账:为每个受影响评估写一条 trigger_failed 决策日志。
channel 区分 "worker" / "planner";失败原因截断落 reason供决策过程页可见。
Returns:
落账条数。
"""
recorded = 0
for eval_id in eval_ids:
attempt = count_decisions(eval_id, "trigger_failed", session) + 1
append_decision_log(
eval_id,
"trigger_failed",
f"平台触发 {channel} 失败(第 {attempt} 次):{error[:200]}",
"platform",
{"platform_supplemented": True, "channel": channel, "attempt": attempt},
session,
)
recorded += 1
return recorded
def fail_eval(session: Session, eval_id: str, reason: str, decision_type: str, context: dict[str, Any]) -> bool:
"""watchdog 判失败的统一接缝:表校验 → CAS 条件写 → 决策日志留痕。
所有平台侧"置 failed"必须穿过这里——``_TRANSITIONS`` 是唯一真相,
条件写挡住并发竞争(如用户在扫描间隙抢先取消)。
Returns:
True 判失败成功False 表示跳过(评估不存在、非法转换或 CAS 冲突
——状态已被他人收敛,属正常竞争结局,仅留日志不当故障)。
"""
logger = logging.getLogger("agenteval")
repo = IntelligentEvalRepository(session)
ev = repo.get(eval_id)
if ev is None:
logger.info("fail_eval 跳过:评估 %s 已不存在", eval_id)
return False
if IntelligentEvalStatus.FAILED not in _TRANSITIONS.get(ev.status, set()):
logger.warning("fail_eval 被状态表挡下:%s 当前 %s,不允许转 failed", eval_id, ev.status.value)
return False
now = utc_now()
result = repo._compare_and_set_fields(
eval_id,
expected_status=ev.status,
values={
"status": IntelligentEvalStatus.FAILED.value,
"plan_feedback": reason,
"updated_at": now,
"completed_at": now,
},
)
if result.status is not CompareAndSetStatus.APPLIED:
logger.info("fail_eval 跳过:评估 %s 状态已被并发收敛(%s", eval_id, result.status)
return False
append_decision_log(
eval_id,
decision_type,
f"平台兜底:{reason}",
"platform",
{"platform_supplemented": True, **context},
session,
)
return True
def enforce_trigger_failure_gates(session: Session) -> int:
"""连续触发失败判死ADR-0011
- planning 评估planner 触发失败 ≥ TRIGGER_FAILURE_MAX 次即失败
(仍在 planning 说明 planner 从未成功,失败必然连续)。
- executing 评估:自最近一次任务被认领以来 worker 触发失败 ≥ 上限即失败
(期间无认领 = 触发从未生效,即"连续")。
Returns:
被判失败的评估数。
"""
def _failures(eval_id: str, channel: str) -> list:
logs = session.exec(
select(IntelligentEvalDecisionLogDB).where(
IntelligentEvalDecisionLogDB.eval_id == eval_id,
IntelligentEvalDecisionLogDB.decision_type == "trigger_failed",
)
).all()
return [x for x in logs if x.get_context().get("channel") == channel]
failed = 0
stuck = session.exec(
select(IntelligentEvalDB).where(
IntelligentEvalDB.status.in_(
[IntelligentEvalStatus.PLANNING.value, IntelligentEvalStatus.EXECUTING.value]
)
)
).all()
for row in stuck:
if row.status == IntelligentEvalStatus.PLANNING.value:
failures = _failures(row.id, "planner")
if len(failures) < TRIGGER_FAILURE_MAX:
continue
reason = f"planner 连续触发失败 {len(failures)} 次,按 ADR-0011 判失败"
if fail_eval(
session, row.id, reason, "trigger_failure_gate", {"channel": "planner", "failures": len(failures)}
):
failed += 1
else:
last_assigned = session.exec(
select(func.max(IntelligentEvalTaskQueueDB.assigned_at)).where(
IntelligentEvalTaskQueueDB.eval_id == row.id
)
).one()
failures = [
x
for x in _failures(row.id, "worker")
if last_assigned is None or (x.created_at is not None and x.created_at > last_assigned)
]
if len(failures) < TRIGGER_FAILURE_MAX:
continue
reason = f"worker 连续触发失败 {len(failures)} 次且期间无任务被认领,按 ADR-0011 判失败"
if fail_eval(
session, row.id, reason, "trigger_failure_gate", {"channel": "worker", "failures": len(failures)}
):
failed += 1
return failed