# frozen_string_literal: true # Dron::Sanador — Módulo atómico para reparación de drones # # Responsabilidad única: Reparar drones zombies/fallidos require_relative 'base' require_relative '../../db/core/dron_db' module Dron class Sanador MAX_REINTENTOS = 3 BACKOFF_BASE = 60 CLEANUP_DAYS = 7 class << self def reintentar_fallidos(dry_run: false) Base.logger.info("🩹 Dron Sanador: Re-intentando drones fallidos...") fallidos = Base.db_query("SELECT * FROM bitacoras.dron_logs WHERE estado = 'failed' AND reintentos < #{MAX_REINTENTOS} AND cmd IS NOT NULL ORDER BY completed_at ASC") return { reintentados: 0, omitidos: 0 } if fallidos.empty? reintentados = 0 omitidos = 0 fallidos.each do |dron| espera = BACKOFF_BASE * (2 ** dron['reintentos'].to_i) tiempo_desde_fallo = Time.now - dron['completed_at'] if tiempo_desde_fallo < espera Base.logger.debug("⏳ #{dron['dron_id']} en backoff (#{tiempo_desde_fallo.round}s/#{espera}s)") omitidos += 1 next end if dry_run Base.logger.info("🩹 [DRY RUN] Re-intentaría: #{dron['dron_id']}") reintentados += 1 else Base.logger.info("🩹 Re-intentando #{dron['dron_id']} (intento #{dron['reintentos'].to_i + 1}/#{MAX_REINTENTOS})") ADN::DB::DronDB.incrementar_reintentos(dron['dron_id']) resultado = Dron::Ejecutor.lanzar(cmd: dron['cmd'], nota: "Reintento: #{dron.dig('metadata', 'nota')}", flujo_id: dron['flujo_id']) reintentados += 1 if resultado[:exit_code] == 0 notificar_fallo_persistente(dron) if dron['reintentos'].to_i >= MAX_REINTENTOS && resultado[:exit_code] != 0 end end puts "\n🩹 Resultado: Reintentados=#{reintentados} | Omitidos=#{omitidos}" { reintentados: reintentados, omitidos: omitidos } end def limpiar_antiguos(dias: CLEANUP_DAYS, dry_run: false) Base.logger.info("🩹 Dron Sanador: Limpiando drones antiguos (>#{dias} días)...") resultado = Base.db_query("SELECT COUNT(*) as cantidad FROM bitacoras.dron_logs WHERE estado IN ('completed', 'failed', 'zombie') AND completed_at < NOW() - INTERVAL '#{dias} days'") cantidad = resultado.first['cantidad'].to_i return { eliminados: 0 } if cantidad == 0 if dry_run Base.logger.info("🩹 [DRY RUN] Eliminaría #{cantidad} drones antiguos") return { eliminados: 0 } end Base.db_exec("DELETE FROM bitacoras.dron_logs WHERE estado IN ('completed', 'failed', 'zombie') AND completed_at < NOW() - INTERVAL '#{dias} days'") Base.logger.info("🩹 ✅ Limpiados #{cantidad} drones antiguos") { eliminados: cantidad } end def sanear(dias_limpieza: CLEANUP_DAYS, dry_run: false) Base.logger.info("🩹 Dron Sanador: Saneamiento completo...") resultados = { reintentos: reintentar_fallidos(dry_run: dry_run), limpieza: limpiar_antiguos(dias: dias_limpieza, dry_run: dry_run) } puts "\n🩹 Saneamiento completado" puts " Reintentos: #{resultados[:reintentos][:reintentados]}" puts " Limpieza: #{resultados[:limpieza][:eliminados]}" resultados end private def notificar_fallo_persistente(dron) Base.logger.error("🚨 FALLO PERSISTENTE: #{dron['dron_id']} - #{Base.slice_safe(dron['cmd'], 50)}") Base.db_exec("INSERT INTO bitacoras.dron_avances (dron_id, paso, descripcion, estado) VALUES ($1, -99, $2, 'critical')", [dron['dron_id'], "FALLO PERSISTENTE tras #{dron['reintentos']} reintentos"]) end end end end