feat: cerebro reactivo del meta-orquestador (flow 0012, fase 2)
Primitivas (python/functions/infra): - drain_fleet_events: consume la cola del watcher (~/.claude/fleet/ events.jsonl) desde un cursor, agrupa por clasificacion, marca urgentes. 7 tests. - set_dod_contract: escribe el DoD-contrato fijo (dod_contract/dod_status) en el goal.json de un agente sin pisar el resto (escritura atomica). 5 tests. Skill /orquestador evolucionado (sin romper lo existente): vigila la flota por su DoD (no por 'esta vivo'). Nueva seccion 'Consumo de la cola de la flota': DoD-contrato obligatorio al lanzar, drenar la cola, politicas por clasificacion (RECLAMA escala / DICE_TERMINADO verifica / ESTANCADO nudge / MAL_LANZADO re-DoD), verificador independiente del ejecutor (lee el report vs dod_contract), splitter con tope de fan-out, y cadencia (drain al actuar + heartbeat). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: orquestador
|
||||
description: "Modo orquestador: el Claude principal NO hace el trabajo pesado — descompone la tarea y lanza Claudes SECUNDARIOS interactivos, cada uno en su propia terminal kitty con un prompt autonomo y aislamiento git impuesto. El humano habla solo con el orquestador, ve a los secundarios en sus kitties y puede saltar a cualquiera. El orquestador sigue la flota, lee sus reports e integra. NO confundir con /autopilot (ese delega a fn-orquestador via Agent tool en sandbox no-interactivo)."
|
||||
description: "Modo orquestador: el Claude principal NO hace el trabajo pesado — descompone la tarea y lanza Claudes SECUNDARIOS interactivos, cada uno en su propia terminal con un prompt autonomo, aislamiento git impuesto y un DoD-contrato fijo. El humano habla solo con el orquestador, ve a los secundarios y puede saltar a cualquiera. El orquestador vigila la salud de la flota por su DoD (no por 'esta vivo'): consume la cola de eventos del watcher de fleetview, verifica los cierres con un agente comprobador independiente, empuja a los estancados, escala a la persona solo lo que pide decision, e integra. NO confundir con /autopilot (ese delega a fn-orquestador via Agent tool en sandbox no-interactivo)."
|
||||
---
|
||||
|
||||
# /orquestador — coordinar Claudes secundarios interactivos en kitty
|
||||
@@ -118,6 +118,11 @@ el prompt debe ser **autocontenido**. Incluye SIEMPRE:
|
||||
`delegation.md`).
|
||||
6. **La coletilla**: *"reporta tu progreso en esta terminal"* — para que el humano que mire la
|
||||
kitty vea el estado sin abrir el report.
|
||||
7. **DoD-contrato** — el criterio de aceptación **fijo y verificable** del secundario (golden +
|
||||
edge + error path con evidencia ejecutable, `dod_quality.md`), redactado por ti. Va en el
|
||||
prompt Y se escribe en el `goal.json` del secundario con `set_dod_contract` en cuanto conozcas
|
||||
su `sessionId` (paso 5). Es el blanco estable contra el que el verificador juzgará el cierre.
|
||||
Sin `dod_contract`, el agente se clasifica `MAL_LANZADO`. Ver "Consumo de la cola de la flota".
|
||||
|
||||
Mira `/tmp/unibus_agent_*.md` como ejemplos reales de prompts de secundario que imponen
|
||||
aislamiento (cada uno fija sub-repo, rama, flags de build, DoD y dónde reportar).
|
||||
@@ -180,6 +185,85 @@ Cuando un secundario termina (rama pusheada + report verde):
|
||||
Regla práctica: si el humano va a querer hablar con ello o mirarlo trabajar → kitty. Si es una
|
||||
sub-tarea que devuelve un resultado y se acabó → Agent tool.
|
||||
|
||||
## Consumo de la cola de la flota — el cerebro reactivo (flow 0012)
|
||||
|
||||
Seguir la flota (paso 5) no es solo "¿quién vive?". Es **vigilar la salud por el DoD**: cada agente termina lo que empieza, o sabes por qué no. La métrica es el **throughput de DoD cumplidos**, no el número de agentes vivos — 30 agentes que no cierran nada no sirven. La fuente es la cola del **watcher embebido en fleetview** (`~/.claude/fleet/events.jsonl`): una línea por **transición** de estado de un agente (edge-triggered, sin ruido de nivel). El orquestador la drena cada vez que actúa y aplica una política por clasificación.
|
||||
|
||||
### DoD-contrato fijo al lanzar (regla dura)
|
||||
|
||||
Ningún secundario arranca sin **DoD-contrato**: el criterio de aceptación FIJO contra el que se evalúa su terminación. Es distinto del campo `dod` (resumen móvil que el hook GOAL-TRACKER reescribe con cada prompt). Tras lanzar y conocer el `sessionId`:
|
||||
|
||||
```bash
|
||||
./fn run set_dod_contract <sessionId> "Golden: <caso feliz+evidencia>. Edge: <2 bordes>. Error: <1 fallo manejado>." pending
|
||||
```
|
||||
|
||||
El contrato sigue `dod_quality.md` (golden + edge + error con evidencia ejecutable), no un checkbox vago. Sin él, el agente es `MAL_LANZADO`.
|
||||
|
||||
### Drenar la cola
|
||||
|
||||
```bash
|
||||
./fn run drain_fleet_events # consume nuevos (avanza cursor), agrupa por clasificación, marca urgentes
|
||||
./fn run drain_fleet_events --advance false # peek sin consumir (inspección)
|
||||
```
|
||||
|
||||
Devuelve `{total_new, events, by_classification, urgent, cursor}`. La clasificación de cada agente la produce `classify_fleet_termination` (pura) desde su estado (status + phase + dod_contract + dod_status + segundos ociosos).
|
||||
|
||||
### Políticas por clasificación
|
||||
|
||||
| Transición a… | Qué hace el orquestador |
|
||||
|---|---|
|
||||
| `RECLAMA` (urgent) | **Escalar a la persona**: resumen corto de QUÉ decisión se necesita + `/fleet focus <sid>` para llevarla al agente. Si no está presente, `PushNotification`. NUNCA decidir tú por ella en un RECLAMA. |
|
||||
| `DICE_TERMINADO` | Lanzar **verificador independiente** (abajo). No confiar en el autodeclarado. |
|
||||
| `ESTANCADO` | **Nudge** al agente (abajo). Solo idle; jamás waiting. |
|
||||
| `MAL_LANZADO` | Escribir `dod_contract` retroactivo (`set_dod_contract`) o re-lanzar con DoD. |
|
||||
| `TRABAJANDO` | No molestar. |
|
||||
| `GONE` | Limpiar de la tabla de seguimiento (terminó o murió; si tenía DoD sin cumplir, anótalo). |
|
||||
|
||||
### Verificador — cierre de `DICE_TERMINADO` (cero auto-aprobación)
|
||||
|
||||
Cuando un agente se autodeclara terminado, **no se confía**: lanzas un **verificador independiente** del ejecutor (Agent efímero), que compara el **report** del ejecutor (en `reports/`, con evidencia ejecutable) contra su `dod_contract`:
|
||||
|
||||
```
|
||||
Agent(subagent_type="general-purpose", prompt:
|
||||
"Verifica de forma ADVERSARIAL si el trabajo cumple su DoD-contrato. NO ejecutaste tú la tarea.
|
||||
DoD-contrato: <contract>
|
||||
Report del ejecutor: <ruta del reports/NNNN-*.md>
|
||||
Comprueba CADA cláusula (golden + edge + error) contra la evidencia citada en el report; re-ejecuta
|
||||
los comandos de verificación si puedes. Devuelve {verdict: met|failed, gaps: [...], evidence: [...]}.
|
||||
Por defecto failed si la evidencia no respalda una cláusula.")
|
||||
```
|
||||
|
||||
- `met` → el orquestador **cierra/reasigna** el agente y lo informa a la persona. Marca `set_dod_contract <sid> "<contract>" met`.
|
||||
- `failed` → **nudge** al ejecutor con el gap concreto (no cerrar). `set_dod_contract <sid> "<contract>" failed` (vuelve a pending tras el nudge si reabre trabajo).
|
||||
|
||||
### Nudge — `ESTANCADO`
|
||||
|
||||
Agente idle con `dod_contract` sin cumplir y sin actividad > umbral (10 min). Empújalo a cerrar SU DoD inyectando en su pane tmux:
|
||||
|
||||
```bash
|
||||
tmux -L "${FLEET_SOCKET:-fleet}" send-keys -t <window_id> \
|
||||
"Sigues idle con tu DoD-contrato sin cerrar. Falta: <gap>. Cierra el golden+edge+error con evidencia, o reporta el bloqueo concreto." Enter
|
||||
```
|
||||
|
||||
El `window_id` lo da `list_claude_fleet`/`fleetview list --json`. **Solo a idle/ESTANCADO. JAMÁS a un agente en `waiting`/`preguntando`** — esos te reclaman a TI, no un empujón del bot.
|
||||
|
||||
### Splitter — tarea demasiado grande
|
||||
|
||||
Si una sub-tarea sigue siendo grande para un solo agente, antes de lanzarla pásala por un **splitter** (Agent efímero) que devuelve un plan de sub-tareas atómicas, cada una con su `dod_contract` y sus dependencias:
|
||||
|
||||
```
|
||||
Agent(subagent_type="Plan", prompt:
|
||||
"Descompón esta tarea en sub-tareas ATÓMICAS, cada una cerrable por UN agente en una sesión, con
|
||||
su propio DoD-contrato (golden+edge+error) y dependencias (cuáles son paralelas y cuáles
|
||||
secuenciales). Máximo 6 sub-tareas. Tarea: <...>. Devuelve [{tarea, dod_contract, deps:[...]}].")
|
||||
```
|
||||
|
||||
El orquestador lanza un ejecutor por sub-tarea respetando las dependencias (paralelas a la vez, secuenciales encadenadas). **Tope de fan-out** para no explotar la flota.
|
||||
|
||||
### Cadencia
|
||||
|
||||
El orquestador no hace polling caro: drena la cola **cuando actúa** (cuando la persona le habla) y, para vigilancia desatendida, con un heartbeat largo (`ScheduleWakeup` 20-30 min) o cuando el watcher empuja un urgente. Lo urgente (`RECLAMA`) sube al instante; el resto (cierres, estancados) se procesa en lote.
|
||||
|
||||
## Reglas duras del modo
|
||||
|
||||
- **El orquestador no hace el trabajo pesado.** Descompone, lanza, sigue, integra. Si te
|
||||
@@ -213,6 +297,10 @@ sub-tarea que devuelve un resultado y se acabó → Agent tool.
|
||||
| `launch_claude_agent_kitty_bash_infra` | Lanzar un secundario en kitty con prompt autónomo + `--dangerously-skip-permissions` |
|
||||
| `list_claude_agents_bash_infra` | Listar la flota de Claudes vivos (PID, sessionId, cwd, status, kitty) para seguirla |
|
||||
| `reboot_all_claudes_bash_infra` | Reiniciar/parar la flota retomando sesiones; `--exclude-current` para no tocarte |
|
||||
| `set_dod_contract_py_infra` | Escribir el DoD-contrato fijo (`dod_contract`/`dod_status`) en el `goal.json` de un secundario al lanzarlo |
|
||||
| `drain_fleet_events_py_infra` | Consumir la cola de transiciones del watcher (`~/.claude/fleet/events.jsonl`), agrupada por clasificación + urgentes |
|
||||
| `classify_fleet_termination_go_infra` | Clasificar el estado de terminación de un agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) — lo usa el watcher |
|
||||
| `list_claude_fleet_go_infra` | Fleet tipado con goal/phase/`dod_contract`/`dod_status`/`role` + window tmux (alimenta `/fleet` y el watcher) |
|
||||
|
||||
## Ejemplo end-to-end
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: drain_fleet_events
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def drain_fleet_events(events_path: str | None = None, cursor_path: str | None = None, advance: bool = True) -> dict"
|
||||
description: "Drena la cola JSONL de eventos de la flota desde un cursor persistente. Lee los eventos nuevos (desde la linea del cursor hasta el final), los parsea saltando lineas en blanco o JSON invalido, los agrupa por su campo `to` (RECLAMA, MAL_LANZADO, DICE_TERMINADO, ESTANCADO, TRABAJANDO, GONE), aisla los urgentes y avanza el cursor para no reprocesar. Pensado para que el orquestador-Claude de flota consuma eventos nuevos cada vez que despierta."
|
||||
tags: [fleet, claude-fleet, jsonl, cursor, queue, drain, watcher, orchestrator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os", "json"]
|
||||
params:
|
||||
- name: events_path
|
||||
desc: "ruta a la cola JSONL append-only de eventos. Default: ~/.claude/fleet/events.jsonl"
|
||||
- name: cursor_path
|
||||
desc: "ruta al archivo de cursor (numero de lineas ya procesadas, entero en texto plano). Default: ~/.claude/fleet/cursor"
|
||||
- name: advance
|
||||
desc: "si True avanza el cursor al total de lineas para no reprocesar; si False hace peek sin mover el cursor (inspeccion)"
|
||||
output: "dict con total_new (int), events (lista de eventos nuevos en orden), by_classification (dict agrupado por campo `to`), urgent (lista de eventos con urgent==True), cursor (nueva posicion) y opcionalmente reset==True si la cola estaba truncada/rotada y el cursor se reinicio a 0"
|
||||
tested: true
|
||||
tests:
|
||||
- "cola con eventos cursor cero devuelve todos y avanza"
|
||||
- "segunda llamada cero nuevos"
|
||||
- "advance false no mueve cursor"
|
||||
- "archivo ausente vacio sin crash"
|
||||
- "json invalido se salta"
|
||||
- "agrupacion por to y urgent"
|
||||
- "cursor mayor que lineas reinicia"
|
||||
test_file_path: "python/functions/infra/drain_fleet_events_test.py"
|
||||
file_path: "python/functions/infra/drain_fleet_events.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from infra.drain_fleet_events import drain_fleet_events
|
||||
|
||||
# El orquestador despierta y consume los eventos nuevos de la flota.
|
||||
drained = drain_fleet_events() # usa ~/.claude/fleet/events.jsonl y ~/.claude/fleet/cursor
|
||||
|
||||
print(f"{drained['total_new']} eventos nuevos")
|
||||
for ev in drained["by_classification"].get("RECLAMA", []):
|
||||
print("RECLAMA:", ev["session_id"], ev["goal"])
|
||||
|
||||
# Eventos urgentes primero.
|
||||
for ev in drained["urgent"]:
|
||||
print("URGENTE:", ev["to"], ev["session_id"])
|
||||
|
||||
# Peek sin avanzar el cursor (no consume): util para inspeccionar antes de actuar.
|
||||
peek = drain_fleet_events(advance=False)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala en el orquestador-Claude de flota cada vez que despierta y necesita ver
|
||||
las transiciones de estado producidas DESDE la ultima vez sin reprocesar las ya
|
||||
vistas. Tambien para diagnostico con `advance=False` cuando quieres mirar la
|
||||
cola sin consumirla.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee la cola JSONL del disco y, con `advance=True`, sobrescribe el
|
||||
archivo de cursor. Pasa `advance=False` para peek puro sin efectos en cursor.
|
||||
- **Cursor por lineas, no por bytes**: el cursor cuenta lineas procesadas. Las
|
||||
lineas en blanco e invalidas tambien avanzan el cursor (se cuentan en el
|
||||
total de lineas) aunque no produzcan eventos, de modo que nunca se re-leen.
|
||||
- **Cola rotada/truncada**: si el cursor guardado es mayor que el numero de
|
||||
lineas actual (la cola se trunco o rota), el drain reinicia desde 0 y marca
|
||||
`reset: True` en el dict de salida — devolvera de nuevo todos los eventos
|
||||
presentes. Diseñado asi para no perder eventos silenciosamente.
|
||||
- **JSON invalido o lineas en blanco**: se saltan sin romper el drain. Solo los
|
||||
objetos JSON validos entran en `events`.
|
||||
- **`by_classification`**: agrupa por el campo `to`. Un evento sin campo `to`
|
||||
(raro) entra en `events` pero no en ningun grupo.
|
||||
- **Fallo al persistir el cursor**: si `advance=True` pero no se puede escribir
|
||||
el cursor (permisos, FS), el drain sigue siendo valido pero la proxima llamada
|
||||
reprocesara estos eventos (at-least-once, no at-most-once).
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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.
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tests para drain_fleet_events."""
|
||||
|
||||
import json
|
||||
|
||||
from drain_fleet_events import 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_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"
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: set_dod_contract
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def set_dod_contract(session_id: str, contract: str, status: str = 'pending', goals_dir: str | None = None) -> dict"
|
||||
description: "Escribe un DoD-contrato fijo y su estado en el sidecar goal.json de una sesion Claude (~/.claude/goals/<session_id>.json). Preserva TODOS los campos existentes (goal, phase, dod, history, prompts) y solo actualiza dod_contract y dod_status. Usado por el meta-orquestador de flota para fijar el criterio de aceptacion estable contra el que se evalua la terminacion de un agente. Escritura atomica via tmp + os.replace."
|
||||
tags: [fleet, claude-fleet, dod, goals, orchestrator, sidecar, json]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os", "json"]
|
||||
params:
|
||||
- name: session_id
|
||||
desc: "ID de la sesion Claude; da nombre al archivo <session_id>.json dentro de goals_dir"
|
||||
- name: contract
|
||||
desc: "criterio de aceptacion estable (DoD-contrato) contra el que se evalua la terminacion del agente; no puede ser vacio"
|
||||
- name: status
|
||||
desc: "estado del contrato; uno de 'pending' | 'met' | 'failed' (default 'pending'); otro valor -> ValueError"
|
||||
- name: goals_dir
|
||||
desc: "directorio de sidecars de goal; default ~/.claude/goals; en tests apuntar a tmp_path para no tocar el real"
|
||||
output: "dict con session_id, path (ruta del goal.json escrito), dod_contract, dod_status y written=True"
|
||||
tested: true
|
||||
tests:
|
||||
- "Escribe contrato en goal.json existente y preserva otros campos"
|
||||
- "Crea el archivo si no existe"
|
||||
- "Status invalido lanza ValueError sin escribir"
|
||||
- "Contract vacio lanza ValueError sin escribir"
|
||||
- "Re-escribir actualiza sin duplicar claves"
|
||||
test_file_path: "python/functions/infra/set_dod_contract_test.py"
|
||||
file_path: "python/functions/infra/set_dod_contract.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from infra.set_dod_contract import set_dod_contract
|
||||
|
||||
# El meta-orquestador fija el criterio de aceptacion al lanzar un agente.
|
||||
res = set_dod_contract(
|
||||
session_id="a1b2c3d4-5678-90ab-cdef-1234567890ab",
|
||||
contract="Los tests de python/functions/infra/ pasan en verde y fn doctor uses-functions no reporta drift",
|
||||
status="pending",
|
||||
)
|
||||
print(res)
|
||||
# {'session_id': 'a1b2c3d4-...', 'path': '/home/enmanuel/.claude/goals/a1b2c3d4-....json',
|
||||
# 'dod_contract': '...', 'dod_status': 'pending', 'written': True}
|
||||
|
||||
# Mas tarde, al evaluar la terminacion: marcar el contrato cumplido (preserva goal/phase/dod).
|
||||
set_dod_contract("a1b2c3d4-5678-90ab-cdef-1234567890ab", res["dod_contract"], status="met")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando el meta-orquestador de flota lance un agente y necesite fijar el DoD-contrato estable, y cada vez que reevalue la terminacion para mover `dod_status` a `met` o `failed`. Es la unica via correcta para tocar `dod_contract`/`dod_status` sin pisar el resto del sidecar goal.json.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El hook GOAL-TRACKER reescribe la clave `dod` del goal.json (el DoD de trabajo, volatil) en cada turno, pero NO debe tocar `dod_contract` ni `dod_status` (el contrato FIJO de aceptacion). Esta funcion solo escribe esas dos claves y preserva `dod` tal cual la dejo el hook.
|
||||
- `status` solo acepta "pending" | "met" | "failed". Cualquier otro valor lanza ValueError ANTES de escribir: el archivo en disco queda intacto.
|
||||
- `contract` vacio o solo whitespace lanza ValueError sin escribir.
|
||||
- Escritura atomica (tmp + os.fsync + os.replace): si el proceso muere a mitad, el goal.json original no se corrompe.
|
||||
- Si el goal.json existente es JSON corrupto o ilegible, se parte de un dict vacio (no se aborta) — se pierde el contenido viejo ilegible pero se preserva el contrato nuevo. Es deliberado: un sidecar ya roto no debe bloquear al orquestador.
|
||||
- `goals_dir` default es `~/.claude/goals` (real). En tests SIEMPRE pasar `goals_dir=str(tmp_path)` para no tocar las sesiones vivas.
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Escribe un DoD-contrato fijo y su estado en el sidecar goal.json de una sesion Claude."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
_VALID_STATUS = ("pending", "met", "failed")
|
||||
|
||||
|
||||
def set_dod_contract(
|
||||
session_id: str,
|
||||
contract: str,
|
||||
status: str = "pending",
|
||||
goals_dir: str | None = None,
|
||||
) -> dict:
|
||||
"""Escribe el DoD-contrato y su estado en el goal.json de una sesion Claude.
|
||||
|
||||
Lee el sidecar `<goals_dir>/<session_id>.json` si existe, preservando
|
||||
TODOS sus campos (goal, phase, dod, history, prompts, etc.), y actualiza
|
||||
solo las claves `dod_contract` y `dod_status`. La escritura es atomica
|
||||
(tmp + os.replace) para no corromper el archivo si el proceso muere a mitad.
|
||||
|
||||
Args:
|
||||
session_id: ID de la sesion Claude. Da nombre al archivo
|
||||
`<session_id>.json` dentro de goals_dir.
|
||||
contract: Criterio de aceptacion estable contra el que se evalua la
|
||||
terminacion del agente. No puede ser vacio.
|
||||
status: Estado del contrato. Uno de "pending", "met" o "failed".
|
||||
goals_dir: Directorio de sidecars de goal. Por defecto
|
||||
`~/.claude/goals`.
|
||||
|
||||
Returns:
|
||||
dict con session_id, path, dod_contract, dod_status y written.
|
||||
|
||||
Raises:
|
||||
ValueError: si contract es vacio o status no es valido. No escribe nada.
|
||||
"""
|
||||
if not contract or not contract.strip():
|
||||
raise ValueError("contract no puede ser vacio: un DoD-contrato vacio no tiene sentido")
|
||||
if status not in _VALID_STATUS:
|
||||
raise ValueError(
|
||||
f"status invalido: {status!r}. Debe ser uno de {_VALID_STATUS}"
|
||||
)
|
||||
|
||||
if goals_dir is None:
|
||||
goals_dir = os.path.join(os.path.expanduser("~"), ".claude", "goals")
|
||||
|
||||
os.makedirs(goals_dir, exist_ok=True)
|
||||
path = os.path.join(goals_dir, f"{session_id}.json")
|
||||
|
||||
data: dict = {}
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
data = loaded
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Archivo corrupto o ilegible: no se pierde lo nuevo, se parte limpio.
|
||||
data = {}
|
||||
|
||||
# Solo se tocan estas dos claves; el resto del dict se preserva intacto.
|
||||
data["dod_contract"] = contract
|
||||
data["dod_status"] = status
|
||||
|
||||
tmp_path = f"{path}.tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"path": path,
|
||||
"dod_contract": contract,
|
||||
"dod_status": status,
|
||||
"written": True,
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests para set_dod_contract."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from .set_dod_contract import set_dod_contract
|
||||
|
||||
|
||||
def _read(path: str) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def test_escribe_contrato_en_goal_json_existente_y_preserva_otros_campos(tmp_path):
|
||||
sid = "sess-existente"
|
||||
path = os.path.join(str(tmp_path), f"{sid}.json")
|
||||
existing = {
|
||||
"goal": "implementar la fase 2",
|
||||
"phase": "build",
|
||||
"dod": "tests verdes (volatil, lo escribe el hook)",
|
||||
"history": [{"turn": 1, "action": "init"}],
|
||||
"prompts": ["primer prompt"],
|
||||
"emojis": "🚀",
|
||||
}
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(existing, f)
|
||||
|
||||
res = set_dod_contract(sid, "criterio fijo de aceptacion", status="pending", goals_dir=str(tmp_path))
|
||||
|
||||
assert res["written"] is True
|
||||
assert res["session_id"] == sid
|
||||
assert res["path"] == path
|
||||
assert res["dod_contract"] == "criterio fijo de aceptacion"
|
||||
assert res["dod_status"] == "pending"
|
||||
|
||||
data = _read(path)
|
||||
# Las dos claves nuevas estan.
|
||||
assert data["dod_contract"] == "criterio fijo de aceptacion"
|
||||
assert data["dod_status"] == "pending"
|
||||
# TODO el resto se preserva intacto.
|
||||
assert data["goal"] == "implementar la fase 2"
|
||||
assert data["phase"] == "build"
|
||||
assert data["dod"] == "tests verdes (volatil, lo escribe el hook)"
|
||||
assert data["history"] == [{"turn": 1, "action": "init"}]
|
||||
assert data["prompts"] == ["primer prompt"]
|
||||
assert data["emojis"] == "🚀"
|
||||
|
||||
|
||||
def test_crea_el_archivo_si_no_existe(tmp_path):
|
||||
sid = "sess-nueva"
|
||||
path = os.path.join(str(tmp_path), f"{sid}.json")
|
||||
assert not os.path.exists(path)
|
||||
|
||||
res = set_dod_contract(sid, "contrato nuevo", goals_dir=str(tmp_path))
|
||||
|
||||
assert os.path.exists(path)
|
||||
assert res["dod_status"] == "pending" # default
|
||||
data = _read(path)
|
||||
assert data == {"dod_contract": "contrato nuevo", "dod_status": "pending"}
|
||||
|
||||
|
||||
def test_status_invalido_lanza_valueerror_sin_escribir(tmp_path):
|
||||
sid = "sess-bad-status"
|
||||
path = os.path.join(str(tmp_path), f"{sid}.json")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
set_dod_contract(sid, "un contrato", status="done", goals_dir=str(tmp_path))
|
||||
|
||||
assert not os.path.exists(path)
|
||||
|
||||
|
||||
def test_contract_vacio_lanza_valueerror_sin_escribir(tmp_path):
|
||||
sid = "sess-empty"
|
||||
path = os.path.join(str(tmp_path), f"{sid}.json")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
set_dod_contract(sid, "", goals_dir=str(tmp_path))
|
||||
with pytest.raises(ValueError):
|
||||
set_dod_contract(sid, " ", goals_dir=str(tmp_path))
|
||||
|
||||
assert not os.path.exists(path)
|
||||
|
||||
|
||||
def test_reescribir_actualiza_sin_duplicar(tmp_path):
|
||||
sid = "sess-reescribe"
|
||||
path = os.path.join(str(tmp_path), f"{sid}.json")
|
||||
|
||||
set_dod_contract(sid, "contrato v1", status="pending", goals_dir=str(tmp_path))
|
||||
set_dod_contract(sid, "contrato v2", status="met", goals_dir=str(tmp_path))
|
||||
|
||||
data = _read(path)
|
||||
assert data["dod_contract"] == "contrato v2"
|
||||
assert data["dod_status"] == "met"
|
||||
# Solo dos claves, ninguna duplicada ni residual.
|
||||
assert sorted(data.keys()) == ["dod_contract", "dod_status"]
|
||||
# El archivo es JSON valido unico (no concatenado).
|
||||
raw = open(path, "r", encoding="utf-8").read()
|
||||
assert raw.count('"dod_contract"') == 1
|
||||
assert raw.count('"dod_status"') == 1
|
||||
Reference in New Issue
Block a user