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>
This commit is contained in:
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# Hook UserPromptSubmit: inyecta el estado de la flota al Claude orquestador.
|
||||
#
|
||||
# En el modo /orquestador, el Claude principal gestiona una flota de agentes y
|
||||
# necesita enterarse de forma reactiva cuando uno cambia de estado: termina
|
||||
# (DICE_TERMINADO), reclama una decision (RECLAMA) o se estanca (ESTANCADO).
|
||||
# El watcher de fleetview escribe esas transiciones a la cola JSONL
|
||||
# ~/.claude/fleet/events.jsonl. Este hook hace un peek de esa cola en cada turno
|
||||
# y emite un bloque "FLEET-STATE:" para que el orquestador vea los cambios
|
||||
# pendientes sin tener que drenar la cola a mano.
|
||||
#
|
||||
# Entrada (stdin JSON del hook UserPromptSubmit): { session_id, cwd, ... }
|
||||
# El stdout de este script se inyecta como additionalContext en el turno.
|
||||
#
|
||||
# Solo el orquestador recibe el feed: se identifica leyendo el campo `role` de
|
||||
# ~/.claude/goals/<session_id>.json (lo marca `mark_claude_role`). Cualquier
|
||||
# sesion que no sea role=orchestrator termina en silencio (sin stdout).
|
||||
#
|
||||
# El peek usa advance=False: NO mueve el cursor de la cola. El orquestador sigue
|
||||
# viendo los mismos eventos pendientes cada turno hasta que los consume
|
||||
# explicitamente con `./fn run drain_fleet_events` (que si avanza el cursor).
|
||||
#
|
||||
# Degradacion limpia: si falta jq/python/venv, si la cola no existe, o si el
|
||||
# watcher esta caido, el hook nunca rompe el turno (siempre exit 0).
|
||||
set -u
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
|
||||
INPUT=$(cat)
|
||||
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
|
||||
[ -z "$SESSION_ID" ] && exit 0
|
||||
|
||||
GOAL_FILE="$HOME/.claude/goals/${SESSION_ID}.json"
|
||||
ROLE=""
|
||||
[ -f "$GOAL_FILE" ] && ROLE=$(jq -r '.role // ""' "$GOAL_FILE" 2>/dev/null)
|
||||
|
||||
# Solo el orquestador recibe el feed de la flota. Resto: silencio total.
|
||||
[ "$ROLE" != "orchestrator" ] && exit 0
|
||||
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/fn_registry}"
|
||||
PY="$PROJECT_DIR/python/.venv/bin/python3"
|
||||
{ [ -x "$PY" ] && [ -d "$PROJECT_DIR/python/functions" ]; } || exit 0
|
||||
|
||||
OUT=$(FN_PROJECT_DIR="$PROJECT_DIR" timeout 8 "$PY" - <<'PYEOF' 2>/dev/null
|
||||
import os
|
||||
import sys
|
||||
|
||||
root = os.environ.get("FN_PROJECT_DIR", os.path.expanduser("~/fn_registry"))
|
||||
sys.path.insert(0, os.path.join(root, "python", "functions"))
|
||||
events = os.path.join(os.path.expanduser("~"), ".claude", "fleet", "events.jsonl")
|
||||
|
||||
try:
|
||||
from infra.drain_fleet_events import drain_fleet_events
|
||||
from infra.summarize_fleet_transitions import summarize_fleet_transitions
|
||||
|
||||
if not os.path.exists(events):
|
||||
# Watcher nunca arranco o cola borrada: diagnostico explicito.
|
||||
print("FLEET-STATE: cola del watcher no disponible (events.jsonl ausente)")
|
||||
else:
|
||||
drained = drain_fleet_events(advance=False) # peek: NO mueve el cursor
|
||||
print(summarize_fleet_transitions(drained.get("by_classification", {})))
|
||||
except Exception:
|
||||
# Funciones no indexadas, cola corrupta, etc.: degradar sin romper el turno.
|
||||
pass
|
||||
PYEOF
|
||||
)
|
||||
|
||||
[ -n "$OUT" ] && printf '%s\n' "$OUT"
|
||||
exit 0
|
||||
@@ -3,7 +3,7 @@ name: drain_fleet_events
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "def drain_fleet_events(events_path: str | None = None, cursor_path: str | None = None, advance: bool = True) -> dict"
|
||||
description: "Drena la cola JSONL de eventos de la flota desde un cursor persistente. Lee los eventos nuevos (desde la linea del cursor hasta el final), los parsea saltando lineas en blanco o JSON invalido, los agrupa por su campo `to` (RECLAMA, MAL_LANZADO, DICE_TERMINADO, ESTANCADO, TRABAJANDO, GONE), aisla los urgentes y avanza el cursor para no reprocesar. Pensado para que el orquestador-Claude de flota consuma eventos nuevos cada vez que despierta."
|
||||
@@ -31,6 +31,8 @@ tests:
|
||||
- "json invalido se salta"
|
||||
- "agrupacion por to y urgent"
|
||||
- "cursor mayor que lineas reinicia"
|
||||
- "normaliza flag advance posicional de fn run"
|
||||
- "peek flag posicional no mueve cursor"
|
||||
test_file_path: "python/functions/infra/drain_fleet_events_test.py"
|
||||
file_path: "python/functions/infra/drain_fleet_events.py"
|
||||
---
|
||||
@@ -55,6 +57,17 @@ for ev in drained["urgent"]:
|
||||
peek = drain_fleet_events(advance=False)
|
||||
```
|
||||
|
||||
Tambien desde la CLI, sin escribir codigo. El drenado canonico consume y avanza
|
||||
el cursor; el peek inspecciona sin moverlo:
|
||||
|
||||
```bash
|
||||
# Drenado canonico: consume los eventos nuevos y avanza el cursor.
|
||||
./fn run drain_fleet_events
|
||||
|
||||
# Peek (no mueve el cursor): inspeccionar la cola sin consumirla.
|
||||
./fn run drain_fleet_events --advance false
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala en el orquestador-Claude de flota cada vez que despierta y necesita ver
|
||||
@@ -80,3 +93,19 @@ cola sin consumirla.
|
||||
- **Fallo al persistir el cursor**: si `advance=True` pero no se puede escribir
|
||||
el cursor (permisos, FS), el drain sigue siendo valido pero la proxima llamada
|
||||
reprocesara estos eventos (at-least-once, no at-most-once).
|
||||
- **Peek por `fn run`**: `fn run` mapea los argumentos POSICIONALMENTE y no
|
||||
parsea flags `--nombre valor`. Para que el peek documentado
|
||||
`./fn run drain_fleet_events --advance false` funcione, la funcion renormaliza
|
||||
ese patron internamente (`_normalize_fn_run_flags`): detecta `--advance` como
|
||||
primer posicional y lo interpreta como el booleano `advance`, devolviendo las
|
||||
rutas a su default. Una llamada normal por kwargs no se ve afectada. Acepta
|
||||
`--advance false|true|0|1|no|yes`; `--advance` sin valor equivale a `true`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-21) — el peek `./fn run drain_fleet_events --advance false`
|
||||
ahora funciona directo desde la CLI. Antes `fn run` colaba el flag como
|
||||
`events_path="--advance"` (ruta inexistente) y devolvia un falso
|
||||
`{total_new:0, cursor:0}`. Se añade `_normalize_fn_run_flags` para renormalizar
|
||||
el patron sin tocar el runner de Go. Elimina el gotcha de invocacion que
|
||||
silenciaba la cola al orquestador (reports 0011/0012).
|
||||
|
||||
@@ -39,6 +39,13 @@ def drain_fleet_events(
|
||||
numero de lineas de la cola (cola truncada/rotada): se reinicio
|
||||
desde 0.
|
||||
"""
|
||||
# `fn run` aplana los argumentos posicionalmente y NO parsea flags
|
||||
# `--nombre valor`. Renormalizar el caso del peek documentado antes de
|
||||
# resolver rutas (ver _normalize_fn_run_flags).
|
||||
events_path, cursor_path, advance = _normalize_fn_run_flags(
|
||||
events_path, cursor_path, advance
|
||||
)
|
||||
|
||||
home = os.path.expanduser("~")
|
||||
if events_path is None:
|
||||
events_path = os.path.join(home, ".claude", "fleet", "events.jsonl")
|
||||
@@ -127,3 +134,40 @@ def drain_fleet_events(
|
||||
if reset:
|
||||
result["reset"] = True
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_fn_run_flags(
|
||||
events_path: str | None,
|
||||
cursor_path: str | None,
|
||||
advance: bool,
|
||||
) -> tuple:
|
||||
"""Renormaliza el peek `./fn run drain_fleet_events --advance false`.
|
||||
|
||||
`fn run` mapea los argumentos de la linea de comandos POSICIONALMENTE a los
|
||||
parametros de la funcion y NO entiende flags `--nombre valor`. Por eso
|
||||
invocar `./fn run drain_fleet_events --advance false` asigna
|
||||
events_path="--advance" y cursor_path="false", lo que rompia el peek
|
||||
(events_path apuntaba a una ruta inexistente -> drain vacio con cursor 0).
|
||||
|
||||
Cuando el primer posicional es el flag `--advance`, este helper lo
|
||||
interpreta: el segundo posicional (cursor_path) es su valor booleano y las
|
||||
dos rutas vuelven a su default (None). `--advance false` -> advance=False;
|
||||
`--advance true` o `--advance` sin valor -> advance=True. Una llamada normal
|
||||
por kwargs (events_path/cursor_path reales) pasa sin cambios.
|
||||
|
||||
Returns:
|
||||
tupla (events_path, cursor_path, advance) ya normalizada.
|
||||
"""
|
||||
if (
|
||||
isinstance(events_path, str)
|
||||
and events_path.lstrip("-").replace("-", "_") == "advance"
|
||||
):
|
||||
if cursor_path is None:
|
||||
# `--advance` sin valor: presencia del flag => True (store_true).
|
||||
advance = True
|
||||
else:
|
||||
value = str(cursor_path).strip().lower()
|
||||
advance = value not in ("false", "0", "no", "off", "")
|
||||
events_path = None
|
||||
cursor_path = None
|
||||
return events_path, cursor_path, advance
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import json
|
||||
|
||||
from drain_fleet_events import drain_fleet_events
|
||||
from drain_fleet_events import _normalize_fn_run_flags, drain_fleet_events
|
||||
|
||||
|
||||
def _write_queue(path, events):
|
||||
@@ -132,6 +132,57 @@ def test_agrupacion_por_to_y_urgent(tmp_path):
|
||||
assert urgent_sessions == {"a", "d"}
|
||||
|
||||
|
||||
def test_normaliza_flag_advance_false_posicional():
|
||||
# `fn run drain_fleet_events --advance false` aplana a estos posicionales.
|
||||
ev, cur, adv = _normalize_fn_run_flags("--advance", "false", True)
|
||||
assert (ev, cur, adv) == (None, None, False)
|
||||
|
||||
|
||||
def test_normaliza_flag_advance_true_posicional():
|
||||
ev, cur, adv = _normalize_fn_run_flags("--advance", "true", True)
|
||||
assert (ev, cur, adv) == (None, None, True)
|
||||
|
||||
|
||||
def test_normaliza_flag_advance_presencia_sin_valor():
|
||||
# `--advance` solo (sin valor): presencia => True.
|
||||
ev, cur, adv = _normalize_fn_run_flags("--advance", None, True)
|
||||
assert (ev, cur, adv) == (None, None, True)
|
||||
|
||||
|
||||
def test_normaliza_flag_advance_cero_es_false():
|
||||
ev, cur, adv = _normalize_fn_run_flags("--advance", "0", True)
|
||||
assert adv is False
|
||||
|
||||
|
||||
def test_no_toca_llamada_normal_por_kwargs():
|
||||
# Una llamada normal con rutas reales no se altera.
|
||||
ev, cur, adv = _normalize_fn_run_flags("/tmp/q.jsonl", "/tmp/cursor", False)
|
||||
assert (ev, cur, adv) == ("/tmp/q.jsonl", "/tmp/cursor", False)
|
||||
ev2, cur2, adv2 = _normalize_fn_run_flags(None, None, True)
|
||||
assert (ev2, cur2, adv2) == (None, None, True)
|
||||
|
||||
|
||||
def test_peek_flag_posicional_no_mueve_cursor(tmp_path):
|
||||
# End-to-end del patron `--advance false` sobre una cola controlada: como el
|
||||
# normalizador descarta las rutas, apuntamos via env HOME a tmp_path.
|
||||
fleet_dir = tmp_path / ".claude" / "fleet"
|
||||
fleet_dir.mkdir(parents=True)
|
||||
_write_queue(fleet_dir / "events.jsonl", [_make_event("RECLAMA")])
|
||||
import os as _os
|
||||
|
||||
prev_home = _os.environ.get("HOME")
|
||||
_os.environ["HOME"] = str(tmp_path)
|
||||
try:
|
||||
# Simula exactamente lo que pasa el runner de `fn run`.
|
||||
result = drain_fleet_events("--advance", "false")
|
||||
finally:
|
||||
if prev_home is not None:
|
||||
_os.environ["HOME"] = prev_home
|
||||
assert result["total_new"] == 1
|
||||
# advance=False => no se persiste cursor.
|
||||
assert not (fleet_dir / "cursor").exists()
|
||||
|
||||
|
||||
def test_cursor_mayor_que_lineas_reinicia(tmp_path):
|
||||
events_path = tmp_path / "events.jsonl"
|
||||
cursor_path = tmp_path / "cursor"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: summarize_fleet_transitions
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def summarize_fleet_transitions(by_classification: dict, max_items: int = 6) -> str"
|
||||
description: "Resume en una sola linea las transiciones accionables de la flota de Claudes para inyectar por un hook UserPromptSubmit al orquestador. Toma el dict by_classification de drain_fleet_events (agrupado por campo `to`) y condensa SOLO las tres categorias accionables: DICE_TERMINADO (terminados), RECLAMA (reclaman) y ESTANCADO (estancados); ignora TRABAJANDO, GONE y MAL_LANZADO. Funcion pura, determinista y defensiva: deduplica por session_id, trunca el goal y limita el total con overflow `(+N mas)`."
|
||||
tags: [orchestration, claude-fleet, fleet, summary, hook, orchestrator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: by_classification
|
||||
desc: "dict agrupado por campo `to` tal como lo devuelve drain_fleet_events. Solo se leen las claves accionables DICE_TERMINADO, RECLAMA y ESTANCADO; cada valor es una lista de eventos (dicts) con al menos session_id (str UUID), goal (str) y to (str). Puede ser None o {} (devuelve 'sin cambios')."
|
||||
- name: max_items
|
||||
desc: "limite total de eventos mostrados sumando las tres categorias (default 6). Al superarse se trunca y se añade ' (+N mas)'. El cupo se llena en orden de categorias terminados -> reclaman -> estancados."
|
||||
output: "una cadena de UNA sola linea. 'FLEET-STATE: sin cambios' si no hay nada accionable; en caso contrario 'FLEET-STATE: terminados=[<sid8>:<goal>] reclaman=[...] estancados=[...] (+N mas) (drain con ./fn run drain_fleet_events para consumir)' omitiendo las categorias vacias."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_tres_categorias_con_datos"
|
||||
- "test_categorias_vacias_sin_cambios"
|
||||
- "test_solo_no_accionables_sin_cambios"
|
||||
- "test_dedup_por_session_id_se_queda_ultima"
|
||||
- "test_truncado_de_goal_mayor_de_40"
|
||||
- "test_max_items_con_overflow"
|
||||
- "test_entrada_none_sin_cambios"
|
||||
- "test_entrada_dict_vacio_sin_cambios"
|
||||
- "test_valor_no_lista_se_salta"
|
||||
- "test_eventos_no_dict_se_saltan"
|
||||
- "test_session_id_ausente_usa_placeholder"
|
||||
- "test_goal_ausente_usa_placeholder"
|
||||
- "test_omite_categorias_vacias_solo_renderiza_con_datos"
|
||||
- "test_varios_eventos_misma_categoria_separados_por_coma"
|
||||
test_file_path: "python/functions/infra/summarize_fleet_transitions_test.py"
|
||||
file_path: "python/functions/infra/summarize_fleet_transitions.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from summarize_fleet_transitions import summarize_fleet_transitions
|
||||
|
||||
by_classification = {
|
||||
"DICE_TERMINADO": [
|
||||
{"session_id": "a1b2c3d4-aaaa-bbbb", "goal": "Migrar tabla salesforce a europe-west1", "to": "DICE_TERMINADO"},
|
||||
],
|
||||
"RECLAMA": [
|
||||
{"session_id": "e5f6g7h8-cccc-dddd", "goal": "Necesito clave API de Metabase", "to": "RECLAMA", "urgent": True},
|
||||
],
|
||||
"ESTANCADO": [],
|
||||
"TRABAJANDO": [
|
||||
{"session_id": "99887766-eeee-ffff", "goal": "Indexando registry", "to": "TRABAJANDO"},
|
||||
],
|
||||
}
|
||||
|
||||
linea = summarize_fleet_transitions(by_classification)
|
||||
# FLEET-STATE: terminados=[a1b2c3d4:Migrar tabla salesforce a europe-w…] reclaman=[e5f6g7h8:Necesito clave API de Metabase] (drain con ./fn run drain_fleet_events para consumir)
|
||||
print(linea)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando un hook (tipico `UserPromptSubmit`) necesita resumir en una sola linea las
|
||||
transiciones pendientes de la flota para inyectarlas al orquestador-Claude. Se
|
||||
encadena despues de `drain_fleet_events` (que produce el `by_classification`) para
|
||||
darle al orquestador, sin coste de lectura extra, el estado accionable de un
|
||||
vistazo: quien dice haber terminado, quien reclama atencion y quien esta estancado.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Dedup por session_id**: dentro de cada categoria, si un mismo `session_id`
|
||||
aparece en varios eventos se conserva solo la ultima aparicion (la transicion
|
||||
mas reciente en la lista). Eventos sin `session_id` valido no se deduplican
|
||||
entre si y cada uno cuenta como entrada propia.
|
||||
- **Truncado de goal**: el `goal` se recorta a 40 caracteres con elipsis `…`; un
|
||||
`goal` ausente o vacio se muestra como `(sin objetivo)` y un `session_id`
|
||||
ausente/vacio como `????????`.
|
||||
- **Solo categorias accionables**: TRABAJANDO, GONE y MAL_LANZADO se ignoran a
|
||||
proposito. Si las tres categorias accionables estan vacias o no existen,
|
||||
devuelve `FLEET-STATE: sin cambios`.
|
||||
- **Reparto de max_items**: el cupo total se llena recorriendo las categorias en
|
||||
orden (terminados -> reclaman -> estancados); los sobrantes se cuentan en
|
||||
`(+N mas)`. Es defensiva ante datos mal formados (valores no-lista o eventos
|
||||
no-dict se omiten sin lanzar excepcion).
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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)"
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests para summarize_fleet_transitions."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from summarize_fleet_transitions import summarize_fleet_transitions
|
||||
|
||||
DRAIN = "(drain con ./fn run drain_fleet_events para consumir)"
|
||||
|
||||
|
||||
def test_golden_tres_categorias_con_datos():
|
||||
by = {
|
||||
"DICE_TERMINADO": [{"session_id": "a1b2c3d4ee", "goal": "Goal terminado", "to": "DICE_TERMINADO"}],
|
||||
"RECLAMA": [{"session_id": "e5f6g7h8ii", "goal": "Goal reclama", "to": "RECLAMA"}],
|
||||
"ESTANCADO": [{"session_id": "99887766zz", "goal": "Goal estanca", "to": "ESTANCADO"}],
|
||||
}
|
||||
out = summarize_fleet_transitions(by)
|
||||
assert out == (
|
||||
"FLEET-STATE: terminados=[a1b2c3d4:Goal terminado] "
|
||||
"reclaman=[e5f6g7h8:Goal reclama] "
|
||||
"estancados=[99887766:Goal estanca] " + DRAIN
|
||||
)
|
||||
|
||||
|
||||
def test_categorias_vacias_sin_cambios():
|
||||
by = {"DICE_TERMINADO": [], "RECLAMA": [], "ESTANCADO": []}
|
||||
assert summarize_fleet_transitions(by) == "FLEET-STATE: sin cambios"
|
||||
|
||||
|
||||
def test_solo_no_accionables_sin_cambios():
|
||||
by = {
|
||||
"TRABAJANDO": [{"session_id": "aaaaaaaa11", "goal": "trabajando", "to": "TRABAJANDO"}],
|
||||
"GONE": [{"session_id": "bbbbbbbb22", "goal": "gone", "to": "GONE"}],
|
||||
"MAL_LANZADO": [{"session_id": "cccccccc33", "goal": "mal", "to": "MAL_LANZADO"}],
|
||||
}
|
||||
assert summarize_fleet_transitions(by) == "FLEET-STATE: sin cambios"
|
||||
|
||||
|
||||
def test_dedup_por_session_id_se_queda_ultima():
|
||||
by = {
|
||||
"RECLAMA": [
|
||||
{"session_id": "dup00000xx", "goal": "primera version", "to": "RECLAMA"},
|
||||
{"session_id": "dup00000xx", "goal": "segunda version", "to": "RECLAMA"},
|
||||
],
|
||||
}
|
||||
out = summarize_fleet_transitions(by)
|
||||
assert out == "FLEET-STATE: reclaman=[dup00000:segunda version] " + DRAIN
|
||||
assert "primera version" not in out
|
||||
|
||||
|
||||
def test_truncado_de_goal_mayor_de_40():
|
||||
goal = "X" * 50
|
||||
by = {"DICE_TERMINADO": [{"session_id": "trunc000yy", "goal": goal, "to": "DICE_TERMINADO"}]}
|
||||
out = summarize_fleet_transitions(by)
|
||||
expected_goal = "X" * 40 + "…"
|
||||
assert f"terminados=[trunc000:{expected_goal}]" in out
|
||||
# El goal mostrado tiene exactamente 40 chars + elipsis.
|
||||
assert ("X" * 41) not in out
|
||||
|
||||
|
||||
def test_max_items_con_overflow():
|
||||
by = {
|
||||
"DICE_TERMINADO": [
|
||||
{"session_id": f"term{i:06d}", "goal": f"t{i}", "to": "DICE_TERMINADO"} for i in range(4)
|
||||
],
|
||||
"RECLAMA": [
|
||||
{"session_id": f"recl{i:06d}", "goal": f"r{i}", "to": "RECLAMA"} for i in range(4)
|
||||
],
|
||||
}
|
||||
out = summarize_fleet_transitions(by, max_items=3)
|
||||
# 8 disponibles, 3 mostrados -> +5 mas. Se llenan en orden terminados primero.
|
||||
assert "(+5 mas)" in out
|
||||
assert out.count(":") >= 3 # al menos los 3 items renderizados
|
||||
# Los 3 primeros mostrados son de terminados (orden de categorias).
|
||||
# session_id truncado a 8 chars: "term000000" -> "term0000".
|
||||
assert "terminados=[term0000:t0, term0000:t1, term0000:t2]" in out
|
||||
assert "reclaman=" not in out
|
||||
|
||||
|
||||
def test_entrada_none_sin_cambios():
|
||||
assert summarize_fleet_transitions(None) == "FLEET-STATE: sin cambios"
|
||||
|
||||
|
||||
def test_entrada_dict_vacio_sin_cambios():
|
||||
assert summarize_fleet_transitions({}) == "FLEET-STATE: sin cambios"
|
||||
|
||||
|
||||
def test_valor_no_lista_se_salta():
|
||||
by = {
|
||||
"DICE_TERMINADO": "no soy una lista",
|
||||
"RECLAMA": [{"session_id": "ok000000aa", "goal": "valido", "to": "RECLAMA"}],
|
||||
}
|
||||
out = summarize_fleet_transitions(by)
|
||||
assert out == "FLEET-STATE: reclaman=[ok000000:valido] " + DRAIN
|
||||
|
||||
|
||||
def test_eventos_no_dict_se_saltan():
|
||||
by = {
|
||||
"ESTANCADO": [
|
||||
"string suelto",
|
||||
42,
|
||||
None,
|
||||
{"session_id": "good0000bb", "goal": "evento bueno", "to": "ESTANCADO"},
|
||||
],
|
||||
}
|
||||
out = summarize_fleet_transitions(by)
|
||||
assert out == "FLEET-STATE: estancados=[good0000:evento bueno] " + DRAIN
|
||||
|
||||
|
||||
def test_session_id_ausente_usa_placeholder():
|
||||
by = {"RECLAMA": [{"goal": "sin sid", "to": "RECLAMA"}]}
|
||||
out = summarize_fleet_transitions(by)
|
||||
assert out == "FLEET-STATE: reclaman=[????????:sin sid] " + DRAIN
|
||||
|
||||
|
||||
def test_goal_ausente_usa_placeholder():
|
||||
by = {"DICE_TERMINADO": [{"session_id": "nogoal00cc", "to": "DICE_TERMINADO"}]}
|
||||
out = summarize_fleet_transitions(by)
|
||||
assert out == "FLEET-STATE: terminados=[nogoal00:(sin objetivo)] " + DRAIN
|
||||
|
||||
|
||||
def test_omite_categorias_vacias_solo_renderiza_con_datos():
|
||||
by = {
|
||||
"DICE_TERMINADO": [],
|
||||
"RECLAMA": [{"session_id": "only0000dd", "goal": "solo reclama", "to": "RECLAMA"}],
|
||||
"ESTANCADO": [],
|
||||
}
|
||||
out = summarize_fleet_transitions(by)
|
||||
assert out == "FLEET-STATE: reclaman=[only0000:solo reclama] " + DRAIN
|
||||
assert "terminados=" not in out
|
||||
assert "estancados=" not in out
|
||||
|
||||
|
||||
def test_varios_eventos_misma_categoria_separados_por_coma():
|
||||
by = {
|
||||
"ESTANCADO": [
|
||||
{"session_id": "aaaaaaaa11", "goal": "uno", "to": "ESTANCADO"},
|
||||
{"session_id": "bbbbbbbb22", "goal": "dos", "to": "ESTANCADO"},
|
||||
],
|
||||
}
|
||||
out = summarize_fleet_transitions(by)
|
||||
assert out == "FLEET-STATE: estancados=[aaaaaaaa:uno, bbbbbbbb:dos] " + DRAIN
|
||||
Reference in New Issue
Block a user