diff --git a/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py b/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py index cf20cd968..2c1625973 100644 --- a/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py +++ b/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py @@ -356,6 +356,52 @@ def _learning_profile( } +def _reconcile_existing_playbook_profile( + playbook: PlaybookRecord, + *, + profile: dict[str, Any], + mode: str, +) -> bool: + """Idempotently remove legacy cold-start semantics from typed routes.""" + + if profile["affected_services"] != ["backup_restore"]: + return False + desired = { + "description": profile["description"], + "symptom_pattern": { + "alert_names": [], + "affected_services": profile["affected_services"], + "severity_range": ["P0", "P1", "P2"], + "label_patterns": {}, + "keywords": profile["keywords"], + }, + "repair_steps": [{ + "step_number": 1, + "action_type": profile["action_type"], + "command": mode, + "expected_result": profile["expected_result"], + "rollback_command": profile["rollback_command"], + "requires_approval": False, + "risk_level": "LOW", + }], + "notes": profile["notes"], + "stateful_targets": [], + "requires_pre_backup": False, + } + changed = False + for field, value in desired.items(): + if getattr(playbook, field, None) != value: + setattr(playbook, field, value) + changed = True + tags = list(playbook.tags or []) + for tag in profile["tags"]: + if tag not in tags: + tags.append(tag) + changed = True + playbook.tags = tags + return changed + + async def _ensure_playbook_trust( identity: Agent99DispatchIdentity, *, @@ -423,13 +469,21 @@ async def _ensure_playbook_trust( else: tags = list(playbook.tags or []) incidents = list(playbook.source_incident_ids or []) - if run_tag not in tags: + profile_changed = _reconcile_existing_playbook_profile( + playbook, + profile=profile, + mode=mode, + ) + tags = list(playbook.tags or []) + new_run = run_tag not in tags + if new_run: tags.append(run_tag) playbook.success_count = int(playbook.success_count or 0) + 1 playbook.trust_score = min( 1.0, 0.9 * float(playbook.trust_score or 0.3) + 0.1, ) + if new_run or profile_changed: playbook.version = int(playbook.version or 0) + 1 if identity.incident_id not in incidents: incidents.append(identity.incident_id) diff --git a/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py b/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py index 1ab7961a1..330cc9e34 100644 --- a/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py +++ b/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py @@ -71,6 +71,47 @@ def test_backup_learning_profile_is_typed_and_no_write() -> None: assert "verified recovery" not in profile["km_title"] +def test_existing_backup_playbook_profile_is_reconciled_idempotently() -> None: + identity = build_agent99_dispatch_identity( + project_id="awoooi", + incident_id="INC-20260719-BACKUP-PLAYBOOK", + source_fingerprint="backup-playbook-fingerprint", + route_id="agent99:backup_health:BackupCheck", + ) + profile = job._learning_profile(identity, mode="BackupCheck") + playbook = SimpleNamespace( + description="legacy recovery", + symptom_pattern={"affected_services": ["cold-start-gate"]}, + repair_steps=[{"action_type": "agent99_mode", "command": "Recover"}], + notes="legacy", + stateful_targets=["cold-start-gate"], + requires_pre_backup=True, + tags=["agent99", "controlled_dispatch"], + ) + + changed = job._reconcile_existing_playbook_profile( + playbook, + profile=profile, + mode="BackupCheck", + ) + replay_changed = job._reconcile_existing_playbook_profile( + playbook, + profile=profile, + mode="BackupCheck", + ) + + assert changed is True + assert replay_changed is False + assert playbook.symptom_pattern["affected_services"] == ["backup_restore"] + assert playbook.repair_steps[0]["action_type"] == "agent99_readonly_check" + assert playbook.repair_steps[0]["command"] == "BackupCheck" + assert playbook.repair_steps[0]["rollback_command"] == ( + "not_applicable_no_write" + ) + assert "backup_restore" in playbook.tags + assert "cold-start" not in str(playbook.__dict__) + + class _Context: def __init__(self, db) -> None: # type: ignore[no-untyped-def] self.db = db