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:
@@ -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)"
|
||||
Reference in New Issue
Block a user