from __future__ import annotations import json import uuid import pytest from codexpi.codex_service import INTERRUPTED_WORKER_REASON, CodexManager from codexpi.database import Database, utcnow from codexpi.remediation_service import ( AutonomousRemediationService, RemediationConflict, normalize_name, parse_self_repair_verdict, parse_supervision_verdict, ) def plan_step(target: str = "NASflix") -> dict: return { "id": "step-one", "order": 1, "phase": 1, "phaseLabel": "Mesurer", "target": target, "aliases": ["NASFLIX"], "title": "Réduire la charge", "summary": "Mesurer puis corriger la charge du projet.", "risk": "code", "prompt": "Inspecter les traitements de fond, appliquer une correction bornée et vérifier par des tests reproductibles. " * 2, } def service_env(tmp_path): database = Database(tmp_path / "codexpi.db") database.initialize() master_path = tmp_path / "master" target_path = tmp_path / "nasflix" master_path.mkdir() target_path.mkdir() master_id = str(uuid.uuid4()) target_id = str(uuid.uuid4()) database.execute( "INSERT INTO projects(id,name,path,owner_id,full_access,execution_group,created_at) VALUES(?,?,?,?,?,?,?)", (master_id, "Agent maître", str(master_path), None, 1, "__global_agent__", utcnow()), ) database.execute( "INSERT INTO projects(id,name,path,owner_id,full_access,created_at) VALUES(?,?,?,?,?,?)", (target_id, "NASflix", str(target_path), "owner-1", 0, utcnow()), ) codex = CodexManager(database, tmp_path / "data", "/bin/true") codex._spawn = lambda *args, **kwargs: 4242 return database, AutonomousRemediationService(database, codex) def finish_queue(database: Database, queue_id: str, content: str) -> str: queue = database.one("SELECT * FROM prompt_queue WHERE id=?", (queue_id,)) database.execute("UPDATE prompt_queue SET status='done',finished_at=? WHERE id=?", (utcnow(), queue_id)) database.execute("UPDATE conversations SET status='completed',worker_pid=NULL,updated_at=? WHERE id=?", (utcnow(), queue["conversation_id"])) database.execute( "INSERT INTO messages(id,conversation_id,role,content,metadata,created_at) VALUES(?,?,?,?,?,?)", (str(uuid.uuid4()), queue["conversation_id"], "assistant", content, json.dumps({"queue_id": queue_id, "actions": [], "files": []}), utcnow()), ) return queue_id def finish_active(database: Database, run_id: str, content: str) -> str: run = database.one("SELECT * FROM remediation_runs WHERE id=?", (run_id,)) return finish_queue(database, run["active_queue_id"], content) def add_self_project(database: Database, root) -> str: path = root / "codexpi-self" path.mkdir() project_id = str(uuid.uuid4()) database.execute( "INSERT INTO projects(id,name,path,owner_id,full_access,created_at) VALUES(?,?,?,?,?,?)", (project_id, "CodexPi (Self)", str(path), "owner-1", 1, utcnow()), ) return project_id def test_supervision_marker_is_strict_and_accent_insensitive(): assert normalize_name("Agent Bibliothécaire") == "agentbibliothecaire" verdict = parse_supervision_verdict('Rapport\nCODEXPI_SUPERVISION {"decision":"correct","feedback":"Rejouer les tests"}') assert verdict == {"decision": "correct", "feedback": "Rejouer les tests"} assert parse_supervision_verdict("approuvé sans marqueur") is None def test_self_repair_marker_is_strict_and_bounded(): verdict = parse_self_repair_verdict( 'Rapport\nCODEXPI_SELF_REPAIR {"decision":"fixed","feedback":"Plan corrigé",' '"prompt_addendum":"Accepter une maintenance bornée."}' ) assert verdict == { "decision": "fixed", "feedback": "Plan corrigé", "prompt_addendum": "Accepter une maintenance bornée.", } assert parse_self_repair_verdict('CODEXPI_SELF_REPAIR {"decision":"ignore"}') is None def test_block_dispatches_codexpi_self_without_replacing_existing_threads(tmp_path): database, service = service_env(tmp_path) self_project_id = add_self_project(database, tmp_path) run = service.start("owner-1", [plan_step()]) finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"approve","feedback":"Plan valide"}') service.tick() target_conversation = service.detail(run["id"], "owner-1")["steps"][0]["conversation_id"] finish_active(database, run["id"], "La protection persistante manque.") service.tick() finish_active( database, run["id"], 'CODEXPI_SUPERVISION {"decision":"block","feedback":"Corriger le contrat de reprise"}', ) service.tick() blocked = service.detail(run["id"], "owner-1") repair = blocked["self_repair"] assert blocked["status"] == "BLOCKED" assert blocked["steps"][0]["conversation_id"] == target_conversation assert repair["status"] == "RUNNING" assert repair["project_id"] == self_project_id assert repair["conversation_id"] != target_conversation queued = database.one("SELECT content FROM prompt_queue WHERE id=?", (repair["queue_id"],)) assert "Corriger le contrat de reprise" in queued["content"] assert "Ne contourne jamais" in queued["content"] assert any(event["kind"] == "self_repair.started" for event in blocked["events"]) def test_successful_self_repair_rebases_and_resumes_the_same_step(tmp_path): database, service = service_env(tmp_path) add_self_project(database, tmp_path) run = service.start("owner-1", [plan_step()]) finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"approve","feedback":"Plan valide"}') service.tick() working = service.detail(run["id"], "owner-1") target_conversation = working["steps"][0]["conversation_id"] finish_active(database, run["id"], "Le plan impose deux contraintes contradictoires.") service.tick() finish_active( database, run["id"], 'CODEXPI_SUPERVISION {"decision":"block","feedback":"Contradiction du plan"}', ) service.tick() repair = service.detail(run["id"], "owner-1")["self_repair"] finish_queue( database, repair["queue_id"], 'Correction et tests terminés.\nCODEXPI_SELF_REPAIR ' '{"decision":"fixed","feedback":"Le contrat distingue maintenance et perte de données.",' '"prompt_addendum":"Une courte maintenance réversible est autorisée sans perte de données."}', ) assert service.tick()["state"] == "self_repair_processed" resumed = service.detail(run["id"], "owner-1") assert resumed["status"] == "RUNNING" assert resumed["stage"] == "AGENT" assert resumed["steps"][0]["status"] == "CORRECTING" assert resumed["steps"][0]["conversation_id"] == target_conversation assert resumed["steps"][0]["attempts"] == 2 assert "courte maintenance réversible" in resumed["steps"][0]["prompt"] assert resumed["self_repair"]["status"] == "SUCCEEDED" assert any(event["kind"] == "self_repair.resumed" for event in resumed["events"]) def test_self_repair_can_require_the_owner_without_retrying_the_chantier(tmp_path): database, service = service_env(tmp_path) add_self_project(database, tmp_path) run = service.start("owner-1", [plan_step()]) finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"block","feedback":"Secret externe requis"}') service.tick() repair = service.detail(run["id"], "owner-1")["self_repair"] finish_queue( database, repair["queue_id"], 'CODEXPI_SELF_REPAIR {"decision":"needs_user","feedback":"Un secret externe doit être fourni",' '"prompt_addendum":""}', ) service.tick() blocked = service.detail(run["id"], "owner-1") assert blocked["status"] == "BLOCKED" assert blocked["self_repair"]["status"] == "NEEDS_USER" assert blocked["self_repair"]["feedback"] == "Un secret externe doit être fourni" def test_self_repair_keeps_deployment_approval_blocked_without_completing_the_step(tmp_path): database, service = service_env(tmp_path) add_self_project(database, tmp_path) run = service.start("owner-1", [plan_step(target="Aloe")]) finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"approve","feedback":"Plan valide"}') service.tick() finish_active(database, run["id"], "Les manifests et les contrôles dry-run sont valides.") service.tick() finish_active( database, run["id"], 'CODEXPI_SUPERVISION {"decision":"block","feedback":"Le déploiement et le redémarrage exigent la validation distincte du propriétaire"}', ) service.tick() repair = service.detail(run["id"], "owner-1")["self_repair"] finish_queue( database, repair["queue_id"], 'CODEXPI_SELF_REPAIR {"decision":"needs_user","feedback":"Validation propriétaire requise avant déploiement ou redémarrage",' '"prompt_addendum":""}', ) assert service.tick()["state"] == "self_repair_processed" blocked = service.detail(run["id"], "owner-1") assert blocked["status"] == "BLOCKED" assert blocked["steps"][0]["status"] == "BLOCKED" assert blocked["steps"][0]["finished_at"] is None assert blocked["self_repair"]["status"] == "NEEDS_USER" assert blocked["self_repair"]["prompt_addendum"] == "" resumed = service.resume("owner-1") assert resumed["status"] == "RUNNING" assert resumed["stage"] == "AGENT" assert resumed["self_repair"]["status"] == "SUPERSEDED" assert any(event["kind"] == "self_repair.owner_resolved" for event in resumed["events"]) def test_interrupted_supervisor_restarts_review_without_replaying_project_work(tmp_path): database, service = service_env(tmp_path) run = service.start("owner-1", [plan_step(target="Aloe")]) finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"approve","feedback":"Plan valide"}') service.tick() working = service.detail(run["id"], "owner-1") conversation_id = working["steps"][0]["conversation_id"] finish_active(database, run["id"], "Déploiement réversible terminé avec mesures avant/après.") service.tick() reviewing = service.detail(run["id"], "owner-1") interrupted_queue_id = reviewing["active_queue_id"] database.execute( "UPDATE prompt_queue SET status='failed',error=?,finished_at=? WHERE id=?", (INTERRUPTED_WORKER_REASON, utcnow(), interrupted_queue_id), ) assert service.tick()["state"] == "processed" restarted = service.detail(run["id"], "owner-1") assert restarted["status"] == "RUNNING" assert restarted["stage"] == "SUPERVISOR" assert restarted["active_queue_id"] != interrupted_queue_id assert restarted["steps"][0]["status"] == "REVIEWING" assert restarted["steps"][0]["attempts"] == 1 assert restarted["steps"][0]["conversation_id"] == conversation_id assert restarted["self_repair"] is None retry = database.one("SELECT content FROM prompt_queue WHERE id=?", (restarted["active_queue_id"],)) assert "Reprends uniquement l’inspection" in retry["content"] assert "Ne relance aucune modification" in retry["content"] assert any(event["kind"] == "master.review_restarted" for event in restarted["events"]) def test_self_repair_of_interrupted_supervisor_resumes_review_not_agent(tmp_path): database, service = service_env(tmp_path) add_self_project(database, tmp_path) run = service.start("owner-1", [plan_step(target="Aloe")]) finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"approve","feedback":"Plan valide"}') service.tick() finish_active(database, run["id"], "Déploiement réversible terminé avec mesures avant/après.") service.tick() reviewing = service.detail(run["id"], "owner-1") project_attempts = reviewing["steps"][0]["attempts"] service._block(run["id"], INTERRUPTED_WORKER_REASON, "step-one") repair = service.detail(run["id"], "owner-1")["self_repair"] finish_queue( database, repair["queue_id"], 'CODEXPI_SELF_REPAIR {"decision":"fixed","feedback":"Reprise de revue bornée testée",' '"prompt_addendum":""}', ) assert service.tick()["state"] == "self_repair_processed" resumed = service.detail(run["id"], "owner-1") assert resumed["status"] == "RUNNING" assert resumed["stage"] == "SUPERVISOR" assert resumed["steps"][0]["status"] == "REVIEWING" assert resumed["steps"][0]["attempts"] == project_attempts queued = database.one("SELECT content FROM prompt_queue WHERE id=?", (resumed["active_queue_id"],)) assert "Reprends uniquement l’inspection" in queued["content"] def test_unexpected_runner_crash_is_sent_to_codexpi_self(tmp_path): database, service = service_env(tmp_path) add_self_project(database, tmp_path) run = service.start("owner-1", [plan_step()]) def crash(_run): raise RuntimeError("plantage inattendu du moteur") service._process = crash result = service.tick() assert result["state"] == "blocked" blocked = service.detail(run["id"], "owner-1") assert blocked["status"] == "BLOCKED" assert "plantage inattendu" in blocked["error"] assert blocked["self_repair"]["status"] == "RUNNING" queued = database.one("SELECT content FROM prompt_queue WHERE id=?", (blocked["self_repair"]["queue_id"],)) assert "plantage inattendu du moteur" in queued["content"] def test_upgrade_recovers_a_legacy_block_and_rebuilds_its_supervisor_stage(tmp_path): database, service = service_env(tmp_path) add_self_project(database, tmp_path) run = service.start("owner-1", [plan_step()]) finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"approve","feedback":"Plan valide"}') service.tick() finish_active(database, run["id"], "Travail projet terminé avec preuves.") service.tick() reviewing = service.detail(run["id"], "owner-1") assert reviewing["stage"] == "SUPERVISOR" queue_id = reviewing["active_queue_id"] queue = database.one("SELECT conversation_id FROM prompt_queue WHERE id=?", (queue_id,)) stamp = utcnow() database.execute( "UPDATE prompt_queue SET status='failed',error=?,finished_at=? WHERE id=?", ("thread-store conflict: already has an active writer", stamp, queue_id), ) database.execute( "UPDATE conversations SET status='failed',worker_pid=NULL,updated_at=? WHERE id=?", (stamp, queue["conversation_id"]), ) database.execute( "UPDATE remediation_runs SET status='BLOCKED',stage='IDLE',error=?,lease_owner=NULL,lease_until=NULL," "finished_at=?,updated_at=? WHERE id=?", ("thread-store conflict: already has an active writer", stamp, stamp, run["id"]), ) database.execute( "UPDATE remediation_step_runs SET status='BLOCKED',feedback=?,updated_at=? WHERE run_id=? AND step_id=?", ("thread-store conflict", stamp, run["id"], "step-one"), ) upgraded = AutonomousRemediationService(database, service.codex) result = upgraded.tick() assert result["state"] == "self_repair_recovered" assert result["blocked_stage"] == "SUPERVISOR" recovered = upgraded.detail(run["id"], "owner-1") assert recovered["self_repair"]["status"] == "RUNNING" assert recovered["self_repair"]["blocked_stage"] == "SUPERVISOR" assert any(event["kind"] == "self_repair.catchup_started" for event in recovered["events"]) def test_master_and_project_ping_pong_runs_until_final_report(tmp_path): database, service = service_env(tmp_path) created = service.start("owner-1", [plan_step()]) run_id = created["id"] assert created["stage"] == "PLAN_REVIEW" assert created["steps"][0]["status"] == "PENDING" finish_active(database, run_id, 'Plan cohérent.\nCODEXPI_SUPERVISION {"decision":"approve","feedback":"Ordre validé"}') assert service.tick()["state"] == "processed" running = service.detail(run_id, "owner-1") assert running["stage"] == "AGENT" assert running["steps"][0]["status"] == "WORKING" first_conversation = running["steps"][0]["conversation_id"] finish_active(database, run_id, "Correction appliquée, mais preuve partielle.") service.tick() reviewing = service.detail(run_id, "owner-1") assert reviewing["stage"] == "SUPERVISOR" assert reviewing["steps"][0]["status"] == "REVIEWING" finish_active(database, run_id, 'Preuve insuffisante.\nCODEXPI_SUPERVISION {"decision":"correct","feedback":"Ajouter la mesure avant/après"}') service.tick() correcting = service.detail(run_id, "owner-1") assert correcting["stage"] == "AGENT" assert correcting["correction_round"] == 1 assert correcting["steps"][0]["status"] == "CORRECTING" assert correcting["steps"][0]["conversation_id"] == first_conversation assert correcting["steps"][0]["attempts"] == 2 finish_active(database, run_id, "Correction complète avec mesures et tests réussis.") service.tick() finish_active(database, run_id, 'Résultat vérifié.\nCODEXPI_SUPERVISION {"decision":"approve","feedback":"Mesures et tests conformes"}') service.tick() finalizing = service.detail(run_id, "owner-1") assert finalizing["stage"] == "FINAL_REVIEW" assert finalizing["steps"][0]["status"] == "APPROVED" finish_active(database, run_id, "Synthèse finale du chantier.") service.tick() completed = service.detail(run_id, "owner-1") assert completed["status"] == "COMPLETED" assert completed["stage"] == "IDLE" assert any(event["kind"] == "run.completed" for event in completed["events"]) def test_only_one_autonomous_run_can_be_active(tmp_path): _, service = service_env(tmp_path) service.start("owner-1", [plan_step()]) with pytest.raises(RemediationConflict, match="déjà en cours"): service.start("owner-1", [plan_step()]) def test_blocked_run_resumes_in_the_existing_project_conversation(tmp_path): database, service = service_env(tmp_path) run = service.start("owner-1", [plan_step()]) finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"approve","feedback":"Plan valide"}') service.tick() working = service.detail(run["id"], "owner-1") conversation_id = working["steps"][0]["conversation_id"] finish_active(database, run["id"], "Accès technique indisponible.") service.tick() finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"block","feedback":"Canal manquant"}') service.tick() assert service.detail(run["id"], "owner-1")["status"] == "BLOCKED" resumed = service.resume("owner-1") assert resumed["status"] == "RUNNING" assert resumed["stage"] == "AGENT" assert resumed["steps"][0]["status"] == "CORRECTING" assert resumed["steps"][0]["conversation_id"] == conversation_id assert resumed["steps"][0]["attempts"] == 2 assert any(event["kind"] == "run.resumed" for event in resumed["events"]) def test_blocked_run_can_rebase_prompts_without_replacing_threads_or_permissions(tmp_path): database, service = service_env(tmp_path) original = plan_step() run = service.start("owner-1", [original]) finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"approve","feedback":"Plan valide"}') service.tick() working = service.detail(run["id"], "owner-1") conversation_id = working["steps"][0]["conversation_id"] finish_active(database, run["id"], "Certaines métriques sont refusées par le noyau.") service.tick() finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"block","feedback":"Critère impossible"}') service.tick() database.execute("UPDATE remediation_runs SET correction_round=3 WHERE id=?", (run["id"],)) revised = {**original, "prompt": original["prompt"] + " Accepter une couverture explicitement qualifiée sans modifier les droits."} resumed = service.resume("owner-1", [revised]) assert resumed["status"] == "RUNNING" assert resumed["correction_round"] == 0 assert resumed["steps"][0]["prompt"] == revised["prompt"] assert resumed["steps"][0]["conversation_id"] == conversation_id queued = database.one("SELECT content FROM prompt_queue WHERE id=?", (resumed["active_queue_id"],)) assert revised["prompt"] in queued["content"] event = next(item for item in resumed["events"] if item["kind"] == "run.resumed") assert event["details"]["plan_rebased"] is True def test_rebase_refuses_to_change_target_or_risk(tmp_path): database, service = service_env(tmp_path) run = service.start("owner-1", [plan_step()]) finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"approve","feedback":"Plan valide"}') service.tick() finish_active(database, run["id"], "Résultat incomplet.") service.tick() finish_active(database, run["id"], 'CODEXPI_SUPERVISION {"decision":"block","feedback":"Étape bloquée"}') service.tick() assert service.detail(run["id"], "owner-1")["status"] == "BLOCKED" changed = {**plan_step(), "risk": "production"} with pytest.raises(RemediationConflict, match="ordre, la cible ou le risque"): service.resume("owner-1", [changed]) def test_owner_can_start_and_read_the_autonomous_run_through_the_api(logged_in, monkeypatch): client, app, projects, _, headers = logged_in owner_id = str(app.state.db.one("SELECT id FROM users LIMIT 1")["id"]) master_path = projects / "master" target_path = projects / "nasflix" master_path.mkdir() target_path.mkdir() app.state.db.execute( "INSERT INTO projects(id,name,path,owner_id,full_access,execution_group,created_at) VALUES(?,?,?,?,?,?,?)", (str(uuid.uuid4()), "Agent maître", str(master_path), None, 1, "__global_agent__", utcnow()), ) app.state.db.execute( "INSERT INTO projects(id,name,path,owner_id,created_at) VALUES(?,?,?,?,?)", (str(uuid.uuid4()), "NASflix", str(target_path), owner_id, utcnow()), ) monkeypatch.setattr(app.state.codex, "detect", lambda *args: "/synthetic-agent") monkeypatch.setattr(app.state.codex, "_spawn", lambda *args: 4242) assert client.get("/api/resource-remediation").json()["status"] == "NOT_STARTED" response = client.post("/api/resource-remediation/start", headers=headers, json={"steps": [plan_step()]}) assert response.status_code == 201, response.text assert response.json()["stage"] == "PLAN_REVIEW" assert response.json()["master_conversation_id"] current = client.get("/api/resource-remediation") assert current.status_code == 200 assert current.json()["status"] == "RUNNING"