"""Durable scheduled producer for the G8 agentic content pipeline. The producer is intentionally opt-in. It only claims source packs already classified as synthetic research material, revalidates source approval/hash/PII before every engine run, and can only persist a pending human-review candidate. """ from __future__ import annotations import asyncio import hashlib import json import logging from pathlib import Path from typing import Any, Literal from uuid import UUID, uuid5 from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator from .. import db from ..config import settings from ..engine_client import EngineClient, engine_client from . import continuous_improvement_agentic, continuous_improvement_store logger = logging.getLogger(__name__) DATA_CLASSIFICATION = "synthetic_replay_red_team_coverage_drift" _PRODUCER_NAMESPACE = UUID("87cd17b8-2e03-5ea9-9454-7ff60d7ba142") _REPO_SOURCE_PATH = ( Path(__file__).resolve().parents[1] / "data" / "continuous_improvement" / "synthetic_source_pack.v5.json" ) _PRODUCER_TASK: asyncio.Task[None] | None = None class AgenticJobError(ValueError): """Durable queue specification or transition failed.""" class ScheduledAgenticJobSpec(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) job_key: str = Field(pattern=r"^oas-g8-job-[a-z0-9-]+$") data_classification: Literal["synthetic_replay_red_team_coverage_drift"] content_kind: Literal["case", "rupture", "practice", "benchmark"] difficulty_level: int = Field(ge=1, le=5) variant_count: int = Field(ge=3, le=12) prompt_version: str = Field(min_length=1, max_length=80) trigger_kind: Literal["scheduled_repo_source", "scheduled_incident"] source_packs: tuple[continuous_improvement_agentic.AgenticSourcePack, ...] = Field( min_length=1, max_length=20, ) @model_validator(mode="after") def validate_sources_before_enqueue(self) -> "ScheduledAgenticJobSpec": continuous_improvement_agentic.validate_source_packs(self.source_packs) return self class ClaimedAgenticJob(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) job_id: UUID spec: ScheduledAgenticJobSpec source_fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") attempt_count: int = Field(ge=1) class AgenticJobOutcome(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) job_id: UUID status: Literal["completed", "retry_wait", "rejected"] agent_calls_executed: int = Field(ge=0) idempotent_replay: bool = False error_code: str | None = None class _ProducerEngine: """Apply the G8 call timeout without changing other EngineClient users.""" def __init__(self, engine: Any) -> None: self.engine = engine async def generate(self, request: Any) -> Any: if isinstance(self.engine, EngineClient): return await self.engine.generate( request, timeout=settings.continuous_improvement_producer_engine_timeout_seconds, ) return await self.engine.generate(request) def _canonical_json(value: Any) -> str: return json.dumps( value, ensure_ascii=False, separators=(",", ":"), sort_keys=True, default=str, ) def _fingerprint(spec: ScheduledAgenticJobSpec) -> str: return hashlib.sha256( _canonical_json(spec.model_dump(mode="json")).encode("utf-8") ).hexdigest() def _job_ids(job_id: UUID) -> dict[str, UUID]: return { name: uuid5(job_id, name) for name in ( "submission", "pipeline", "benchmark_record", "qualification", ) } def load_repo_approved_job( path: Path = _REPO_SOURCE_PATH, ) -> ScheduledAgenticJobSpec: try: payload = json.loads(path.read_text(encoding="utf-8")) return ScheduledAgenticJobSpec.model_validate(payload) except (OSError, json.JSONDecodeError, ValidationError) as exc: raise AgenticJobError("repo-approved G8 source pack is invalid") from exc async def enqueue_agentic_job(conn: Any, spec: ScheduledAgenticJobSpec) -> UUID: """Idempotently enqueue an immutable validated source/configuration tuple.""" # Revalidate at the persistence boundary even when caller already has a model. continuous_improvement_agentic.validate_source_packs(spec.source_packs) if spec.data_classification != DATA_CLASSIFICATION: raise AgenticJobError("unsupported agentic job data classification") job_id = uuid5(_PRODUCER_NAMESPACE, spec.job_key) source_fingerprint = _fingerprint(spec) await conn.execute( """ INSERT INTO app.ci_agentic_job ( job_id, job_key, source_packs, source_fingerprint, data_classification, content_kind, difficulty_level, variant_count, prompt_version, trigger_kind ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (job_id) DO NOTHING """, job_id, spec.job_key, [item.model_dump(mode="json") for item in spec.source_packs], source_fingerprint, spec.data_classification, spec.content_kind, spec.difficulty_level, spec.variant_count, spec.prompt_version, spec.trigger_kind, ) row = await conn.fetchrow( """ SELECT job_key, source_fingerprint, data_classification FROM app.ci_agentic_job WHERE job_id = $1 """, job_id, ) if row is None: raise AgenticJobError("agentic job is not visible after enqueue") if ( str(row["job_key"]) != spec.job_key or str(row["source_fingerprint"]) != source_fingerprint or str(row["data_classification"]) != DATA_CLASSIFICATION ): raise AgenticJobError("agentic job key was reused with changed source input") return job_id async def ensure_repo_approved_job() -> UUID: spec = load_repo_approved_job() async with db.acquire(ai_view="research", ai_context=True) as conn: return await enqueue_agentic_job(conn, spec) async def claim_next_agentic_job( *, job_id: UUID | None = None ) -> ClaimedAgenticJob | None: async with db.acquire(ai_view="research", ai_context=True) as conn: row = await conn.fetchrow( """ WITH candidate AS ( SELECT job_id FROM app.ci_agentic_job WHERE ( (status IN ('pending','retry_wait') AND next_attempt_at <= now()) OR ( status = 'processing' AND lease_started_at <= now() - ($1::double precision * interval '1 second') ) ) AND ($2::uuid IS NULL OR job_id = $2) ORDER BY next_attempt_at, created_at FOR UPDATE SKIP LOCKED LIMIT 1 ) UPDATE app.ci_agentic_job job SET status = 'processing', attempt_count = job.attempt_count + 1, lease_started_at = now(), updated_at = now(), last_error_code = NULL, last_error_message = NULL FROM candidate WHERE job.job_id = candidate.job_id RETURNING job.* """, settings.continuous_improvement_producer_lease_timeout_seconds, job_id, ) if row is None: return None try: spec = ScheduledAgenticJobSpec.model_validate( { "job_key": row["job_key"], "data_classification": row["data_classification"], "content_kind": row["content_kind"], "difficulty_level": row["difficulty_level"], "variant_count": row["variant_count"], "prompt_version": row["prompt_version"], "trigger_kind": row["trigger_kind"], "source_packs": row["source_packs"], } ) return ClaimedAgenticJob( job_id=row["job_id"], spec=spec, source_fingerprint=str(row["source_fingerprint"]), attempt_count=int(row["attempt_count"]), ) except (ValidationError, continuous_improvement_agentic.AgenticPipelineRejectedError) as exc: # The row was durable before validation failed. Preserve a terminal, # inspectable rejection without exposing source/model payloads in errors. job_id = UUID(str(row["job_id"])) await _mark_rejected(job_id, "invalid_source_contract", str(exc)) raise AgenticJobError("claimed job failed source validation") from exc def _safe_error_message(exc: BaseException) -> str: return f"{type(exc).__name__}: {str(exc)}"[:500] async def _mark_retry(job_id: UUID, error_code: str, exc: BaseException) -> None: async with db.acquire(ai_view="research", ai_context=True) as conn: await conn.execute( """ UPDATE app.ci_agentic_job SET status = 'retry_wait', lease_started_at = NULL, next_attempt_at = now() + ($2::double precision * interval '1 second'), last_error_code = $3, last_error_message = $4, updated_at = now() WHERE job_id = $1 AND status = 'processing' """, job_id, settings.continuous_improvement_producer_retry_delay_seconds, error_code, _safe_error_message(exc), ) async def _mark_rejected(job_id: UUID, error_code: str, message: str) -> None: async with db.acquire(ai_view="research", ai_context=True) as conn: await conn.execute( """ UPDATE app.ci_agentic_job SET status = 'rejected', lease_started_at = NULL, last_error_code = $2, last_error_message = $3, updated_at = now() WHERE job_id = $1 AND status = 'processing' """, job_id, error_code, message[:500], ) async def execute_claimed_agentic_job( job: ClaimedAgenticJob, *, engine: Any = engine_client, ) -> AgenticJobOutcome: """Execute one claimed job without allowing it to block later jobs.""" ids = _job_ids(job.job_id) try: if job.spec.data_classification != DATA_CLASSIFICATION: raise continuous_improvement_agentic.AgenticPipelineRejectedError( "unsupported scheduled source classification" ) continuous_improvement_agentic.validate_source_packs(job.spec.source_packs) if _fingerprint(job.spec) != job.source_fingerprint: raise continuous_improvement_agentic.AgenticPipelineRejectedError( "scheduled source fingerprint mismatch" ) async with db.acquire(ai_view="research", ai_context=True) as conn: result = await continuous_improvement_agentic.run_agentic_content_pipeline( conn=conn, engine=_ProducerEngine(engine), submission_id=ids["submission"], pipeline_id=ids["pipeline"], benchmark_record_id=ids["benchmark_record"], qualification_id=ids["qualification"], source_packs=job.spec.source_packs, content_kind=job.spec.content_kind, difficulty_level=job.spec.difficulty_level, variant_count=job.spec.variant_count, prompt_version=job.spec.prompt_version, trigger_kind=job.spec.trigger_kind, ) if ( result.state != "pending_human_approval" or not result.human_approval_required or result.catalog_promoted or result.clinical_claim_allowed ): raise continuous_improvement_agentic.AgenticPipelineExecutionError( "scheduled pipeline crossed the mandatory human approval boundary" ) await conn.execute( """ UPDATE app.ci_agentic_job SET status = 'completed', lease_started_at = NULL, result_submission_id = $2, result_qualification_id = $3, completed_at = now(), updated_at = now() WHERE job_id = $1 AND status = 'processing' """, job.job_id, result.submission_id, result.qualification_id, ) return AgenticJobOutcome( job_id=job.job_id, status="completed", agent_calls_executed=result.agent_calls_executed, idempotent_replay=result.idempotent_replay, ) except asyncio.CancelledError: raise except continuous_improvement_agentic.AgenticPipelineRejectedError as exc: await _mark_rejected(job.job_id, "safety_gate_rejected", _safe_error_message(exc)) return AgenticJobOutcome( job_id=job.job_id, status="rejected", agent_calls_executed=0, error_code="safety_gate_rejected", ) except continuous_improvement_store.ContinuousImprovementConflictError as exc: await _mark_rejected(job.job_id, "idempotency_conflict", _safe_error_message(exc)) return AgenticJobOutcome( job_id=job.job_id, status="rejected", agent_calls_executed=0, error_code="idempotency_conflict", ) except continuous_improvement_agentic.AgenticPipelineExecutionError as exc: await _mark_retry(job.job_id, "engine_execution_failed", exc) return AgenticJobOutcome( job_id=job.job_id, status="retry_wait", agent_calls_executed=0, error_code="engine_execution_failed", ) except Exception as exc: await _mark_retry(job.job_id, "unexpected_execution_failed", exc) logger.exception("G8 scheduled agentic job failed: job_id=%s", job.job_id) return AgenticJobOutcome( job_id=job.job_id, status="retry_wait", agent_calls_executed=0, error_code="unexpected_execution_failed", ) async def produce_queued_agentic_jobs_once() -> dict[str, int]: """Bootstrap the repo source and process an isolated bounded job batch.""" # Imported lazily to keep the trigger's job-spec dependency acyclic. This # runs only when the existing default-off producer scheduler (or an explicit # one-shot caller) invokes a cycle. from . import continuous_improvement_trigger drift_trigger = { "drift_signals_scanned": 0, "drift_invalid_signals": 0, "drift_incidents_created": 0, "drift_incident_replays": 0, "drift_jobs_enqueued": 0, "drift_trigger_failed": 0, } if settings.continuous_improvement_drift_trigger_enabled: try: triggered = ( await continuous_improvement_trigger.enqueue_drift_adversarial_jobs_once() ) drift_trigger.update( { "drift_signals_scanned": triggered.scanned, "drift_invalid_signals": triggered.invalid_signals, "drift_incidents_created": triggered.incidents_created, "drift_incident_replays": triggered.incident_replays, "drift_jobs_enqueued": triggered.jobs_enqueued, } ) except asyncio.CancelledError: raise except Exception: drift_trigger["drift_trigger_failed"] = 1 logger.exception("G8 operational drift trigger failed closed") enqueued = 0 bootstrap_failed = 0 try: await ensure_repo_approved_job() enqueued = 1 except asyncio.CancelledError: raise except Exception: bootstrap_failed = 1 logger.exception("G8 repo-approved source enqueue failed") counts = {"completed": 0, "retry_wait": 0, "rejected": 0} claimed = 0 for _ in range(settings.continuous_improvement_producer_batch_size): try: job = await claim_next_agentic_job() if job is None: break claimed += 1 outcome = await execute_claimed_agentic_job(job) counts[outcome.status] += 1 except asyncio.CancelledError: raise except Exception: counts["retry_wait"] += 1 logger.exception("G8 scheduler isolated an unhandled job failure") return { **drift_trigger, "repo_source_enqueued": enqueued, "bootstrap_failed": bootstrap_failed, "claimed": claimed, **counts, } async def _producer_loop() -> None: delay = settings.continuous_improvement_producer_startup_delay_seconds if delay: await asyncio.sleep(delay) while True: try: result = await produce_queued_agentic_jobs_once() if result["claimed"] or result["bootstrap_failed"]: logger.info("G8 scheduled agentic cycle: %s", result) except asyncio.CancelledError: raise except Exception: logger.exception("G8 scheduled agentic cycle failed") await asyncio.sleep(settings.continuous_improvement_producer_interval_seconds) def schedule_continuous_improvement_producer() -> asyncio.Task[None] | None: global _PRODUCER_TASK if not settings.continuous_improvement_producer_enabled: return None if _PRODUCER_TASK is not None and not _PRODUCER_TASK.done(): return _PRODUCER_TASK _PRODUCER_TASK = asyncio.create_task( _producer_loop(), name="continuous-improvement-agentic-producer", ) return _PRODUCER_TASK async def stop_continuous_improvement_producer() -> None: global _PRODUCER_TASK task = _PRODUCER_TASK _PRODUCER_TASK = None if task is None or task.done(): return task.cancel() try: await task except asyncio.CancelledError: pass __all__ = [ "AgenticJobError", "AgenticJobOutcome", "ClaimedAgenticJob", "ScheduledAgenticJobSpec", "claim_next_agentic_job", "enqueue_agentic_job", "ensure_repo_approved_job", "execute_claimed_agentic_job", "load_repo_approved_job", "produce_queued_agentic_jobs_once", "schedule_continuous_improvement_producer", "stop_continuous_improvement_producer", ]