Files
fn_registry/python/functions/infra/summarize_fleet_transitions.py
T
agent 118d5d36d3 feat(orquestador): feed reactivo FLEET-STATE + fix peek de drain_fleet_events
El orquestador no se enteraba de los cambios de estado de su flota: el drenado
era manual y el peek documentado `./fn run drain_fleet_events --advance false`
devolvia un falso `{total_new:0, cursor:0}` porque `fn run` mapea los argumentos
posicionalmente y no parsea flags `--nombre valor` (events_path acababa valiendo
"--advance", una ruta inexistente).

- drain_fleet_events: nuevo helper _normalize_fn_run_flags que renormaliza el
  patron `--advance <bool>` aplanado por `fn run`, de modo que el peek funciona
  directo desde la CLI sin tocar el runner de Go. Bump 1.1.0 + growth log + tests
  del normalizador (unit y end-to-end por HOME).
- summarize_fleet_transitions (nueva, pure, grupo claude-fleet): resume el dict
  by_classification de drain en un bloque de una linea con las tres categorias
  accionables (terminados / reclaman / estancados), dedup por session_id y
  truncado de objetivo.
- hook_fleet_state_inject.sh (UserPromptSubmit): si la sesion es role=orchestrator
  (leido de ~/.claude/goals/<session_id>.json), hace peek de la cola sin mover el
  cursor y emite el bloque FLEET-STATE cada turno. Degrada limpio si el watcher
  esta caido, la cola no existe o la sesion no es orquestador.

El registro del hook va en .claude/settings.local.json (gitignored, fuera de este
commit). Pendiente, lo integra otro agente: documentar el bloque FLEET-STATE en
.claude/commands/orquestador.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:06:01 +02:00

121 lines
4.5 KiB
Python

"""Resumen de una linea de las transiciones accionables de la flota.
Toma el dict `by_classification` que produce `drain_fleet_events` y lo condensa
en una sola linea inyectable por un hook `UserPromptSubmit` al orquestador-Claude
de flota. Funcion pura, determinista y defensiva ante datos mal formados.
"""
# Categorias accionables (clave del dict -> etiqueta de salida), en orden de render.
_ACTIONABLE: list[tuple[str, str]] = [
("DICE_TERMINADO", "terminados"),
("RECLAMA", "reclaman"),
("ESTANCADO", "estancados"),
]
_GOAL_MAXLEN = 40
_SID_LEN = 8
_NO_SID = "????????"
_NO_GOAL = "(sin objetivo)"
def _short_sid(session_id) -> str:
"""Primeros 8 caracteres del session_id, o `????????` si falta/vacio."""
if not isinstance(session_id, str) or session_id == "":
return _NO_SID
return session_id[:_SID_LEN]
def _trunc_goal(goal) -> str:
"""Goal truncado a 40 caracteres con elipsis, o `(sin objetivo)` si falta."""
if not isinstance(goal, str) or goal == "":
return _NO_GOAL
if len(goal) > _GOAL_MAXLEN:
return goal[:_GOAL_MAXLEN] + ""
return goal
def _dedup_events(raw) -> list[dict]:
"""Filtra a dicts validos y deduplica por session_id quedandose con la ultima aparicion.
Mantiene el orden de la ultima aparicion de cada session_id. Eventos sin
session_id valido (None/vacio/ausente) NO se deduplican entre si: cada uno
cuenta como entrada propia.
"""
if not isinstance(raw, list):
return []
ordered: list[dict] = []
index_by_sid: dict[str, int] = {}
for ev in raw:
if not isinstance(ev, dict):
continue
sid = ev.get("session_id")
if isinstance(sid, str) and sid != "":
if sid in index_by_sid:
# Reemplaza la entrada previa por la mas reciente, conservando
# la posicion de la ultima aparicion.
ordered[index_by_sid[sid]] = None # type: ignore[call-overload]
index_by_sid[sid] = len(ordered)
ordered.append(ev)
else:
ordered.append(ev)
return [ev for ev in ordered if ev is not None]
def summarize_fleet_transitions(by_classification: dict, max_items: int = 6) -> str:
"""Resume en una linea las transiciones accionables de la flota para el orquestador.
Args:
by_classification: dict agrupado por campo `to` (salida de drain_fleet_events).
Solo se consideran las claves accionables DICE_TERMINADO, RECLAMA y
ESTANCADO; el resto (TRABAJANDO, GONE, MAL_LANZADO) se ignora.
max_items: limite total de eventos mostrados sumando las tres categorias.
Si se supera, se truncan los sobrantes y se añade ` (+N mas)`. El
recorte respeta el orden de categorias terminados -> reclaman ->
estancados (se llenan en ese orden hasta agotar el cupo).
Returns:
Una cadena de una sola linea. `FLEET-STATE: sin cambios` si no hay nada
accionable; en caso contrario el bloque con las categorias no vacias.
"""
if not isinstance(by_classification, dict) or not by_classification:
return "FLEET-STATE: sin cambios"
# Deduplicar y normalizar cada categoria accionable.
deduped: dict[str, list[dict]] = {}
total_available = 0
for key, _label in _ACTIONABLE:
events = _dedup_events(by_classification.get(key))
deduped[key] = events
total_available += len(events)
if total_available == 0:
return "FLEET-STATE: sin cambios"
# Aplicar el limite total recorriendo categorias en orden, llenando el cupo.
limit = max_items if isinstance(max_items, int) and max_items >= 0 else 0
shown_total = 0
rendered_segments: list[str] = []
for key, label in _ACTIONABLE:
events = deduped[key]
if not events:
continue
remaining = limit - shown_total
if remaining <= 0:
break
take = events[:remaining]
shown_total += len(take)
items = [f"{_short_sid(ev.get('session_id'))}:{_trunc_goal(ev.get('goal'))}" for ev in take]
rendered_segments.append(f"{label}=[{', '.join(items)}]")
omitted = total_available - shown_total
# Caso limite: max_items == 0 (u omite todo) pero habia eventos disponibles.
if not rendered_segments:
return f"FLEET-STATE: (+{omitted} mas) (drain con ./fn run drain_fleet_events para consumir)"
body = " ".join(rendered_segments)
overflow = f" (+{omitted} mas)" if omitted > 0 else ""
return f"FLEET-STATE: {body}{overflow} (drain con ./fn run drain_fleet_events para consumir)"