Terminal / api /persistence.py
Baida-ari7
sync: 121 file da Baida98/AI@69f95410 (2026-06-27 11:43 UTC)
adad7f6 verified
Raw
History Blame Contribute Delete
10.4 kB
"""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)