Ciclo de cierre ITSM — resolved_at con escritor y auto-resolución de grupos
{“metadata”: {“last_verified”: “2026-09-06”, “sources”: [{“type”:“commit”,“ref”:“1c83533”},{“type”:“code”,“ref”:“monitoring/services/correlation_service.py”},{“type”:“code”,“ref”:“monitoring/services/insight_service.py”},{“type”:“code”,“ref”:“monitoring/services/pattern_service.py”},{“type”:“code”,“ref”:“monitoring/services/sla_service.py”},{“type”:“code”,“ref”:“monitoring/api/insights.py”},{“type”:“code”,“ref”:“tests/api/test_itsm_close_cycle.py”},{“type”:“commit”,“ref”:“e83125f”}]}, “content”: ”## Resumen\n\nLa v1.66.11 cierra por fin el ciclo de vida de incidencias: el campo AIInsight.resolved_at —que existía desde la Fase 1 pero jamás era escrito— ahora recibe valor cuando:\n\n1. ACK (reconocimiento manual) → resolved_at := acknowledged_at\n2. Auto-resolución por conectividad → resolved_at := acknowledged_at (se genera)\n3. Fix aplicado con éxito → resolved_at := now()\n4. NOT: Expiración → resolved_at se deja en NULL (caducar no es resolver)\n\nCon este cambio, las métricas ITSM cobran vida:\n- MTTR (Mean Time To Resolve) ahora mide tiempo real, no 0.\n- Tasa de resolución diferencia qué se atendió de qué se abandonó.\n- SLA Compliance deja de estar en rojo perpetuo (~8.515/8.663 insights marcados erróneamente como incumplidos).\n\n### Lo que cambia en operación\n\n#### Ante un insight:\n- Cuando lo reconoces, el sistema registra la hora de cierre → la métrica MTTR incluye ese cierre.\n- Si el equipo se recupera solo, la auto-resolución escribe la hora → MTTR se actualiza.\n- Si aplicas un fix que funciona, se escribe la hora → resolución confirmada.\n- Si caduca sin que lo mires, no cuenta como resuelto (es abandonado, no cerrado).\n\n#### Ante un grupo de insights correlacionados:\n- Cuando cierra el último insight abierto (pending/executing/failed), el grupo se resuelve automáticamente.\n- Si entra un insight nuevo y se correlaciona al grupo, el grupo se reabre.\n- Los grupos que llevaban abiertos desde siempre (~825) se auto-resuelven con la migración, capturando la fecha real de cierre.\n\n#### Ante patrones recurrentes:\n- La tarea diaria purga patrones sin apariciones en los últimos 30 días → deja de haber “señales rancias” en PROD.\n\n#### En la lista de grupos de ITSM:\n- Performance: de ~1.700 consultas a 2 (annotate + un pase de targets).\n- Test con techo de consultas previene regresión.\n\n---\n\n## Semántica: la decisión de Edu\n\nReconocer es resolver. El producto no tiene un “acto de cierre” separado: el ticket se abre con el diagnóstico y se cierra cuando alguien lo reconoce o aplica el fix. Caducar NO cuenta como resolución porque es abandono, no intervención — esa métrica debe poder decir “el 85% de lo que detectamos fue atendido”.\n\n(Ver [[decision—20260814—itsm-closed-at-semantics]] para argumentación completa.)\n\n---\n\n## Impacto en datos históricos\n\nLa migración 0029_backfill_itsm_close_cycle:\n- Llena resolved_at de 8.663 insights retroactivamente (desde ack/applied reales).\n- Des-marca ~8.515 insights que fueron erróneamente tachados de SLA incumplido.\n- Purga 373 grupos vacíos (cascarones tras borrados en cascada).\n- Auto-resuelve 449 grupos que no tenían insights abiertos.\n- Borra 69 patrones fuera de la ventana de 30 días.\n\nLas métricas nacen con meses de histórico real, no de cero.\n\n---\n\n## Cobertura de pruebas\n\nEl archivo tests/api/test_itsm_close_cycle.py (194 líneas) fija el contrato completo:\n\n- ✅ Cada camino de cierre escribe resolved_at o NO según corresponda.\n- ✅ Los grupos se cierran/reabren solos.\n- ✅ La lista de grupos agrega en 2 queries (sin N+1).\n- ✅ La purga de patrones respeta la ventana de 30 días.\n\nClase de test anterior: la suite nunca validaba esto. De ahí el hallazgo.\n\n---\n\n## Actualización 23-08-2026 (v1.82.3, task #250) — la purga de grupos vacíos se hace permanente\n\nLa migración 0029 (arriba) purgó 373 grupos vacíos una sola vez. El problema seguía vivo: AIInsight.incident_group es SET_NULL, y close_group_if_done() solo se invoca desde los cierres de insight — un IncidentGroup que se queda sin ningún insight (porque se borraron todos, no porque se cerraron) nunca pasa por ahí y queda huérfano en pie para siempre. El 23-08-2026 la cuenta en PROD había vuelto a subir a 477 grupos vacíos, uno de ellos “open” desde siempre (tarjeta fantasma en el panel ITSM).\n\nDos caminos concretos volvían a generar el problema:\n1. Borrar un target (monitoring/api/targets.py::delete_target) — el .delete() en cascada se lleva las incidencias del target y deja su(s) grupo(s) vacíos en pie.\n2. Purgado masivo del Observatory (monitoring/api/insights.py::purge_all_insights) — borra todos los insights de la organización de un golpe; sin limpieza explícita, todos los grupos de esa organización quedan vacíos.\n\n### El fix: purge_empty_groups(org) permanente\n\npython\n# monitoring/services/correlation_service.py\ndef purge_empty_groups(org) -> int:\n ids = list(\n IncidentGroup.objects.filter(organization=org)\n .annotate(n_insights=Count(\"insights\"))\n .filter(n_insights=0)\n .values_list(\"id\", flat=True)\n )\n if ids:\n IncidentGroup.objects.filter(id__in=ids).delete()\n return len(ids)\n\n\nLlamado desde los dos caminos que lo disparan (delete_target y purge_all_insights), así que ya no depende de una migración puntual para mantenerse limpio.\n\n### Migración 0031_purge_empty_incident_groups\n\nLimpieza única de lo ya acumulado desde la 0029 — mismo criterio (annotate(Count(\"insights\")).filter(n_insights=0)), aplicado con RunPython sin DDL (solo DML, seguro bajo RLS con bypass de rol de app).\n\nLección: una migración de backfill que arregla un histórico sin arreglar también el código que lo generó vuelve a ensuciarse. La 0031 no es solo “otra limpieza” — viene acompañada del guardián permanente que la 0029 no tenía.\n\n---\n\n## Actualización 06-09-2026 (v1.121.0, task #284 pts. 3 y 6) — el mismo guardián se extiende a los patrones, y el semáforo de SLA deja de estar en rojo por diseño\n\nDos huecos más del mismo ciclo de cierre, cerrados en el commit e83125f (PR #514):\n\n### RecurringPattern huérfano tras un purgado masivo\n\npurge_all_insights (mismo endpoint que en agosto) ya limpiaba IncidentGroup vacíos con purge_empty_groups(), pero dejaba huérfano a RecurringPattern — un modelo que no tiene FK a AIInsight: es un snapshot recalculado a diario por pattern_service.detect_patterns() a partir de insights que ya no existen tras el purgado. Antes de este fix, esos 41 patrones huérfanos no se corregían — caducaban solos hacia el 18-09 tal como predijo el CHANGELOG v1.82.3 (la purga diaria por antigüedad de 30 días) —, pero el hueco que permitía volver a crearlos seguía abierto. Ahora purge_all_insights borra también los RecurringPattern de la organización en el mismo golpe, mismo criterio de guardián permanente que purge_empty_groups:\n\npython\n# monitoring/api/insights.py — dentro de purge_all_insights\nfrom monitoring.models_itsm import RecurringPattern\npatterns_deleted, _ = await sync_to_async(\n lambda: RecurringPattern.objects.filter(organization=org).delete()\n)()\n\n\n### El denominador de SLA Compliance excluía a nadie\n\nHasta ahora, sla_service.get_sla_metrics() calculaba el % de cumplimiento de SLA sobre todos los insights con sla_ack_deadline puesto, sin excluir a ninguno. El problema: dos categorías de cierre nunca pueden “cumplir” porque el sistema las cerró sin que nadie interviniera —EXPIRED (caducó sin que nadie llegara a tiempo) y ACKNOWLEDGED con acknowledged_by nulo (auto-resuelto por recuperación de conectividad, sin operador detrás)—. Con 0% de triaje humano en la ventana medida, el semáforo estaba en rojo por diseño matemático, no porque el equipo fallara. Decisión de Edu (opción A): el denominador ahora excluye ambas categorías; todo lo demás —incluida cualquier incidencia con un operador real detrás— sigue contando igual:\n\npython\n# monitoring/services/sla_service.py — get_sla_metrics()\nsystem_closed = Q(status=AIInsight.Status.EXPIRED) | Q(\n status=AIInsight.Status.ACKNOWLEDGED, acknowledged_by__isnull=True\n)\nsla_qs = qs.filter(sla_ack_deadline__isnull=False).exclude(system_closed)\n\n\nMisma filosofía que la sección de arriba: la métrica solo debe medir lo que un humano pudo haber hecho distinto.\n\n---\n\n## Ficheros principales\n\n| Fichero | Cambio |\n|---------|--------|\n| monitoring/services/correlation_service.py | close_group_if_done(), reopen_group(), callback en correlación, purge_empty_groups() (v1.82.3) |\n| monitoring/services/insight_service.py | resolved_at en ack, auto-resolución, applied success; groups callback en expire |\n| monitoring/services/pattern_service.py | Purga diaria de patrones last_seen < now - 30d |\n| monitoring/services/sla_service.py | get_sla_metrics(): denominador de compliance excluye EXPIRED/ACKNOWLEDGED sin operador (v1.121.0) |\n| monitoring/api/itsm_groups.py | Optimización list_groups: annotate + single pass targets |\n| monitoring/api/targets.py | delete_target llama purge_empty_groups() tras el delete en cascada (v1.82.3) |\n| monitoring/api/insights.py | purge_all_insights llama purge_empty_groups() (v1.82.3) y purga RecurringPattern huérfanos (v1.121.0) |\n| monitoring/migrations/0029_backfill_itsm_close_cycle.py | Backfill + history reconstruction |\n| monitoring/migrations/0031_purge_empty_incident_groups.py | Limpieza única de los 477 grupos reacumulados (v1.82.3) |\n| tests/api/test_itsm_close_cycle.py | Suite de 194 líneas fijando el contrato |\n\n---\n\n## Véase también\n\n- [[decision—20260814—itsm-closed-at-semantics]]\n- [[entity—monitoring—service—close-group-if-done]]\n- [[entity—monitoring—service—reopen-group]]\n- [[entity—monitoring—migration—0029-backfill-itsm]]\n- [[concept—saas—multi-tenancy]]\n- [[feature—monitoring—diagnostico-proveedor-visible]] — la otra mitad del mismo PR (v1.121.0): visibilidad del proveedor de IA y arreglo del gráfico MTTA/MTTR.”}