Spaces:
Running
Running
sync: 145 file da Baida98/AI@0405a40d (2026-08-10 16:35 UTC) [deploy-all]
#28
by Baida07 - opened
- agents/engineering_state.py +255 -0
- agents/unified_loop.py +155 -15
- agents/unified_loop_prompts.py +5 -3
- api/agent.py +10 -1
- api/agent_checkpoint.py +1 -1
- api/auth_guard.py +25 -0
- api/persistence.py +107 -7
- api/scheduler.py +29 -8
- tests/test_auth_scheduler_regressions.py +111 -0
- tests/test_engineering_state.py +92 -0
agents/engineering_state.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Versioned, bounded engineering lifecycle state for the unified agent loop.
|
| 2 |
+
|
| 3 |
+
The module is deliberately dependency-free. It mirrors the legacy lifecycle without
|
| 4 |
+
being authoritative for recovery when the rollout mode is enabled, and it never stores
|
| 5 |
+
raw prompts, credentials, or arbitrary tool output.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import time
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from enum import Enum
|
| 15 |
+
from typing import Any, Mapping
|
| 16 |
+
|
| 17 |
+
SCHEMA_VERSION = 1
|
| 18 |
+
MAX_HISTORY = 64
|
| 19 |
+
MAX_DIAGNOSTICS = 24
|
| 20 |
+
MAX_PREVIEW_CHARS = 256
|
| 21 |
+
MAX_ID_CHARS = 180
|
| 22 |
+
|
| 23 |
+
_SECRET_PATTERNS = (
|
| 24 |
+
re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{8,}"),
|
| 25 |
+
re.compile(r"(?i)(api[_-]?key\s*[:=]\s*)[^\s,;]+"),
|
| 26 |
+
re.compile(r"(?i)(token\s*[:=]\s*)[^\s,;]+"),
|
| 27 |
+
re.compile(r"(?i)\b(?:ghp|gho|github_pat|hf|sk|xoxb|xapp|r8)_[A-Za-z0-9_-]{8,}\b"),
|
| 28 |
+
re.compile(r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class EngineeringStateMode(str, Enum):
|
| 33 |
+
OFF = "off"
|
| 34 |
+
SHADOW = "shadow"
|
| 35 |
+
CANARY = "canary"
|
| 36 |
+
AUTHORITATIVE = "authoritative"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class EngineeringStateConfig:
|
| 41 |
+
"""Conservative rollout configuration read once per run."""
|
| 42 |
+
|
| 43 |
+
mode: EngineeringStateMode = EngineeringStateMode.OFF
|
| 44 |
+
canary_rate: float = 0.0
|
| 45 |
+
|
| 46 |
+
@classmethod
|
| 47 |
+
def from_env(cls) -> "EngineeringStateConfig":
|
| 48 |
+
raw_mode = os.getenv("ENGINEERING_STATE_MODE", "authoritative").strip().lower() # P1 default; off remains an explicit rollback mode
|
| 49 |
+
try:
|
| 50 |
+
mode = EngineeringStateMode(raw_mode)
|
| 51 |
+
except ValueError:
|
| 52 |
+
mode = EngineeringStateMode.OFF
|
| 53 |
+
try:
|
| 54 |
+
rate = float(os.getenv("ENGINEERING_STATE_CANARY_RATE", "0"))
|
| 55 |
+
except (TypeError, ValueError):
|
| 56 |
+
rate = 0.0
|
| 57 |
+
return cls(mode=mode, canary_rate=max(0.0, min(rate, 1.0)))
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def enabled(self) -> bool:
|
| 61 |
+
return self.mode is not EngineeringStateMode.OFF
|
| 62 |
+
|
| 63 |
+
def selects_canary(self, run_id: str, session_id: str) -> bool:
|
| 64 |
+
if self.mode is not EngineeringStateMode.CANARY or not session_id:
|
| 65 |
+
return False
|
| 66 |
+
if self.canary_rate >= 1.0:
|
| 67 |
+
return True
|
| 68 |
+
if self.canary_rate <= 0.0:
|
| 69 |
+
return False
|
| 70 |
+
digest = hashlib.sha256(f"{run_id}:{session_id}".encode()).digest()
|
| 71 |
+
bucket = int.from_bytes(digest[:8], "big") / float(2**64)
|
| 72 |
+
return bucket < self.canary_rate
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _bounded_id(value: str | None) -> str:
|
| 76 |
+
return re.sub(r"[^A-Za-z0-9_.:/-]", "_", str(value or ""))[:MAX_ID_CHARS]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def redact_text(value: object, max_chars: int = MAX_PREVIEW_CHARS) -> str:
|
| 80 |
+
"""Redact common credential forms before anything reaches a checkpoint."""
|
| 81 |
+
text = str(value or "")[: max_chars * 4]
|
| 82 |
+
for pattern in _SECRET_PATTERNS:
|
| 83 |
+
if pattern.groups:
|
| 84 |
+
text = pattern.sub(lambda match: f"{match.group(1)}[REDACTED]", text)
|
| 85 |
+
else:
|
| 86 |
+
text = pattern.sub("[REDACTED]", text)
|
| 87 |
+
return text[:max_chars]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
_ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = {
|
| 91 |
+
"IDLE": frozenset({"CLASSIFYING", "FAILED"}),
|
| 92 |
+
"CLASSIFYING": frozenset({"TOOL_EXECUTING", "THINKING", "COMPLETED", "FAILED"}),
|
| 93 |
+
"TOOL_EXECUTING": frozenset({"THINKING", "COMPLETED", "FAILED"}),
|
| 94 |
+
"THINKING": frozenset({"COMPLETED", "FAILED"}),
|
| 95 |
+
"FAILED": frozenset({"IDLE"}),
|
| 96 |
+
"COMPLETED": frozenset({"IDLE", "FAILED"}),
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@dataclass
|
| 101 |
+
class EngineeringState:
|
| 102 |
+
"""Bounded state envelope that can be persisted and safely restored."""
|
| 103 |
+
|
| 104 |
+
run_id: str
|
| 105 |
+
session_id: str
|
| 106 |
+
checkpoint_id: str
|
| 107 |
+
goal_digest: str
|
| 108 |
+
goal_preview: str
|
| 109 |
+
current_state: str = "IDLE"
|
| 110 |
+
history: list[dict[str, Any]] = field(default_factory=list)
|
| 111 |
+
diagnostics: list[str] = field(default_factory=list)
|
| 112 |
+
revision: int = 0
|
| 113 |
+
sequence: int = 0
|
| 114 |
+
created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
| 115 |
+
updated_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
| 116 |
+
|
| 117 |
+
@classmethod
|
| 118 |
+
def start(
|
| 119 |
+
cls,
|
| 120 |
+
goal: str,
|
| 121 |
+
*,
|
| 122 |
+
run_id: str,
|
| 123 |
+
session_id: str = "",
|
| 124 |
+
checkpoint_id: str | None = None,
|
| 125 |
+
now_ms: int | None = None,
|
| 126 |
+
) -> "EngineeringState":
|
| 127 |
+
now = int(time.time() * 1000) if now_ms is None else int(now_ms)
|
| 128 |
+
normalized_goal = str(goal or "")
|
| 129 |
+
return cls(
|
| 130 |
+
run_id=_bounded_id(run_id),
|
| 131 |
+
session_id=_bounded_id(session_id),
|
| 132 |
+
checkpoint_id=_bounded_id(checkpoint_id or session_id or run_id),
|
| 133 |
+
goal_digest=hashlib.sha256(normalized_goal.encode("utf-8", "replace")).hexdigest(),
|
| 134 |
+
goal_preview=redact_text(normalized_goal),
|
| 135 |
+
created_at_ms=now,
|
| 136 |
+
updated_at_ms=now,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
@property
|
| 140 |
+
def status(self) -> str:
|
| 141 |
+
if self.current_state == "COMPLETED":
|
| 142 |
+
return "completed"
|
| 143 |
+
if self.current_state == "FAILED":
|
| 144 |
+
return "failed"
|
| 145 |
+
return "active"
|
| 146 |
+
|
| 147 |
+
def transition(self, next_state: str, *, now_ms: int | None = None) -> bool:
|
| 148 |
+
"""Apply an idempotent transition; reject illegal transitions deterministically."""
|
| 149 |
+
target = str(next_state)
|
| 150 |
+
if target == self.current_state:
|
| 151 |
+
return False
|
| 152 |
+
allowed = _ALLOWED_TRANSITIONS.get(self.current_state, frozenset())
|
| 153 |
+
if target not in allowed:
|
| 154 |
+
raise ValueError(f"Invalid EngineeringState transition: {self.current_state} -> {target}")
|
| 155 |
+
now = int(time.time() * 1000) if now_ms is None else int(now_ms)
|
| 156 |
+
self.sequence += 1
|
| 157 |
+
self.revision += 1
|
| 158 |
+
self.history.append({
|
| 159 |
+
"sequence": self.sequence,
|
| 160 |
+
"from_state": self.current_state,
|
| 161 |
+
"to_state": target,
|
| 162 |
+
"at_ms": now,
|
| 163 |
+
})
|
| 164 |
+
if len(self.history) > MAX_HISTORY:
|
| 165 |
+
del self.history[:-MAX_HISTORY]
|
| 166 |
+
self.current_state = target
|
| 167 |
+
self.updated_at_ms = now
|
| 168 |
+
return True
|
| 169 |
+
|
| 170 |
+
def prepare_for_resume(self) -> None:
|
| 171 |
+
"""Normalize a restored snapshot before a new loop execution."""
|
| 172 |
+
if self.current_state != "IDLE":
|
| 173 |
+
self.current_state = "IDLE"
|
| 174 |
+
self.revision += 1
|
| 175 |
+
self.updated_at_ms = int(time.time() * 1000)
|
| 176 |
+
self.diagnostic("resume normalized state to IDLE")
|
| 177 |
+
|
| 178 |
+
def diagnostic(self, message: str) -> None:
|
| 179 |
+
value = redact_text(message, 180)
|
| 180 |
+
if not value or value in self.diagnostics:
|
| 181 |
+
return
|
| 182 |
+
self.diagnostics.append(value)
|
| 183 |
+
if len(self.diagnostics) > MAX_DIAGNOSTICS:
|
| 184 |
+
del self.diagnostics[:-MAX_DIAGNOSTICS]
|
| 185 |
+
self.revision += 1
|
| 186 |
+
self.updated_at_ms = int(time.time() * 1000)
|
| 187 |
+
|
| 188 |
+
def snapshot(self) -> dict[str, Any]:
|
| 189 |
+
"""Return a bounded JSON-compatible envelope; never expose the raw goal."""
|
| 190 |
+
return {
|
| 191 |
+
"schema_version": SCHEMA_VERSION,
|
| 192 |
+
"run_id": self.run_id,
|
| 193 |
+
"session_id": self.session_id,
|
| 194 |
+
"checkpoint_id": self.checkpoint_id,
|
| 195 |
+
"goal_digest": self.goal_digest,
|
| 196 |
+
"goal_preview": self.goal_preview,
|
| 197 |
+
"status": self.status,
|
| 198 |
+
"current_state": self.current_state,
|
| 199 |
+
"revision": self.revision,
|
| 200 |
+
"sequence": self.sequence,
|
| 201 |
+
"history": list(self.history[-MAX_HISTORY:]),
|
| 202 |
+
"diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
|
| 203 |
+
"created_at_ms": self.created_at_ms,
|
| 204 |
+
"updated_at_ms": self.updated_at_ms,
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
def projection(self) -> dict[str, Any]:
|
| 208 |
+
"""Small read-only view safe for API/SSE consumers."""
|
| 209 |
+
return {
|
| 210 |
+
"schema_version": SCHEMA_VERSION,
|
| 211 |
+
"status": self.status,
|
| 212 |
+
"current_state": self.current_state,
|
| 213 |
+
"revision": self.revision,
|
| 214 |
+
"sequence": self.sequence,
|
| 215 |
+
"checkpoint_id": self.checkpoint_id,
|
| 216 |
+
"history": [dict(item) for item in self.history[-16:]],
|
| 217 |
+
"diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
@classmethod
|
| 221 |
+
def from_snapshot(cls, payload: Mapping[str, Any]) -> "EngineeringState":
|
| 222 |
+
if not isinstance(payload, Mapping):
|
| 223 |
+
raise ValueError("engineering state must be an object")
|
| 224 |
+
if int(payload.get("schema_version", -1)) != SCHEMA_VERSION:
|
| 225 |
+
raise ValueError("unsupported engineering state schema")
|
| 226 |
+
history = payload.get("history", [])
|
| 227 |
+
diagnostics = payload.get("diagnostics", [])
|
| 228 |
+
if not isinstance(history, list) or len(history) > MAX_HISTORY:
|
| 229 |
+
raise ValueError("invalid engineering state history")
|
| 230 |
+
if not isinstance(diagnostics, list) or len(diagnostics) > MAX_DIAGNOSTICS:
|
| 231 |
+
raise ValueError("invalid engineering state diagnostics")
|
| 232 |
+
current = str(payload.get("current_state", ""))
|
| 233 |
+
if current not in _ALLOWED_TRANSITIONS:
|
| 234 |
+
raise ValueError("invalid engineering state current state")
|
| 235 |
+
revision = int(payload.get("revision", -1))
|
| 236 |
+
sequence = int(payload.get("sequence", -1))
|
| 237 |
+
if revision < 0 or sequence < 0 or revision < sequence:
|
| 238 |
+
raise ValueError("invalid engineering state revision")
|
| 239 |
+
state = cls(
|
| 240 |
+
run_id=_bounded_id(str(payload.get("run_id", ""))),
|
| 241 |
+
session_id=_bounded_id(str(payload.get("session_id", ""))),
|
| 242 |
+
checkpoint_id=_bounded_id(str(payload.get("checkpoint_id", ""))),
|
| 243 |
+
goal_digest=str(payload.get("goal_digest", "")),
|
| 244 |
+
goal_preview=redact_text(payload.get("goal_preview", "")),
|
| 245 |
+
current_state=current,
|
| 246 |
+
history=[dict(item) for item in history if isinstance(item, Mapping)],
|
| 247 |
+
diagnostics=[redact_text(item, 180) for item in diagnostics],
|
| 248 |
+
revision=revision,
|
| 249 |
+
sequence=sequence,
|
| 250 |
+
created_at_ms=int(payload.get("created_at_ms", 0)),
|
| 251 |
+
updated_at_ms=int(payload.get("updated_at_ms", 0)),
|
| 252 |
+
)
|
| 253 |
+
if len(state.goal_digest) != 64 or not re.fullmatch(r"[0-9a-f]{64}", state.goal_digest):
|
| 254 |
+
raise ValueError("invalid engineering state goal digest")
|
| 255 |
+
return state
|
agents/unified_loop.py
CHANGED
|
@@ -63,6 +63,48 @@ from agents.unified_loop_types import (
|
|
| 63 |
# I4.5: active state is scoped to the current asyncio task, not the loop instance.
|
| 64 |
# This lets the public guard close unexpected exceptions without sharing state across runs.
|
| 65 |
_ACTIVE_LOOP_STATE: ContextVar[UnifiedLoopState | None] = ContextVar("active_loop_state", default=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
# S404: Error Classifier â import lazy per evitare circular import issues
|
| 68 |
def _get_classifier():
|
|
@@ -143,15 +185,31 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 143 |
"""Validate and publish one per-run state transition."""
|
| 144 |
previous = state.state_machine.current
|
| 145 |
state.state_machine.transition(next_state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
if previous == next_state or on_step is None:
|
| 147 |
return
|
| 148 |
try:
|
| 149 |
-
|
| 150 |
"action": "state_transition",
|
| 151 |
"status": "done",
|
| 152 |
"from_state": previous.value,
|
| 153 |
"to_state": next_state.value,
|
| 154 |
-
}
|
|
|
|
|
|
|
|
|
|
| 155 |
except Exception as _state_callback_error:
|
| 156 |
_logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
|
| 157 |
|
|
@@ -545,7 +603,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 545 |
"explanation": "Il pianificatore ha impiegato troppo â procedo senza piano",
|
| 546 |
"visibility": "progress",
|
| 547 |
}))
|
| 548 |
-
if plan
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
state.steps.append({"action": "plan", "result": plan})
|
| 550 |
try:
|
| 551 |
from api.state import record_timing as _rtc_pl
|
|
@@ -952,6 +1015,15 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 952 |
tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None))
|
| 953 |
reg_name, inp_builder = tool_key_pair
|
| 954 |
if reg_name and inp_builder is not None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 955 |
_pending_exec.append((subtask, reg_name, inp_builder))
|
| 956 |
elif _s_tool:
|
| 957 |
# COG-4: tool non in _TOOL_MAP — tenta generazione dinamica
|
|
@@ -3389,6 +3461,8 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3389 |
session_id: str = "") -> dict[str, Any]:
|
| 3390 |
"""Run the loop and close unexpected exceptions as a controlled FAILED state."""
|
| 3391 |
previous_state = _ACTIVE_LOOP_STATE.get()
|
|
|
|
|
|
|
| 3392 |
try:
|
| 3393 |
return await self._run_impl(goal, context, max_steps, on_step, session_id)
|
| 3394 |
except Exception as _run_error:
|
|
@@ -3406,17 +3480,14 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3406 |
state.errors.append(error_text)
|
| 3407 |
previous = state.state_machine.current
|
| 3408 |
if previous != AgentState.FAILED:
|
| 3409 |
-
|
| 3410 |
-
|
| 3411 |
-
|
| 3412 |
-
|
| 3413 |
-
|
| 3414 |
-
|
| 3415 |
-
|
| 3416 |
-
|
| 3417 |
-
}))
|
| 3418 |
-
except Exception as _state_callback_error:
|
| 3419 |
-
_logger.debug("[unified_loop] failure callback silenced: %s", _state_callback_error)
|
| 3420 |
return {
|
| 3421 |
"success": False,
|
| 3422 |
"goal": state.goal,
|
|
@@ -3427,6 +3498,8 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3427 |
}
|
| 3428 |
finally:
|
| 3429 |
_ACTIVE_LOOP_STATE.set(previous_state)
|
|
|
|
|
|
|
| 3430 |
|
| 3431 |
async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8,
|
| 3432 |
on_step: StepCallback | None = None,
|
|
@@ -3489,16 +3562,83 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3489 |
max_steps = 12
|
| 3490 |
|
| 3491 |
state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3492 |
_ACTIVE_LOOP_STATE.set(state)
|
| 3493 |
await self._transition_state(state, AgentState.CLASSIFYING, on_step)
|
| 3494 |
|
| 3495 |
def _with_state(result: dict[str, Any]) -> dict[str, Any]:
|
| 3496 |
result.update(state.state_machine.snapshot())
|
|
|
|
|
|
|
| 3497 |
return result
|
| 3498 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3499 |
async def _finish(result: dict[str, Any]) -> dict[str, Any]:
|
| 3500 |
next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
|
| 3501 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3502 |
return _with_state(result)
|
| 3503 |
|
| 3504 |
# GAP-4: StrategicHealer — init + load past failures (LLM-based self-healing cognitivo)
|
|
|
|
| 63 |
# I4.5: active state is scoped to the current asyncio task, not the loop instance.
|
| 64 |
# This lets the public guard close unexpected exceptions without sharing state across runs.
|
| 65 |
_ACTIVE_LOOP_STATE: ContextVar[UnifiedLoopState | None] = ContextVar("active_loop_state", default=None)
|
| 66 |
+
# P0: EngineeringState is a shadow/canary projection of the legacy lifecycle.
|
| 67 |
+
# Context-local storage keeps parallel runs isolated even when one loop instance is reused.
|
| 68 |
+
from agents.engineering_state import EngineeringState, EngineeringStateConfig, EngineeringStateMode
|
| 69 |
+
|
| 70 |
+
_ACTIVE_ENGINEERING_STATE: ContextVar[EngineeringState | None] = ContextVar(
|
| 71 |
+
"active_engineering_state", default=None
|
| 72 |
+
)
|
| 73 |
+
_ACTIVE_ENGINEERING_MODE: ContextVar[EngineeringStateMode | None] = ContextVar(
|
| 74 |
+
"active_engineering_mode", default=None
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _schedule_engineering_persist(engineering_state: EngineeringState) -> None:
|
| 79 |
+
"""Persist a snapshot without blocking the loop or making observability fatal."""
|
| 80 |
+
snapshot = engineering_state.snapshot()
|
| 81 |
+
|
| 82 |
+
async def _persist() -> None:
|
| 83 |
+
try:
|
| 84 |
+
from api.persistence import sb_save_engineering_state
|
| 85 |
+
await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot)
|
| 86 |
+
except Exception as exc: # shadow state must never break the user task
|
| 87 |
+
_logger.debug("[engineering-state] persist silenced: %s", type(exc).__name__)
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
task = asyncio.create_task(_persist())
|
| 91 |
+
task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None)
|
| 92 |
+
except RuntimeError:
|
| 93 |
+
# No running event loop during defensive/test-only calls.
|
| 94 |
+
return
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
async def _flush_engineering_persist(engineering_state: EngineeringState | None) -> None:
|
| 98 |
+
"""Flush the terminal snapshot before returning a run result."""
|
| 99 |
+
if engineering_state is None:
|
| 100 |
+
return
|
| 101 |
+
snapshot = engineering_state.snapshot()
|
| 102 |
+
try:
|
| 103 |
+
from api.persistence import sb_save_engineering_state
|
| 104 |
+
await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot, force=True)
|
| 105 |
+
except Exception as exc: # persistence must not turn a completed task into a crash
|
| 106 |
+
engineering_state.diagnostic(f"final persist failed: {type(exc).__name__}")
|
| 107 |
+
_logger.debug("[engineering-state] final persist silenced: %s", type(exc).__name__)
|
| 108 |
|
| 109 |
# S404: Error Classifier â import lazy per evitare circular import issues
|
| 110 |
def _get_classifier():
|
|
|
|
| 185 |
"""Validate and publish one per-run state transition."""
|
| 186 |
previous = state.state_machine.current
|
| 187 |
state.state_machine.transition(next_state)
|
| 188 |
+
|
| 189 |
+
# P0 adapter: mirror every legacy transition into the versioned state.
|
| 190 |
+
engineering_state = _ACTIVE_ENGINEERING_STATE.get()
|
| 191 |
+
if engineering_state is not None:
|
| 192 |
+
try:
|
| 193 |
+
engineering_state.transition(next_state.value)
|
| 194 |
+
_schedule_engineering_persist(engineering_state)
|
| 195 |
+
except Exception as exc:
|
| 196 |
+
engineering_state.diagnostic(f"transition adapter: {type(exc).__name__}")
|
| 197 |
+
if _ACTIVE_ENGINEERING_MODE.get() == EngineeringStateMode.AUTHORITATIVE:
|
| 198 |
+
raise
|
| 199 |
+
_logger.debug("[engineering-state] transition silenced: %s", type(exc).__name__)
|
| 200 |
+
|
| 201 |
if previous == next_state or on_step is None:
|
| 202 |
return
|
| 203 |
try:
|
| 204 |
+
event = {
|
| 205 |
"action": "state_transition",
|
| 206 |
"status": "done",
|
| 207 |
"from_state": previous.value,
|
| 208 |
"to_state": next_state.value,
|
| 209 |
+
}
|
| 210 |
+
if engineering_state is not None:
|
| 211 |
+
event["engineering_state"] = engineering_state.projection()
|
| 212 |
+
await _maybe_await(on_step(event))
|
| 213 |
except Exception as _state_callback_error:
|
| 214 |
_logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
|
| 215 |
|
|
|
|
| 603 |
"explanation": "Il pianificatore ha impiegato troppo â procedo senza piano",
|
| 604 |
"visibility": "progress",
|
| 605 |
}))
|
| 606 |
+
# P1-RECOVERY: check if plan already exists in steps
|
| 607 |
+
existing_plan_step = next((s for s in state.steps if s.get("action") == "plan"), None)
|
| 608 |
+
if existing_plan_step:
|
| 609 |
+
plan = existing_plan_step.get("result")
|
| 610 |
+
_logger.info("[P1-RECOVERY] Plan restored from steps")
|
| 611 |
+
elif plan is not None:
|
| 612 |
state.steps.append({"action": "plan", "result": plan})
|
| 613 |
try:
|
| 614 |
from api.state import record_timing as _rtc_pl
|
|
|
|
| 1015 |
tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None))
|
| 1016 |
reg_name, inp_builder = tool_key_pair
|
| 1017 |
if reg_name and inp_builder is not None:
|
| 1018 |
+
# P1-RECOVERY: skip subtasks already completed in state.steps
|
| 1019 |
+
_st_id = subtask.get("id")
|
| 1020 |
+
_done_step = next((s for s in state.steps if s.get("subtask_id") == _st_id), None)
|
| 1021 |
+
if _done_step:
|
| 1022 |
+
_logger.info("[P1-RECOVERY] Skipping already completed subtask #%s", _st_id)
|
| 1023 |
+
# Ripristiniamo l'output nel buffer per i dipendenti
|
| 1024 |
+
_existing_out = _done_step.get("output", "")
|
| 1025 |
+
_subtask_outputs[str(_st_id)] = _existing_out
|
| 1026 |
+
continue
|
| 1027 |
_pending_exec.append((subtask, reg_name, inp_builder))
|
| 1028 |
elif _s_tool:
|
| 1029 |
# COG-4: tool non in _TOOL_MAP — tenta generazione dinamica
|
|
|
|
| 3461 |
session_id: str = "") -> dict[str, Any]:
|
| 3462 |
"""Run the loop and close unexpected exceptions as a controlled FAILED state."""
|
| 3463 |
previous_state = _ACTIVE_LOOP_STATE.get()
|
| 3464 |
+
previous_engineering_state = _ACTIVE_ENGINEERING_STATE.get()
|
| 3465 |
+
previous_engineering_mode = _ACTIVE_ENGINEERING_MODE.get()
|
| 3466 |
try:
|
| 3467 |
return await self._run_impl(goal, context, max_steps, on_step, session_id)
|
| 3468 |
except Exception as _run_error:
|
|
|
|
| 3480 |
state.errors.append(error_text)
|
| 3481 |
previous = state.state_machine.current
|
| 3482 |
if previous != AgentState.FAILED:
|
| 3483 |
+
try:
|
| 3484 |
+
await self._transition_state(state, AgentState.FAILED, on_step)
|
| 3485 |
+
except Exception as _state_transition_error:
|
| 3486 |
+
_logger.debug(
|
| 3487 |
+
"[unified_loop] failure transition silenced: %s",
|
| 3488 |
+
_state_transition_error,
|
| 3489 |
+
)
|
| 3490 |
+
await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
|
|
|
|
|
|
|
|
|
|
| 3491 |
return {
|
| 3492 |
"success": False,
|
| 3493 |
"goal": state.goal,
|
|
|
|
| 3498 |
}
|
| 3499 |
finally:
|
| 3500 |
_ACTIVE_LOOP_STATE.set(previous_state)
|
| 3501 |
+
_ACTIVE_ENGINEERING_STATE.set(previous_engineering_state)
|
| 3502 |
+
_ACTIVE_ENGINEERING_MODE.set(previous_engineering_mode)
|
| 3503 |
|
| 3504 |
async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8,
|
| 3505 |
on_step: StepCallback | None = None,
|
|
|
|
| 3562 |
max_steps = 12
|
| 3563 |
|
| 3564 |
state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id)
|
| 3565 |
+
|
| 3566 |
+
# P1: EngineeringState is the recovery authority unless explicitly disabled.
|
| 3567 |
+
engineering_config = EngineeringStateConfig.from_env()
|
| 3568 |
+
_effective_mode = engineering_config.mode
|
| 3569 |
+
_ACTIVE_ENGINEERING_MODE.set(_effective_mode)
|
| 3570 |
+
|
| 3571 |
+
engineering_state: EngineeringState | None = None
|
| 3572 |
+
recovery_status = "disabled"
|
| 3573 |
+
if _effective_mode != EngineeringStateMode.OFF:
|
| 3574 |
+
engineering_state = EngineeringState.start(
|
| 3575 |
+
goal,
|
| 3576 |
+
run_id=self._run_task_id,
|
| 3577 |
+
session_id=session_id,
|
| 3578 |
+
checkpoint_id=session_id or self._run_task_id,
|
| 3579 |
+
)
|
| 3580 |
+
_ACTIVE_ENGINEERING_STATE.set(engineering_state)
|
| 3581 |
+
recovery_status = "started"
|
| 3582 |
+
|
| 3583 |
+
# RECOV-P1.1/P1.2: load and validate EngineeringState before the first transition.
|
| 3584 |
+
if _effective_mode.value in {"canary", "authoritative"} and engineering_state.checkpoint_id:
|
| 3585 |
+
try:
|
| 3586 |
+
from api.persistence import sb_get_checkpoint
|
| 3587 |
+
legacy_checkpoint = await sb_get_checkpoint(engineering_state.checkpoint_id)
|
| 3588 |
+
candidate = (legacy_checkpoint or {}).get("engineering_state")
|
| 3589 |
+
if candidate:
|
| 3590 |
+
restored = EngineeringState.from_snapshot(candidate)
|
| 3591 |
+
if restored.session_id != engineering_state.session_id or restored.goal_digest != engineering_state.goal_digest:
|
| 3592 |
+
engineering_state.diagnostic("restore conflict: identity mismatch")
|
| 3593 |
+
recovery_status = "conflict"
|
| 3594 |
+
elif _effective_mode == EngineeringStateMode.AUTHORITATIVE:
|
| 3595 |
+
engineering_state = restored
|
| 3596 |
+
engineering_state.prepare_for_resume()
|
| 3597 |
+
_ACTIVE_ENGINEERING_STATE.set(engineering_state)
|
| 3598 |
+
if legacy_checkpoint:
|
| 3599 |
+
checkpoint_steps = legacy_checkpoint.get("steps")
|
| 3600 |
+
checkpoint_errors = legacy_checkpoint.get("errors")
|
| 3601 |
+
state.steps = list(checkpoint_steps)[-64:] if isinstance(checkpoint_steps, list) else []
|
| 3602 |
+
state.errors = [str(item)[:512] for item in checkpoint_errors][-24:] if isinstance(checkpoint_errors, list) else []
|
| 3603 |
+
recovery_status = "restored"
|
| 3604 |
+
_logger.info("[P1-RECOVERY] authoritative checkpoint restored revision=%d", restored.revision)
|
| 3605 |
+
else:
|
| 3606 |
+
engineering_state.diagnostic("restore validated read-only")
|
| 3607 |
+
recovery_status = "validated"
|
| 3608 |
+
else:
|
| 3609 |
+
recovery_status = "checkpoint_missing"
|
| 3610 |
+
except Exception as restore_error:
|
| 3611 |
+
engineering_state.diagnostic(f"restore rejected: {type(restore_error).__name__}")
|
| 3612 |
+
recovery_status = "rejected"
|
| 3613 |
+
_logger.debug("[engineering-state] restore silenced: %s", type(restore_error).__name__)
|
| 3614 |
+
|
| 3615 |
_ACTIVE_LOOP_STATE.set(state)
|
| 3616 |
await self._transition_state(state, AgentState.CLASSIFYING, on_step)
|
| 3617 |
|
| 3618 |
def _with_state(result: dict[str, Any]) -> dict[str, Any]:
|
| 3619 |
result.update(state.state_machine.snapshot())
|
| 3620 |
+
if engineering_state is not None:
|
| 3621 |
+
result["engineering_state"] = engineering_state.projection()
|
| 3622 |
return result
|
| 3623 |
|
| 3624 |
+
if engineering_state is not None and on_step is not None:
|
| 3625 |
+
try:
|
| 3626 |
+
await _maybe_await(on_step({
|
| 3627 |
+
"action": "engineering_state",
|
| 3628 |
+
"status": recovery_status,
|
| 3629 |
+
"mode": _effective_mode.value,
|
| 3630 |
+
"engineering_state": engineering_state.projection(),
|
| 3631 |
+
}))
|
| 3632 |
+
except Exception as recovery_event_error:
|
| 3633 |
+
_logger.debug("[engineering-state] recovery event silenced: %s", type(recovery_event_error).__name__)
|
| 3634 |
+
|
| 3635 |
async def _finish(result: dict[str, Any]) -> dict[str, Any]:
|
| 3636 |
next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
|
| 3637 |
+
try:
|
| 3638 |
+
await self._transition_state(state, next_state, on_step)
|
| 3639 |
+
finally:
|
| 3640 |
+
# P1 contract: persist the terminal state before returning to the caller.
|
| 3641 |
+
await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
|
| 3642 |
return _with_state(result)
|
| 3643 |
|
| 3644 |
# GAP-4: StrategicHealer — init + load past failures (LLM-based self-healing cognitivo)
|
agents/unified_loop_prompts.py
CHANGED
|
@@ -47,9 +47,10 @@ class PromptBuilderMixin:
|
|
| 47 |
"8b. Per domande a scelta multipla (A/B/C/D): inizia la risposta con "
|
| 48 |
"\'Risposta: X\' dove X è la lettera scelta, poi spiega il ragionamento.\n"
|
| 49 |
"8c. OBBLIGO TypeScript: ogni snippet di codice TypeScript DEVE essere in blocchi "
|
| 50 |
-
"
|
| 51 |
-
"Il codice deve compilare: nessun placeholder, nessun TODO, tipi espliciti.\n"
|
| 52 |
-
"
|
|
|
|
| 53 |
"10. NON inventare mai informazioni su te stesso: token usati, context window, "
|
| 54 |
"versione, architettura, parametri interni. Se non lo sai con certezza, "
|
| 55 |
"di esplicitamente 'non ho accesso a questa informazione'.\n"
|
|
@@ -1829,3 +1830,4 @@ _CONTEXT_RULES_ADVANCED = [
|
|
| 1829 |
]
|
| 1830 |
|
| 1831 |
|
|
|
|
|
|
| 47 |
"8b. Per domande a scelta multipla (A/B/C/D): inizia la risposta con "
|
| 48 |
"\'Risposta: X\' dove X è la lettera scelta, poi spiega il ragionamento.\n"
|
| 49 |
"8c. OBBLIGO TypeScript: ogni snippet di codice TypeScript DEVE essere in blocchi "
|
| 50 |
+
"```typescript```...```typescript. Mai inline, mai in blocchi generici. "
|
| 51 |
+
"Il codice deve compilare: nessun placeholder, nessun TODO, tipi espliciti. In caso di REFACTORING: sostituisci SEMPRE nomi di variabili a lettera singola (p, m, v) con nomi semantici e descrittivi, e usa interfacce o tipi per ogni oggetto complesso.\n"
|
| 52 |
+
"8d. REASONING: Per problemi complessi, scomponi il problema in sotto-task logici. Verifica la coerenza dei risultati intermedi prima di procedere al calcolo finale.
|
| 53 |
+
9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
|
| 54 |
"10. NON inventare mai informazioni su te stesso: token usati, context window, "
|
| 55 |
"versione, architettura, parametri interni. Se non lo sai con certezza, "
|
| 56 |
"di esplicitamente 'non ho accesso a questa informazione'.\n"
|
|
|
|
| 1830 |
]
|
| 1831 |
|
| 1832 |
|
| 1833 |
+
|
api/agent.py
CHANGED
|
@@ -1067,6 +1067,15 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1067 |
if _action == 'text_chunk':
|
| 1068 |
_sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
|
| 1069 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1070 |
|
| 1071 |
# S363-Blueprint: Narrative Streaming — explanation lookup for ALL step_done events
|
| 1072 |
# S376: _STEP_NARRATIONS espanso — aggiunge 12 tool mancanti
|
|
@@ -1409,7 +1418,7 @@ async def save_checkpoint(task_id: str, body: CheckpointIn, role: AuthRole = Dep
|
|
| 1409 |
'extra': body.extra,
|
| 1410 |
'savedAt': int(time.time() * 1000),
|
| 1411 |
}
|
| 1412 |
-
asyncio.create_task(sb_save_checkpoint(task_id, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
|
| 1413 |
return {'saved': True, 'taskId': task_id, 'step': body.step}
|
| 1414 |
|
| 1415 |
|
|
|
|
| 1067 |
if _action == 'text_chunk':
|
| 1068 |
_sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
|
| 1069 |
return
|
| 1070 |
+
# RECOV-P1: engineering_state event — forward projection to frontend
|
| 1071 |
+
if _action == 'engineering_state':
|
| 1072 |
+
_sse('engineering_state', {
|
| 1073 |
+
'taskId': task_id,
|
| 1074 |
+
'status': step_data.get('status'),
|
| 1075 |
+
'mode': step_data.get('mode'),
|
| 1076 |
+
'engineering_state': step_data.get('engineering_state'),
|
| 1077 |
+
})
|
| 1078 |
+
return
|
| 1079 |
|
| 1080 |
# S363-Blueprint: Narrative Streaming — explanation lookup for ALL step_done events
|
| 1081 |
# S376: _STEP_NARRATIONS espanso — aggiunge 12 tool mancanti
|
|
|
|
| 1418 |
'extra': body.extra,
|
| 1419 |
'savedAt': int(time.time() * 1000),
|
| 1420 |
}
|
| 1421 |
+
asyncio.create_task(sb_save_checkpoint(task_id, body.step, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
|
| 1422 |
return {'saved': True, 'taskId': task_id, 'step': body.step}
|
| 1423 |
|
| 1424 |
|
api/agent_checkpoint.py
CHANGED
|
@@ -95,7 +95,7 @@ async def save_checkpoint_alias(body: CheckpointBody):
|
|
| 95 |
}
|
| 96 |
_task_checkpoints[task_id] = cp
|
| 97 |
# Persist su Supabase — fire-and-forget (stesso pattern di agent.py)
|
| 98 |
-
asyncio.create_task(sb_save_checkpoint(task_id, cp))
|
| 99 |
return {"saved": True, "taskId": task_id, "step": body.step}
|
| 100 |
|
| 101 |
|
|
|
|
| 95 |
}
|
| 96 |
_task_checkpoints[task_id] = cp
|
| 97 |
# Persist su Supabase — fire-and-forget (stesso pattern di agent.py)
|
| 98 |
+
asyncio.create_task(sb_save_checkpoint(task_id, body.step, cp))
|
| 99 |
return {"saved": True, "taskId": task_id, "step": body.step}
|
| 100 |
|
| 101 |
|
api/auth_guard.py
CHANGED
|
@@ -96,6 +96,30 @@ _RATE_LIMITS: dict[int, int] = {
|
|
| 96 |
_RATE_WINDOW_S = 60 # finestra sliding 60s
|
| 97 |
_rate_store: dict[str, _col.deque] = {} # token_hash → deque di timestamps
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
|
| 101 |
"""Chiave rate limiter: hash(role + discriminante) — non espone token né IP in chiaro.
|
|
@@ -123,6 +147,7 @@ def _inmem_rate_check(key: str, limit: int, window_s: float) -> tuple[bool, int]
|
|
| 123 |
"""
|
| 124 |
now = _rl_time.monotonic()
|
| 125 |
window_start = now - window_s
|
|
|
|
| 126 |
|
| 127 |
if key not in _rate_store:
|
| 128 |
_rate_store[key] = _col.deque()
|
|
|
|
| 96 |
_RATE_WINDOW_S = 60 # finestra sliding 60s
|
| 97 |
_rate_store: dict[str, _col.deque] = {} # token_hash → deque di timestamps
|
| 98 |
|
| 99 |
+
# Lo store è usato anche quando Redis non è disponibile. Un client una tantum
|
| 100 |
+
# lasciava una deque vuota nel dict per l'intera vita del processo. Eseguiamo uno
|
| 101 |
+
# sweep ammortizzato: il lavoro resta O(1) per la quasi totalità delle richieste
|
| 102 |
+
# e il numero di chiavi inattive rimane limitato al traffico tra due sweep.
|
| 103 |
+
_RATE_STORE_SWEEP_EVERY = 128
|
| 104 |
+
_rate_store_checks = 0
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _prune_expired_rate_keys(now: float, window_s: float) -> None:
|
| 108 |
+
"""Rimuove bucket in-memory senza timestamp ancora nella finestra corrente."""
|
| 109 |
+
global _rate_store_checks
|
| 110 |
+
_rate_store_checks += 1
|
| 111 |
+
if _rate_store_checks % _RATE_STORE_SWEEP_EVERY:
|
| 112 |
+
return
|
| 113 |
+
|
| 114 |
+
window_start = now - window_s
|
| 115 |
+
stale_keys = [
|
| 116 |
+
stored_key
|
| 117 |
+
for stored_key, timestamps in _rate_store.items()
|
| 118 |
+
if not timestamps or timestamps[-1] < window_start
|
| 119 |
+
]
|
| 120 |
+
for stored_key in stale_keys:
|
| 121 |
+
_rate_store.pop(stored_key, None)
|
| 122 |
+
|
| 123 |
|
| 124 |
def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
|
| 125 |
"""Chiave rate limiter: hash(role + discriminante) — non espone token né IP in chiaro.
|
|
|
|
| 147 |
"""
|
| 148 |
now = _rl_time.monotonic()
|
| 149 |
window_start = now - window_s
|
| 150 |
+
_prune_expired_rate_keys(now, window_s)
|
| 151 |
|
| 152 |
if key not in _rate_store:
|
| 153 |
_rate_store[key] = _col.deque()
|
api/persistence.py
CHANGED
|
@@ -27,6 +27,27 @@ MAX_EVENTS = 500 # max SSE frames persisted per task
|
|
| 27 |
_MAX_RETRY = 2 # GAP-P40D-FIX: tentativi massimi per write Supabase
|
| 28 |
_RETRY_SLEEP = 0.3 # GAP-P40D-FIX: sleep tra tentativi (secondi)
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
# ── Write helpers (fire-and-forget, never raise) ───────────────────────────────
|
| 32 |
|
|
@@ -177,22 +198,101 @@ async def sb_list_tasks(limit: int = 50) -> list[dict]:
|
|
| 177 |
# ── Checkpoint helpers (S359: task state snapshots) ───────────────────────────
|
| 178 |
|
| 179 |
async def sb_save_checkpoint(task_id: str, step: int, checkpoint_data: dict) -> None:
|
| 180 |
-
"""Save a
|
| 181 |
from .state import _sb
|
| 182 |
if not _sb:
|
| 183 |
return
|
| 184 |
now = int(time.time() * 1000)
|
| 185 |
try:
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
except Exception as e:
|
| 193 |
_logger.debug('[persist] save_checkpoint %s#%d: %s', task_id, step, e)
|
| 194 |
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
async def sb_get_checkpoint(task_id: str) -> Optional[dict]:
|
| 197 |
"""Retrieve latest checkpoint for a task."""
|
| 198 |
from .state import _sb
|
|
|
|
| 27 |
_MAX_RETRY = 2 # GAP-P40D-FIX: tentativi massimi per write Supabase
|
| 28 |
_RETRY_SLEEP = 0.3 # GAP-P40D-FIX: sleep tra tentativi (secondi)
|
| 29 |
|
| 30 |
+
# P0: per-run locks serialize compatible envelope updates in one worker.
|
| 31 |
+
_ENGINEERING_LOCKS: dict[str, asyncio.Lock] = {}
|
| 32 |
+
_ENGINEERING_LOCK_LAST_USED: dict[str, float] = {}
|
| 33 |
+
_ENGINEERING_LOCK_MAX = 256
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _engineering_lock(task_id: str) -> asyncio.Lock:
|
| 37 |
+
lock = _ENGINEERING_LOCKS.get(task_id)
|
| 38 |
+
if lock is None:
|
| 39 |
+
lock = asyncio.Lock()
|
| 40 |
+
_ENGINEERING_LOCKS[task_id] = lock
|
| 41 |
+
_ENGINEERING_LOCK_LAST_USED[task_id] = time.monotonic()
|
| 42 |
+
if len(_ENGINEERING_LOCKS) > _ENGINEERING_LOCK_MAX:
|
| 43 |
+
for stale_id, _ in sorted(_ENGINEERING_LOCK_LAST_USED.items(), key=lambda item: item[1]):
|
| 44 |
+
stale_lock = _ENGINEERING_LOCKS.get(stale_id)
|
| 45 |
+
if stale_lock is not None and not stale_lock.locked() and stale_id != task_id:
|
| 46 |
+
_ENGINEERING_LOCKS.pop(stale_id, None)
|
| 47 |
+
_ENGINEERING_LOCK_LAST_USED.pop(stale_id, None)
|
| 48 |
+
break
|
| 49 |
+
return lock
|
| 50 |
+
|
| 51 |
|
| 52 |
# ── Write helpers (fire-and-forget, never raise) ───────────────────────────────
|
| 53 |
|
|
|
|
| 198 |
# ── Checkpoint helpers (S359: task state snapshots) ───────────────────────────
|
| 199 |
|
| 200 |
async def sb_save_checkpoint(task_id: str, step: int, checkpoint_data: dict) -> None:
|
| 201 |
+
"""Save a legacy checkpoint while preserving a valid EngineeringState envelope."""
|
| 202 |
from .state import _sb
|
| 203 |
if not _sb:
|
| 204 |
return
|
| 205 |
now = int(time.time() * 1000)
|
| 206 |
try:
|
| 207 |
+
payload = dict(checkpoint_data) if isinstance(checkpoint_data, dict) else {}
|
| 208 |
+
# A legacy save must not erase the shadow/canary envelope written by the
|
| 209 |
+
# adapter. Read/merge under the same per-task lock used by its writer.
|
| 210 |
+
lock = _engineering_lock(task_id)
|
| 211 |
+
async with lock:
|
| 212 |
+
current = await sb_get_checkpoint(task_id)
|
| 213 |
+
current_engineering = current.get('engineering_state') if isinstance(current, dict) else None
|
| 214 |
+
if isinstance(current_engineering, dict) and 'engineering_state' not in payload:
|
| 215 |
+
payload['engineering_state'] = current_engineering
|
| 216 |
+
serialized = _sjd(payload)
|
| 217 |
+
if len(serialized) > 16000:
|
| 218 |
+
_logger.debug('[persist] save_checkpoint %s#%d skipped: payload exceeds size limit', task_id, step)
|
| 219 |
+
return
|
| 220 |
+
await asyncio.to_thread(
|
| 221 |
+
lambda: _sb.table('agent_tasks')
|
| 222 |
+
.update({'checkpoint': serialized, 'updated_at': now})
|
| 223 |
+
.eq('task_id', task_id)
|
| 224 |
+
.execute()
|
| 225 |
+
)
|
| 226 |
except Exception as e:
|
| 227 |
_logger.debug('[persist] save_checkpoint %s#%d: %s', task_id, step, e)
|
| 228 |
|
| 229 |
|
| 230 |
+
_ENGINEERING_DEBOUNCE_CACHE: dict[str, dict[str, Any]] = {}
|
| 231 |
+
_ENGINEERING_LAST_FLUSH_TS: dict[str, float] = {}
|
| 232 |
+
DEBOUNCE_INTERVAL_SEC = 2.0
|
| 233 |
+
|
| 234 |
+
async def sb_save_engineering_state(task_id: str, envelope: dict, force: bool = False) -> None:
|
| 235 |
+
"""Merge a validated EngineeringState envelope with debouncing and monotone revision check."""
|
| 236 |
+
from .state import _sb
|
| 237 |
+
if not _sb or not task_id:
|
| 238 |
+
return
|
| 239 |
+
try:
|
| 240 |
+
from agents.engineering_state import EngineeringState
|
| 241 |
+
validated = EngineeringState.from_snapshot(envelope).snapshot()
|
| 242 |
+
except Exception as exc:
|
| 243 |
+
_logger.debug('[persist] engineering state rejected: %s', type(exc).__name__)
|
| 244 |
+
return
|
| 245 |
+
|
| 246 |
+
lock = _engineering_lock(task_id)
|
| 247 |
+
async with lock:
|
| 248 |
+
current = await sb_get_checkpoint(task_id)
|
| 249 |
+
current = current if isinstance(current, dict) else {}
|
| 250 |
+
current_engineering = current.get('engineering_state')
|
| 251 |
+
try:
|
| 252 |
+
current_revision = int(current_engineering.get('revision', -1)) if isinstance(current_engineering, dict) else -1
|
| 253 |
+
except (TypeError, ValueError):
|
| 254 |
+
current_revision = -1
|
| 255 |
+
incoming_revision = int(validated.get('revision', -1))
|
| 256 |
+
if current_revision > incoming_revision:
|
| 257 |
+
_logger.debug('[persist] engineering state conflict %s: remote revision %d > %d', task_id, current_revision, incoming_revision)
|
| 258 |
+
return
|
| 259 |
+
now_t = time.time()
|
| 260 |
+
_ENGINEERING_DEBOUNCE_CACHE[task_id] = validated
|
| 261 |
+
if not force and task_id in _ENGINEERING_LAST_FLUSH_TS:
|
| 262 |
+
if now_t - _ENGINEERING_LAST_FLUSH_TS[task_id] < DEBOUNCE_INTERVAL_SEC:
|
| 263 |
+
return
|
| 264 |
+
|
| 265 |
+
_ENGINEERING_LAST_FLUSH_TS[task_id] = now_t
|
| 266 |
+
to_flush = _ENGINEERING_DEBOUNCE_CACHE.get(task_id, validated)
|
| 267 |
+
|
| 268 |
+
async with lock:
|
| 269 |
+
current = await sb_get_checkpoint(task_id)
|
| 270 |
+
current = current if isinstance(current, dict) else {}
|
| 271 |
+
current_engineering = current.get('engineering_state')
|
| 272 |
+
try:
|
| 273 |
+
current_revision = int(current_engineering.get('revision', -1)) if isinstance(current_engineering, dict) else -1
|
| 274 |
+
except (TypeError, ValueError):
|
| 275 |
+
current_revision = -1
|
| 276 |
+
incoming_revision = int(to_flush.get('revision', -1))
|
| 277 |
+
if current_revision > incoming_revision and not force:
|
| 278 |
+
return
|
| 279 |
+
merged = dict(current)
|
| 280 |
+
merged['engineering_state'] = to_flush
|
| 281 |
+
serialized = _sjd(merged)
|
| 282 |
+
if len(serialized) > 16000:
|
| 283 |
+
return
|
| 284 |
+
now = int(time.time() * 1000)
|
| 285 |
+
try:
|
| 286 |
+
await asyncio.to_thread(
|
| 287 |
+
lambda: _sb.table('agent_tasks')
|
| 288 |
+
.update({'checkpoint': serialized, 'updated_at': now})
|
| 289 |
+
.eq('task_id', task_id)
|
| 290 |
+
.execute()
|
| 291 |
+
)
|
| 292 |
+
except Exception as exc:
|
| 293 |
+
_logger.debug('[persist] save_engineering_state %s: %s', task_id, exc)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
async def sb_get_checkpoint(task_id: str) -> Optional[dict]:
|
| 297 |
"""Retrieve latest checkpoint for a task."""
|
| 298 |
from .state import _sb
|
api/scheduler.py
CHANGED
|
@@ -26,6 +26,7 @@ Route:
|
|
| 26 |
import asyncio
|
| 27 |
import datetime
|
| 28 |
import json
|
|
|
|
| 29 |
from .state import safe_json_dumps
|
| 30 |
import os
|
| 31 |
import time
|
|
@@ -198,6 +199,23 @@ def _is_due(task: dict, now_ms: int) -> bool:
|
|
| 198 |
return False
|
| 199 |
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
def _advance_trigger(trigger: dict, now_ms: int) -> dict:
|
| 202 |
t = dict(trigger)
|
| 203 |
tt = t.get("type")
|
|
@@ -206,14 +224,17 @@ def _advance_trigger(trigger: dict, now_ms: int) -> dict:
|
|
| 206 |
elif tt == "daily":
|
| 207 |
hour = t.get("hour", 9)
|
| 208 |
minute = t.get("minute", 0)
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
| 217 |
# once / on_open: nessun avanzamento
|
| 218 |
return t
|
| 219 |
|
|
|
|
| 26 |
import asyncio
|
| 27 |
import datetime
|
| 28 |
import json
|
| 29 |
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
| 30 |
from .state import safe_json_dumps
|
| 31 |
import os
|
| 32 |
import time
|
|
|
|
| 199 |
return False
|
| 200 |
|
| 201 |
|
| 202 |
+
def _daily_timezone(trigger: dict) -> ZoneInfo | None:
|
| 203 |
+
"""Ritorna il fuso IANA salvato dal browser, se disponibile e valido.
|
| 204 |
+
|
| 205 |
+
I task daily creati prima dell'introduzione del campo ``timeZone`` restano
|
| 206 |
+
compatibili: l'assenza o un valore non valido mantiene il calcolo nel fuso
|
| 207 |
+
locale del server invece di bloccare la pianificazione.
|
| 208 |
+
"""
|
| 209 |
+
time_zone = trigger.get("timeZone")
|
| 210 |
+
if not isinstance(time_zone, str) or not time_zone:
|
| 211 |
+
return None
|
| 212 |
+
try:
|
| 213 |
+
return ZoneInfo(time_zone)
|
| 214 |
+
except ZoneInfoNotFoundError:
|
| 215 |
+
logger.warning("Scheduler: timezone daily non valida (%r), fallback server-local", time_zone)
|
| 216 |
+
return None
|
| 217 |
+
|
| 218 |
+
|
| 219 |
def _advance_trigger(trigger: dict, now_ms: int) -> dict:
|
| 220 |
t = dict(trigger)
|
| 221 |
tt = t.get("type")
|
|
|
|
| 224 |
elif tt == "daily":
|
| 225 |
hour = t.get("hour", 9)
|
| 226 |
minute = t.get("minute", 0)
|
| 227 |
+
time_zone = _daily_timezone(t)
|
| 228 |
+
# Usa il timestamp dell'esecuzione, non l'orologio nel momento in cui
|
| 229 |
+
# il task termina: preserva la semantica esistente anche per task lunghi.
|
| 230 |
+
now = datetime.datetime.fromtimestamp(
|
| 231 |
+
now_ms / 1000,
|
| 232 |
+
tz=time_zone,
|
| 233 |
+
) if time_zone else datetime.datetime.fromtimestamp(now_ms / 1000)
|
| 234 |
+
nxt = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
| 235 |
+
if nxt <= now:
|
| 236 |
+
nxt = nxt + datetime.timedelta(days=1)
|
| 237 |
+
t["nextRun"] = int(nxt.timestamp() * 1000)
|
| 238 |
# once / on_open: nessun avanzamento
|
| 239 |
return t
|
| 240 |
|
tests/test_auth_scheduler_regressions.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regressioni auth/scheduler: cleanup rate limiter e timezone daily.
|
| 2 |
+
|
| 3 |
+
Esegui con: python3 -m unittest backend.tests.test_auth_scheduler_regressions -v
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
import unittest
|
| 10 |
+
from collections import deque
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
from unittest.mock import patch
|
| 13 |
+
|
| 14 |
+
_BACKEND = os.path.join(os.path.dirname(__file__), "..")
|
| 15 |
+
if _BACKEND not in sys.path:
|
| 16 |
+
sys.path.insert(0, _BACKEND)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TestInMemoryRateStoreCleanup(unittest.TestCase):
|
| 20 |
+
"""AUTH-RATE-LEAK: bucket inattivi non devono restare nel processo."""
|
| 21 |
+
|
| 22 |
+
def setUp(self) -> None:
|
| 23 |
+
try:
|
| 24 |
+
import api.auth_guard as auth_guard
|
| 25 |
+
except ImportError as exc:
|
| 26 |
+
self.skipTest(str(exc))
|
| 27 |
+
self.auth_guard = auth_guard
|
| 28 |
+
auth_guard._rate_store.clear()
|
| 29 |
+
auth_guard._rate_store_checks = 0
|
| 30 |
+
|
| 31 |
+
def tearDown(self) -> None:
|
| 32 |
+
self.auth_guard._rate_store.clear()
|
| 33 |
+
self.auth_guard._rate_store_checks = 0
|
| 34 |
+
|
| 35 |
+
def test_periodic_sweep_removes_expired_empty_bucket(self) -> None:
|
| 36 |
+
self.auth_guard._rate_store["expired-client"] = deque([1.0])
|
| 37 |
+
self.auth_guard._rate_store_checks = self.auth_guard._RATE_STORE_SWEEP_EVERY - 1
|
| 38 |
+
|
| 39 |
+
with patch.object(self.auth_guard._rl_time, "monotonic", return_value=120.0):
|
| 40 |
+
allowed, retry_after = self.auth_guard._inmem_rate_check(
|
| 41 |
+
"active-client", limit=10, window_s=60
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
self.assertTrue(allowed)
|
| 45 |
+
self.assertEqual(retry_after, 0)
|
| 46 |
+
self.assertNotIn(
|
| 47 |
+
"expired-client",
|
| 48 |
+
self.auth_guard._rate_store,
|
| 49 |
+
"AUTH-RATE-LEAK: il bucket inattivo resta nello store dopo lo sweep",
|
| 50 |
+
)
|
| 51 |
+
self.assertIn("active-client", self.auth_guard._rate_store)
|
| 52 |
+
|
| 53 |
+
def test_current_request_survives_its_own_sweep(self) -> None:
|
| 54 |
+
self.auth_guard._rate_store_checks = self.auth_guard._RATE_STORE_SWEEP_EVERY - 1
|
| 55 |
+
|
| 56 |
+
with patch.object(self.auth_guard._rl_time, "monotonic", return_value=120.0):
|
| 57 |
+
allowed, _ = self.auth_guard._inmem_rate_check(
|
| 58 |
+
"current-client", limit=1, window_s=60
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
self.assertTrue(allowed)
|
| 62 |
+
self.assertIn("current-client", self.auth_guard._rate_store)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class TestDailyTriggerTimezone(unittest.TestCase):
|
| 66 |
+
"""SCHED-TZ-DRIFT: il backend deve conservare l'ora civile scelta dal browser."""
|
| 67 |
+
|
| 68 |
+
def setUp(self) -> None:
|
| 69 |
+
try:
|
| 70 |
+
import api.scheduler as scheduler
|
| 71 |
+
except ImportError as exc:
|
| 72 |
+
self.skipTest(str(exc))
|
| 73 |
+
self.scheduler = scheduler
|
| 74 |
+
|
| 75 |
+
def _advance(self, iso_now: str) -> datetime:
|
| 76 |
+
now = datetime.fromisoformat(iso_now)
|
| 77 |
+
result = self.scheduler._advance_trigger(
|
| 78 |
+
{
|
| 79 |
+
"type": "daily",
|
| 80 |
+
"hour": 9,
|
| 81 |
+
"minute": 0,
|
| 82 |
+
"nextRun": int(now.timestamp() * 1000),
|
| 83 |
+
"timeZone": "Europe/Rome",
|
| 84 |
+
},
|
| 85 |
+
int(now.timestamp() * 1000),
|
| 86 |
+
)
|
| 87 |
+
return datetime.fromtimestamp(result["nextRun"] / 1000, tz=timezone.utc)
|
| 88 |
+
|
| 89 |
+
def test_daily_uses_browser_timezone_not_utc_server_timezone(self) -> None:
|
| 90 |
+
# 09:00 CEST è 07:00 UTC. Essendo già l'orario pianificato, il run successivo
|
| 91 |
+
# deve restare alle 09:00 civili del giorno seguente (07:00 UTC), non 09:00 UTC.
|
| 92 |
+
actual = self._advance("2026-06-01T07:00:00+00:00")
|
| 93 |
+
self.assertEqual(actual, datetime(2026, 6, 2, 7, 0, tzinfo=timezone.utc))
|
| 94 |
+
|
| 95 |
+
def test_daily_preserves_wall_clock_across_dst_transition(self) -> None:
|
| 96 |
+
# Il giorno dopo l'Europa passa da CET (UTC+1) a CEST (UTC+2): l'ora civile
|
| 97 |
+
# deve rimanere 09:00, quindi l'epoch UTC passa correttamente da 08:00 a 07:00.
|
| 98 |
+
actual = self._advance("2026-03-28T08:00:00+00:00")
|
| 99 |
+
self.assertEqual(actual, datetime(2026, 3, 29, 7, 0, tzinfo=timezone.utc))
|
| 100 |
+
|
| 101 |
+
def test_legacy_daily_trigger_without_timezone_remains_schedulable(self) -> None:
|
| 102 |
+
now = datetime(2026, 6, 1, 7, 0, tzinfo=timezone.utc)
|
| 103 |
+
result = self.scheduler._advance_trigger(
|
| 104 |
+
{"type": "daily", "hour": 9, "minute": 0, "nextRun": int(now.timestamp() * 1000)},
|
| 105 |
+
int(now.timestamp() * 1000),
|
| 106 |
+
)
|
| 107 |
+
self.assertGreater(result["nextRun"], int(now.timestamp() * 1000))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
if __name__ == "__main__":
|
| 111 |
+
unittest.main()
|
tests/test_engineering_state.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Focused P0 tests for EngineeringState's safety and rollout contract."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
import unittest
|
| 7 |
+
from unittest.mock import patch
|
| 8 |
+
|
| 9 |
+
_BACKEND = os.path.join(os.path.dirname(__file__), "..")
|
| 10 |
+
if _BACKEND not in sys.path:
|
| 11 |
+
sys.path.insert(0, _BACKEND)
|
| 12 |
+
|
| 13 |
+
from agents.engineering_state import ( # noqa: E402
|
| 14 |
+
EngineeringState,
|
| 15 |
+
EngineeringStateConfig,
|
| 16 |
+
EngineeringStateMode,
|
| 17 |
+
SCHEMA_VERSION,
|
| 18 |
+
redact_text,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TestEngineeringState(unittest.TestCase):
|
| 23 |
+
def test_default_rollout_is_authoritative_and_invalid_mode_fails_closed(self) -> None:
|
| 24 |
+
with patch.dict(os.environ, {}, clear=True):
|
| 25 |
+
self.assertEqual(EngineeringStateConfig.from_env().mode, EngineeringStateMode.AUTHORITATIVE)
|
| 26 |
+
with patch.dict(os.environ, {"ENGINEERING_STATE_MODE": "unsafe"}, clear=False):
|
| 27 |
+
self.assertEqual(EngineeringStateConfig.from_env().mode, EngineeringStateMode.OFF)
|
| 28 |
+
|
| 29 |
+
def test_redaction_removes_common_credentials(self) -> None:
|
| 30 |
+
value = "Authorization: Bearer abcdefghijkl token=ghp_1234567890abcdef hf_1234567890"
|
| 31 |
+
result = redact_text(value)
|
| 32 |
+
self.assertNotIn("abcdefghijkl", result)
|
| 33 |
+
self.assertNotIn("ghp_1234567890abcdef", result)
|
| 34 |
+
self.assertNotIn("hf_1234567890", result)
|
| 35 |
+
self.assertIn("[REDACTED]", result)
|
| 36 |
+
|
| 37 |
+
def test_transitions_are_validated_and_idempotent(self) -> None:
|
| 38 |
+
state = EngineeringState.start("build a safe agent", run_id="run-1", now_ms=100)
|
| 39 |
+
self.assertTrue(state.transition("CLASSIFYING", now_ms=101))
|
| 40 |
+
self.assertFalse(state.transition("CLASSIFYING", now_ms=102))
|
| 41 |
+
with self.assertRaises(ValueError):
|
| 42 |
+
state.transition("IDLE", now_ms=103)
|
| 43 |
+
self.assertEqual(state.revision, 1)
|
| 44 |
+
self.assertEqual(state.sequence, 1)
|
| 45 |
+
|
| 46 |
+
def test_round_trip_is_bounded_and_does_not_store_raw_goal(self) -> None:
|
| 47 |
+
goal = "use token=super-secret-value to build this agent"
|
| 48 |
+
state = EngineeringState.start(goal, run_id="run-2", session_id="session-2", now_ms=100)
|
| 49 |
+
for target in ("CLASSIFYING", "THINKING", "COMPLETED"):
|
| 50 |
+
state.transition(target, now_ms=101)
|
| 51 |
+
snapshot = state.snapshot()
|
| 52 |
+
restored = EngineeringState.from_snapshot(snapshot)
|
| 53 |
+
self.assertEqual(restored.snapshot(), snapshot)
|
| 54 |
+
self.assertEqual(snapshot["schema_version"], SCHEMA_VERSION)
|
| 55 |
+
self.assertNotIn("super-secret-value", str(snapshot))
|
| 56 |
+
self.assertLessEqual(len(snapshot["history"]), 64)
|
| 57 |
+
|
| 58 |
+
def test_corrupt_schema_and_revision_are_rejected(self) -> None:
|
| 59 |
+
state = EngineeringState.start("goal", run_id="run-3")
|
| 60 |
+
snapshot = state.snapshot()
|
| 61 |
+
snapshot["schema_version"] = 999
|
| 62 |
+
with self.assertRaises(ValueError):
|
| 63 |
+
EngineeringState.from_snapshot(snapshot)
|
| 64 |
+
snapshot = state.snapshot()
|
| 65 |
+
snapshot["revision"] = -1
|
| 66 |
+
with self.assertRaises(ValueError):
|
| 67 |
+
EngineeringState.from_snapshot(snapshot)
|
| 68 |
+
|
| 69 |
+
def test_canary_selection_is_deterministic_and_requires_session(self) -> None:
|
| 70 |
+
config = EngineeringStateConfig(EngineeringStateMode.CANARY, 0.5)
|
| 71 |
+
self.assertFalse(config.selects_canary("run", ""))
|
| 72 |
+
self.assertEqual(
|
| 73 |
+
config.selects_canary("run", "session"),
|
| 74 |
+
config.selects_canary("run", "session"),
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
def test_resume_normalizes_terminal_state_and_preserves_history(self) -> None:
|
| 78 |
+
state = EngineeringState.start("resume this task", run_id="run-4", session_id="session-4")
|
| 79 |
+
for target in ("CLASSIFYING", "THINKING", "COMPLETED"):
|
| 80 |
+
state.transition(target)
|
| 81 |
+
history_before_resume = list(state.history)
|
| 82 |
+
|
| 83 |
+
state.prepare_for_resume()
|
| 84 |
+
|
| 85 |
+
self.assertEqual(state.current_state, "IDLE")
|
| 86 |
+
self.assertEqual(state.status, "active")
|
| 87 |
+
self.assertEqual(state.history[:len(history_before_resume)], history_before_resume)
|
| 88 |
+
self.assertTrue(any("resume normalized state to IDLE" in item for item in state.diagnostics))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
if __name__ == "__main__":
|
| 92 |
+
unittest.main(verbosity=2)
|