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>
174 lines
6.2 KiB
Python
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
|