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>
199 lines
6.9 KiB
Python
199 lines
6.9 KiB
Python
"""Tests para drain_fleet_events."""
|
|
|
|
import json
|
|
|
|
from drain_fleet_events import _normalize_fn_run_flags, drain_fleet_events
|
|
|
|
|
|
def _write_queue(path, events):
|
|
"""Escribe una lista de eventos como JSONL en path."""
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
for ev in events:
|
|
fh.write(json.dumps(ev) + "\n")
|
|
|
|
|
|
def _make_event(to, urgent=False, session_id="uuid", from_state="TRABAJANDO"):
|
|
return {
|
|
"ts": 1750445000000,
|
|
"session_id": session_id,
|
|
"pid": 1234,
|
|
"from": from_state,
|
|
"to": to,
|
|
"goal": "objetivo de prueba",
|
|
"phase": "preguntando",
|
|
"urgent": urgent,
|
|
}
|
|
|
|
|
|
def test_cola_con_eventos_cursor_cero_devuelve_todos_y_avanza(tmp_path):
|
|
events_path = tmp_path / "events.jsonl"
|
|
cursor_path = tmp_path / "cursor"
|
|
eventos = [
|
|
_make_event("RECLAMA", urgent=True),
|
|
_make_event("DICE_TERMINADO"),
|
|
_make_event("ESTANCADO"),
|
|
]
|
|
_write_queue(events_path, eventos)
|
|
|
|
result = drain_fleet_events(str(events_path), str(cursor_path))
|
|
|
|
assert result["total_new"] == 3
|
|
assert len(result["events"]) == 3
|
|
assert result["cursor"] == 3
|
|
assert "reset" not in result
|
|
# El cursor se persistio en disco.
|
|
assert cursor_path.read_text().strip() == "3"
|
|
|
|
|
|
def test_segunda_llamada_cero_nuevos(tmp_path):
|
|
events_path = tmp_path / "events.jsonl"
|
|
cursor_path = tmp_path / "cursor"
|
|
_write_queue(events_path, [_make_event("RECLAMA"), _make_event("ESTANCADO")])
|
|
|
|
first = drain_fleet_events(str(events_path), str(cursor_path))
|
|
assert first["total_new"] == 2
|
|
|
|
second = drain_fleet_events(str(events_path), str(cursor_path))
|
|
assert second["total_new"] == 0
|
|
assert second["events"] == []
|
|
assert second["by_classification"] == {}
|
|
assert second["urgent"] == []
|
|
assert second["cursor"] == 2
|
|
|
|
|
|
def test_advance_false_no_mueve_cursor(tmp_path):
|
|
events_path = tmp_path / "events.jsonl"
|
|
cursor_path = tmp_path / "cursor"
|
|
_write_queue(events_path, [_make_event("RECLAMA"), _make_event("GONE")])
|
|
|
|
peek1 = drain_fleet_events(str(events_path), str(cursor_path), advance=False)
|
|
assert peek1["total_new"] == 2
|
|
# No se creo archivo de cursor.
|
|
assert not cursor_path.exists()
|
|
|
|
# Una segunda lectura con peek vuelve a ver los mismos eventos.
|
|
peek2 = drain_fleet_events(str(events_path), str(cursor_path), advance=False)
|
|
assert peek2["total_new"] == 2
|
|
assert not cursor_path.exists()
|
|
|
|
|
|
def test_archivo_ausente_vacio_sin_crash(tmp_path):
|
|
events_path = tmp_path / "no_existe.jsonl"
|
|
cursor_path = tmp_path / "cursor"
|
|
|
|
result = drain_fleet_events(str(events_path), str(cursor_path))
|
|
|
|
assert result["total_new"] == 0
|
|
assert result["events"] == []
|
|
assert result["by_classification"] == {}
|
|
assert result["urgent"] == []
|
|
assert result["cursor"] == 0
|
|
|
|
|
|
def test_json_invalido_se_salta(tmp_path):
|
|
events_path = tmp_path / "events.jsonl"
|
|
cursor_path = tmp_path / "cursor"
|
|
with open(events_path, "w", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(_make_event("RECLAMA")) + "\n")
|
|
fh.write("esto no es json valido {{{\n")
|
|
fh.write("\n") # linea en blanco
|
|
fh.write(" \n") # linea solo espacios
|
|
fh.write(json.dumps(_make_event("ESTANCADO")) + "\n")
|
|
|
|
result = drain_fleet_events(str(events_path), str(cursor_path))
|
|
|
|
# Solo 2 eventos validos, pero el cursor avanza al total de lineas (5).
|
|
assert result["total_new"] == 2
|
|
assert result["cursor"] == 5
|
|
assert [e["to"] for e in result["events"]] == ["RECLAMA", "ESTANCADO"]
|
|
|
|
|
|
def test_agrupacion_por_to_y_urgent(tmp_path):
|
|
events_path = tmp_path / "events.jsonl"
|
|
cursor_path = tmp_path / "cursor"
|
|
eventos = [
|
|
_make_event("RECLAMA", urgent=True, session_id="a"),
|
|
_make_event("RECLAMA", urgent=False, session_id="b"),
|
|
_make_event("DICE_TERMINADO", urgent=False, session_id="c"),
|
|
_make_event("ESTANCADO", urgent=True, session_id="d"),
|
|
]
|
|
_write_queue(events_path, eventos)
|
|
|
|
result = drain_fleet_events(str(events_path), str(cursor_path))
|
|
|
|
bc = result["by_classification"]
|
|
assert set(bc.keys()) == {"RECLAMA", "DICE_TERMINADO", "ESTANCADO"}
|
|
assert len(bc["RECLAMA"]) == 2
|
|
assert len(bc["DICE_TERMINADO"]) == 1
|
|
assert len(bc["ESTANCADO"]) == 1
|
|
|
|
assert len(result["urgent"]) == 2
|
|
urgent_sessions = {e["session_id"] for e in result["urgent"]}
|
|
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"
|
|
# Cola con 2 lineas pero cursor apuntando a 99 (cola rotada/truncada).
|
|
_write_queue(events_path, [_make_event("RECLAMA"), _make_event("GONE")])
|
|
cursor_path.write_text("99")
|
|
|
|
result = drain_fleet_events(str(events_path), str(cursor_path))
|
|
|
|
assert result.get("reset") is True
|
|
assert result["total_new"] == 2
|
|
assert result["cursor"] == 2
|
|
assert cursor_path.read_text().strip() == "2"
|