172 lines
6.3 KiB
Python
172 lines
6.3 KiB
Python
"""One bounded, observable budget for a read-only final acceptance attempt."""
|
|
|
|
import asyncio
|
|
from collections.abc import Awaitable, Callable
|
|
from contextvars import ContextVar
|
|
import logging
|
|
import sys
|
|
import time
|
|
from uuid import uuid4
|
|
|
|
from artemis.data_engine.context_vars import CURRENT_TRACE_ID
|
|
from artemis.llm.reliability import LLMCallError, LLMExhaustedError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Allow evidence selection, a verdict, and (when needed) structured formatting,
|
|
# plus one shared retry allowance. Do not multiply this by the checker's loop
|
|
# limit: every request, tool read, backoff and breaker wait spends the same pool.
|
|
FINAL_REVIEW_REQUEST_SLOTS = 3
|
|
FINAL_REVIEW_TOOL_ALLOWANCE_SECONDS = 60.0
|
|
MAX_FINAL_REVIEW_SECONDS = 3600.0
|
|
|
|
ACTIVE_FINAL_REVIEW: ContextVar["FinalReviewBudget | None"] = ContextVar(
|
|
"final_review_budget", default=None
|
|
)
|
|
|
|
|
|
class FinalReviewBudgetExceeded(TimeoutError):
|
|
def __init__(self, seconds: float):
|
|
super().__init__(f"最终验收总预算已用尽({seconds:g} 秒),尚未取得完整结论")
|
|
|
|
|
|
class FinalReviewBudget:
|
|
def __init__(
|
|
self,
|
|
engine,
|
|
*,
|
|
attempt_id: str,
|
|
request_timeout: float,
|
|
max_retries: int,
|
|
retry_delay: float,
|
|
):
|
|
self.engine = engine
|
|
self.trace_id = uuid4()
|
|
self.parent_id = CURRENT_TRACE_ID.get()
|
|
self.total_seconds = min(
|
|
MAX_FINAL_REVIEW_SECONDS,
|
|
request_timeout * (FINAL_REVIEW_REQUEST_SLOTS + max_retries)
|
|
+ retry_delay * max_retries
|
|
+ FINAL_REVIEW_TOOL_ALLOWANCE_SECONDS,
|
|
)
|
|
self.clock = time.monotonic()
|
|
self.timer: asyncio.Timeout | None = None
|
|
self.data = {
|
|
"review_id": str(self.trace_id),
|
|
"attempt_id": attempt_id,
|
|
"status": "running",
|
|
"stage": "preparing_evidence",
|
|
"iteration": 0,
|
|
"request_count": 0,
|
|
"retry_count": 0,
|
|
"sequence": 0,
|
|
"total_seconds": self.total_seconds,
|
|
"request_timeout_seconds": request_timeout,
|
|
"max_retries": max_retries,
|
|
"retry_allowance_seconds": retry_delay * max_retries,
|
|
}
|
|
|
|
@property
|
|
def expired(self) -> bool:
|
|
"""Only our timer, never a user/guidance cancellation, exhausts the budget."""
|
|
return self.timer is not None and self.timer.expired()
|
|
|
|
def snapshot(self) -> dict:
|
|
elapsed = self.data.get("elapsed_seconds", time.monotonic() - self.clock)
|
|
if self.data["status"] == "running":
|
|
elapsed = time.monotonic() - self.clock
|
|
return {
|
|
**self.data,
|
|
"elapsed_seconds": round(elapsed, 3),
|
|
"remaining_seconds": round(max(0.0, self.total_seconds - elapsed), 3),
|
|
}
|
|
|
|
def publish(self) -> None:
|
|
self.data["sequence"] += 1
|
|
self.data["updated_at"] = time.time()
|
|
if not self.engine or not getattr(self.engine, "current_session_id", None):
|
|
return
|
|
try:
|
|
self.engine.record_trace(
|
|
type="final_review_diagnostic",
|
|
name="final_review",
|
|
trace_id=self.trace_id,
|
|
step_id=getattr(self.engine, "current_step_id", None),
|
|
parent_trace_id=self.parent_id,
|
|
payload=self.snapshot(),
|
|
status="running"
|
|
if self.data["status"] == "running"
|
|
else "success"
|
|
if self.data["status"] == "completed"
|
|
else "failed",
|
|
duration=self.snapshot()["elapsed_seconds"],
|
|
)
|
|
except (OSError, RuntimeError, ValueError, TypeError, AttributeError):
|
|
logger.warning("Could not persist final acceptance diagnostics", exc_info=True)
|
|
|
|
def stage(self, stage: str, *, iteration: int | None = None) -> None:
|
|
self.data["stage"] = stage
|
|
if iteration is not None:
|
|
self.data["iteration"] = iteration
|
|
self.publish()
|
|
|
|
def request_started(self, request_id: str, attempt: int) -> None:
|
|
self.data["request_count" if attempt == 1 else "retry_count"] += 1
|
|
self.data.update(
|
|
request_id=request_id, request_attempt=attempt, request_phase="waiting_response"
|
|
)
|
|
self.publish()
|
|
|
|
def request_finished(self, phase: str, category: str | None = None) -> None:
|
|
self.data["request_phase"] = phase
|
|
if category:
|
|
self.data["last_failure_category"] = category
|
|
self.publish()
|
|
|
|
def finish(self, status: str, *, error: BaseException | None = None) -> None:
|
|
self.data.update(
|
|
status=status,
|
|
ended_at=time.time(),
|
|
elapsed_seconds=time.monotonic() - self.clock,
|
|
)
|
|
if error is not None:
|
|
self.data["error_type"] = type(error).__name__
|
|
if isinstance(error, LLMCallError):
|
|
self.data["last_failure_category"] = error.failure.category.value
|
|
self.publish()
|
|
|
|
async def run[T](self, operation: Callable[[], Awaitable[T]]) -> T:
|
|
self.clock = time.monotonic()
|
|
self.data["started_at"] = time.time()
|
|
self.data["deadline_at"] = self.data["started_at"] + self.total_seconds
|
|
token = ACTIVE_FINAL_REVIEW.set(self)
|
|
self.timer = asyncio.timeout(self.total_seconds)
|
|
self.publish()
|
|
try:
|
|
async with self.timer:
|
|
result = await operation()
|
|
except (KeyboardInterrupt, asyncio.CancelledError) as exc:
|
|
self.finish("cancelled", error=exc)
|
|
raise
|
|
except TimeoutError as exc:
|
|
if self.expired:
|
|
error = FinalReviewBudgetExceeded(self.total_seconds)
|
|
self.finish("budget_exhausted", error=error)
|
|
raise error from exc
|
|
raise
|
|
except LLMExhaustedError as exc:
|
|
self.finish("request_retries_exhausted", error=exc)
|
|
raise
|
|
else:
|
|
self.finish("completed")
|
|
return result
|
|
finally:
|
|
if self.data["status"] == "running":
|
|
self.finish("failed", error=sys.exception())
|
|
ACTIVE_FINAL_REVIEW.reset(token)
|
|
|
|
|
|
def final_review_stage(stage: str, *, iteration: int | None = None) -> None:
|
|
if review := ACTIVE_FINAL_REVIEW.get():
|
|
review.stage(stage, iteration=iteration)
|