Spaces:
Configuration error
Configuration error
| """backend/api/persistence.py — Supabase persistence for agent task state (S359). | |
| Design principles: | |
| - ALL writes are fire-and-forget: never block the SSE hot path. | |
| - Reads happen ONLY on lazy restore (when in-memory is empty, i.e. backend restarted). | |
| - Supabase unavailable → silent fallback to in-memory-only (no exception propagation). | |
| - Event buffer capped at MAX_EVENTS per task to avoid runaway storage. | |
| GAP-P40D-FIX: aggiunto retry (max 2 tentativi, 0.3s sleep) per sb_upsert_task e | |
| sb_append_event — stesse operazioni già protette in linter.py (P40-D pattern). | |
| Il gap: su Railway cold-start le prime scritture Supabase cadono nel vuoto senza retry. | |
| Il pattern è identico a quello in linter.py (lint_and_store) già confermato stabile. | |
| Required Supabase tables (run backend/migrations/s359_task_persistence.sql once): | |
| agent_tasks — task metadata (task_id, goal, status, max_steps, created_at, updated_at) | |
| agent_task_events — SSE event buffer (task_id, event_index, event_data, created_at) | |
| """ | |
| import asyncio, time, json | |
| from .state import safe_json_dumps as _sjd # B11-FIX: surrogate-safe drop-in | |
| from typing import Optional | |
| import logging | |
| _logger = logging.getLogger("api.persistence") | |
| MAX_EVENTS = 500 # max SSE frames persisted per task | |
| _MAX_RETRY = 2 # GAP-P40D-FIX: tentativi massimi per write Supabase | |
| _RETRY_SLEEP = 0.3 # GAP-P40D-FIX: sleep tra tentativi (secondi) | |
| # ── Write helpers (fire-and-forget, never raise) ─────────────────────────────── | |
| async def sb_upsert_task( | |
| task_id: str, goal: str, status: str, max_steps: int, | |
| context: list, created_at: int, | |
| ) -> None: | |
| """Persist or update task metadata. Silent if Supabase not configured. | |
| GAP-P40D-FIX: retry fino a _MAX_RETRY tentativi su errore transitorio.""" | |
| from .state import _sb | |
| if not _sb: | |
| return | |
| now = int(time.time() * 1000) | |
| payload = { | |
| 'task_id': task_id, | |
| 'goal': goal[:1000], | |
| 'status': status, | |
| 'max_steps': max_steps, | |
| 'context': _sjd(context)[:8000], | |
| 'created_at': created_at, | |
| 'updated_at': now, | |
| } | |
| # ─── Mirroring su Hugging Face Dataset (Zero-Cost Backup) ───────────── | |
| try: | |
| from .hf_storage import hf_fire_and_forget | |
| hf_fire_and_forget("tasks.jsonl", payload) | |
| except Exception as hf_exc: | |
| _logger.debug("HF Mirroring (task) silenced: %s", hf_exc) | |
| for _attempt in range(_MAX_RETRY): | |
| try: | |
| await asyncio.to_thread( | |
| lambda: _sb.table('agent_tasks').upsert(payload).execute() | |
| ) | |
| return # successo | |
| except Exception as e: | |
| if _attempt < _MAX_RETRY - 1: | |
| await asyncio.sleep(_RETRY_SLEEP) | |
| else: | |
| _logger.warning('[persist] upsert_task %s failed after %d attempts: %s', | |
| task_id, _MAX_RETRY, e) | |
| async def sb_update_status(task_id: str, status: str) -> None: | |
| """Update only the status column of an existing task.""" | |
| from .state import _sb | |
| if not _sb: | |
| return | |
| now = int(time.time() * 1000) | |
| try: | |
| await asyncio.to_thread( | |
| lambda: _sb.table('agent_tasks') | |
| .update({'status': status, 'updated_at': now}) | |
| .eq('task_id', task_id) | |
| .execute() | |
| ) | |
| except Exception as e: | |
| _logger.warning('[persist] update_status %s→%s: %s', task_id, status, e) | |
| async def sb_append_event(task_id: str, event_index: int, event_data: str) -> None: | |
| """Append one SSE event string to the persistent buffer. Capped at MAX_EVENTS. | |
| GAP-P40D-FIX: retry fino a _MAX_RETRY tentativi su errore transitorio.""" | |
| from .state import _sb | |
| if not _sb or event_index > MAX_EVENTS: | |
| return | |
| now = int(time.time() * 1000) | |
| payload = { | |
| 'task_id': task_id, | |
| 'event_index': event_index, | |
| 'event_data': event_data, | |
| 'created_at': now, | |
| } | |
| # ─── Mirroring su Hugging Face Dataset (Zero-Cost Backup) ───────────── | |
| try: | |
| from .hf_storage import hf_fire_and_forget | |
| # Sharding: i log degli eventi vanno su un dataset potenzialmente diverso (Account B) | |
| hf_fire_and_forget("task_events.jsonl", payload, repo_id=os.getenv("HF_DATASET_REPO_LOGS")) | |
| except Exception as hf_exc: | |
| _logger.debug("HF Mirroring (event) silenced: %s", hf_exc) | |
| for _attempt in range(_MAX_RETRY): | |
| try: | |
| await asyncio.to_thread( | |
| lambda: _sb.table('agent_task_events').upsert(payload).execute() | |
| ) | |
| return # successo | |
| except Exception as e: | |
| if _attempt < _MAX_RETRY - 1: | |
| await asyncio.sleep(_RETRY_SLEEP) | |
| else: | |
| _logger.warning('[persist] append_event %s#%d failed after %d attempts: %s', | |
| task_id, event_index, _MAX_RETRY, e) | |
| async def sb_restore_task(task_id: str) -> Optional[dict]: | |
| """Restore task metadata from Supabase on backend restart.""" | |
| from .state import _sb | |
| if not _sb: | |
| return None | |
| try: | |
| res = await asyncio.to_thread( | |
| lambda: _sb.table('agent_tasks').select('*').eq('task_id', task_id).limit(1).execute() | |
| ) | |
| return res.data[0] if res.data else None | |
| except Exception as e: | |
| _logger.warning('[persist] restore_task %s: %s', task_id, e) | |
| return None | |
| async def sb_get_events(task_id: str) -> list[str]: | |
| """Retrieve all persisted SSE events for a task, ordered by index.""" | |
| from .state import _sb | |
| if not _sb: | |
| return [] | |
| try: | |
| res = await asyncio.to_thread( | |
| lambda: _sb.table('agent_task_events') | |
| .select('event_index, event_data') | |
| .eq('task_id', task_id) | |
| .order('event_index') | |
| .limit(MAX_EVENTS) | |
| .execute() | |
| ) | |
| return [r['event_data'] for r in (res.data or [])] | |
| except Exception as e: | |
| _logger.warning('[persist] get_events %s: %s', task_id, e) | |
| return [] | |
| async def sb_delete_task_events(task_id: str) -> None: | |
| """Delete all events for a task (post-replay cleanup).""" | |
| from .state import _sb | |
| if not _sb: | |
| return | |
| try: | |
| await asyncio.to_thread( | |
| lambda: _sb.table('agent_task_events').delete().eq('task_id', task_id).execute() | |
| ) | |
| except Exception as e: | |
| _logger.debug('[persist] delete_events %s: %s', task_id, e) | |
| async def sb_list_tasks(limit: int = 50) -> list[dict]: | |
| """List recent tasks from Supabase.""" | |
| from .state import _sb | |
| if not _sb: | |
| return [] | |
| try: | |
| res = await asyncio.to_thread( | |
| lambda: _sb.table('agent_tasks') | |
| .select('task_id, goal, status, created_at, updated_at') | |
| .order('created_at', desc=True) | |
| .limit(limit) | |
| .execute() | |
| ) | |
| return res.data or [] | |
| except Exception as e: | |
| _logger.warning('[persist] list_tasks: %s', e) | |
| return [] | |
| # ── Checkpoint helpers (S359: task state snapshots) ─────────────────────────── | |
| async def sb_save_checkpoint(task_id: str, step: int, checkpoint_data: dict) -> None: | |
| """Save a mid-task checkpoint for potential resume.""" | |
| from .state import _sb | |
| if not _sb: | |
| return | |
| now = int(time.time() * 1000) | |
| try: | |
| await asyncio.to_thread( | |
| lambda: _sb.table('agent_tasks') | |
| .update({'checkpoint': _sjd(checkpoint_data)[:16000], 'updated_at': now}) | |
| .eq('task_id', task_id) | |
| .execute() | |
| ) | |
| except Exception as e: | |
| _logger.debug('[persist] save_checkpoint %s#%d: %s', task_id, step, e) | |
| async def sb_get_checkpoint(task_id: str) -> Optional[dict]: | |
| """Retrieve latest checkpoint for a task.""" | |
| from .state import _sb | |
| if not _sb: | |
| return None | |
| try: | |
| res = await asyncio.to_thread( | |
| lambda: _sb.table('agent_tasks') | |
| .select('checkpoint') | |
| .eq('task_id', task_id) | |
| .limit(1) | |
| .execute() | |
| ) | |
| if res.data and res.data[0].get('checkpoint'): | |
| raw = res.data[0]['checkpoint'] | |
| return json.loads(raw) if isinstance(raw, str) else raw | |
| return None | |
| except Exception as e: | |
| _logger.debug('[persist] get_checkpoint %s: %s', task_id, e) | |
| return None | |
| # ── Handoff helpers (BG-4: cross-session context handoff) ───────────────────── | |
| async def sb_restore_handoff_context(session_id: str) -> Optional[dict]: | |
| """Restore cross-session handoff context.""" | |
| from .state import _sb | |
| if not _sb: | |
| return None | |
| try: | |
| res = await asyncio.to_thread( | |
| lambda: _sb.table('handoff_contexts') | |
| .select('*') | |
| .eq('session_id', session_id) | |
| .limit(1) | |
| .execute() | |
| ) | |
| return res.data[0] if res.data else None | |
| except Exception as e: | |
| _logger.debug('[persist] restore_handoff %s: %s', session_id, e) | |
| return None | |
| async def sb_upsert_handoff(session_id: str, context: dict) -> None: | |
| """Save handoff context for cross-session resume.""" | |
| from .state import _sb | |
| if not _sb: | |
| return | |
| now = int(time.time() * 1000) | |
| try: | |
| await asyncio.to_thread( | |
| lambda: _sb.table('handoff_contexts').upsert({ | |
| 'session_id': session_id, | |
| 'context': _sjd(context)[:16000], | |
| 'updated_at': now, | |
| }, on_conflict='session_id').execute() | |
| ) | |
| except Exception as e: | |
| _logger.debug('[persist] upsert_handoff %s: %s', session_id, e) | |
| async def sb_delete_handoff(session_id: str) -> None: | |
| """Remove handoff context after successful resume.""" | |
| from .state import _sb | |
| if not _sb: | |
| return | |
| try: | |
| await asyncio.to_thread( | |
| lambda: _sb.table('handoff_contexts').delete().eq('session_id', session_id).execute() | |
| ) | |
| except Exception as e: | |
| _logger.debug('[persist] delete_handoff %s: %s', session_id, e) | |