118d5d36d3
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>
121 lines
4.5 KiB
Python
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)"
|