Add dark theme, richer Markdown and readable plugin results. Improve Android input evidence, response integrity and diagnostics. Unify lifecycle, guidance receipts and evidence sources across clients. Keep direct action dispatch without an added AI quota approval layer. Align version metadata at 2.2.0 and bundle Helper 1.2.2. Validation: 3063 backend tests before the version bump; 44 after. Frontend: 317 tests and production build passed.
174 lines
6.4 KiB
Python
174 lines
6.4 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
|
|
from artemis.runtime.cancellation import cancellation_reason
|
|
|
|
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.data["cancel_reason"] = cancellation_reason(exc).value
|
|
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)
|