sync: 145 file da Baida98/AI@0405a40d (2026-08-10 16:35 UTC) [deploy-all]

#28
by Baida07 - opened
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
- await _maybe_await(on_step({
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 is not None:
 
 
 
 
 
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
- state.state_machine.transition(AgentState.FAILED)
3410
- if on_step is not None:
3411
- try:
3412
- await _maybe_await(on_step({
3413
- "action": "state_transition",
3414
- "status": "done",
3415
- "from_state": previous.value,
3416
- "to_state": AgentState.FAILED.value,
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
- await self._transition_state(state, next_state, on_step)
 
 
 
 
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
- "\'\'\'typescript\'\'\'...\'\'\'typescript. Mai inline, mai in blocchi generici. "
51
- "Il codice deve compilare: nessun placeholder, nessun TODO, tipi espliciti.\n"
52
- "9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
 
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 mid-task checkpoint for potential resume."""
181
  from .state import _sb
182
  if not _sb:
183
  return
184
  now = int(time.time() * 1000)
185
  try:
186
- await asyncio.to_thread(
187
- lambda: _sb.table('agent_tasks')
188
- .update({'checkpoint': _sjd(checkpoint_data)[:16000], 'updated_at': now})
189
- .eq('task_id', task_id)
190
- .execute()
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
- nxt = datetime.datetime.now().replace(
210
- hour=hour, minute=minute, second=0, microsecond=0
211
- )
212
- nxt_ms = int(nxt.timestamp() * 1000)
213
- if nxt_ms <= now_ms:
214
- nxt = nxt + datetime.timedelta(days=1)
215
- nxt_ms = int(nxt.timestamp() * 1000)
216
- t["nextRun"] = nxt_ms
 
 
 
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)