Files
fn_registry/python/functions/infra/drain_fleet_events.py
T
agent 118d5d36d3 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>
2026-06-21 00:06:01 +02:00

174 lines
6.2 KiB
Python

"""Drena la cola JSONL de eventos de la flota desde un cursor persistente.
El watcher embebido en fleetview escribe transiciones de estado de la flota a
una cola JSONL append-only. El orquestador-Claude se despierta periodicamente y
consume los eventos NUEVOS desde la ultima vez (sin reprocesar los ya vistos)
agrupados por clasificacion para decidir.
"""
import json
import os
def drain_fleet_events(
events_path: str | None = None,
cursor_path: str | None = None,
advance: bool = True,
) -> dict:
"""Lee los eventos nuevos de la cola de la flota desde un cursor persistente.
El cursor es el numero de lineas ya procesadas (entero en texto plano).
Se leen las lineas desde la posicion del cursor hasta el final, se parsean
como JSON (saltando lineas en blanco o invalidas), se agrupan por el campo
`to` y se aisla la lista de eventos urgentes.
Args:
events_path: ruta a la cola JSONL. Default: ~/.claude/fleet/events.jsonl.
cursor_path: ruta al archivo de cursor. Default: ~/.claude/fleet/cursor.
advance: si True, escribe el nuevo cursor (= total de lineas) para no
reprocesar. Si False, no mueve el cursor (peek/inspeccion).
Returns:
dict con:
- total_new: numero de eventos nuevos validos parseados.
- events: lista de los eventos nuevos parseados, en orden.
- by_classification: dict que agrupa los eventos por su campo `to`.
- urgent: lista de eventos nuevos con urgent == True.
- cursor: nueva posicion del cursor (total de lineas de la cola).
- reset: presente y True solo si el cursor previo era mayor que el
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")
if cursor_path is None:
cursor_path = os.path.join(home, ".claude", "fleet", "cursor")
# Cola ausente -> drain vacio sin crash.
if not os.path.exists(events_path):
return {
"total_new": 0,
"events": [],
"by_classification": {},
"urgent": [],
"cursor": 0,
}
# Leer el cursor previo (0 si no existe o es ilegible).
prev_cursor = 0
if os.path.exists(cursor_path):
try:
with open(cursor_path, "r", encoding="utf-8") as fh:
prev_cursor = int(fh.read().strip() or "0")
except (ValueError, OSError):
prev_cursor = 0
if prev_cursor < 0:
prev_cursor = 0
# Leer todas las lineas de la cola.
with open(events_path, "r", encoding="utf-8") as fh:
lines = fh.readlines()
total_lines = len(lines)
# Cursor mayor que el numero de lineas (cola truncada/rotada) -> reinicia.
reset = False
if prev_cursor > total_lines:
prev_cursor = 0
reset = True
new_lines = lines[prev_cursor:]
events: list[dict] = []
by_classification: dict[str, list[dict]] = {}
urgent: list[dict] = []
for raw in new_lines:
stripped = raw.strip()
if not stripped:
# Linea en blanco: saltar sin romper el drain.
continue
try:
event = json.loads(stripped)
except (json.JSONDecodeError, ValueError):
# JSON invalido: saltar sin romper el drain.
continue
if not isinstance(event, dict):
# No es un objeto JSON: saltar.
continue
events.append(event)
classification = event.get("to")
if classification is not None:
by_classification.setdefault(classification, []).append(event)
if event.get("urgent") is True:
urgent.append(event)
# Avanzar el cursor al total de lineas si advance=True.
if advance:
try:
os.makedirs(os.path.dirname(cursor_path) or ".", exist_ok=True)
with open(cursor_path, "w", encoding="utf-8") as fh:
fh.write(str(total_lines))
except OSError:
# No se pudo persistir el cursor: el drain sigue siendo valido,
# pero la proxima llamada reprocesara estos eventos.
pass
result = {
"total_new": len(events),
"events": events,
"by_classification": by_classification,
"urgent": urgent,
"cursor": total_lines,
}
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