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:
agent
2026-06-21 00:06:01 +02:00
parent b410328cec
commit 118d5d36d3
7 changed files with 548 additions and 2 deletions
@@ -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