From 334a71eed16ec70c181d948d478b5d5c8ae2796e Mon Sep 17 00:00:00 2001 From: agent Date: Sat, 20 Jun 2026 20:33:37 +0200 Subject: [PATCH] feat: launcher arranca orquestador + idle, pin por role (flow 0012, fase 3b) - mark_claude_role (python/functions/infra): resuelve PID->sessionId esperando sessions/.json y escribe role en el goal.json sin pisar el resto. 4 tests. - launch_fleetclaude: el pane derecho arranca el ORQUESTADOR con el skill /orquestador embebido como primer prompt; tras arrancar, mark_claude_role le pone role=orchestrator (en background, no-fatal) para que la TUI lo pinee arriba; ademas siembra 1 ejecutor idle inicial en su propia window. - skill /orquestador: regla 'no te vigiles a ti mismo' (ignora en la cola su propia sesion y cualquier role=orchestrator). Validado en vivo (perfil aislado): claude /orquestador entra en modo, role marcado, idle sembrado, pin correcto, fleet2 intacto. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/orquestador.md | 2 + bash/functions/infra/launch_fleetclaude.sh | 30 +++- python/functions/infra/mark_claude_role.md | 89 ++++++++++++ python/functions/infra/mark_claude_role.py | 129 ++++++++++++++++++ .../functions/infra/mark_claude_role_test.py | 119 ++++++++++++++++ 5 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 python/functions/infra/mark_claude_role.md create mode 100644 python/functions/infra/mark_claude_role.py create mode 100644 python/functions/infra/mark_claude_role_test.py diff --git a/.claude/commands/orquestador.md b/.claude/commands/orquestador.md index 4683f1e0..2ac64f50 100644 --- a/.claude/commands/orquestador.md +++ b/.claude/commands/orquestador.md @@ -208,6 +208,8 @@ El contrato sigue `dod_quality.md` (golden + edge + error con evidencia ejecutab 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). +**No te vigiles a ti mismo.** Al procesar la cola, **ignora** los eventos de tu propia sesión y de cualquier agente con `role=orchestrator` (cruza el `session_id` del evento con `list_claude_fleet`). El orquestador no tiene `dod_contract` y aparecería como `MAL_LANZADO` — es ruido, no un ejecutor que vigilar. Solo actúas sobre los **ejecutores** (`role=executor` o sin role). + ### Políticas por clasificación | Transición a… | Qué hace el orquestador | diff --git a/bash/functions/infra/launch_fleetclaude.sh b/bash/functions/infra/launch_fleetclaude.sh index 9d44001d..6c5281a2 100644 --- a/bash/functions/infra/launch_fleetclaude.sh +++ b/bash/functions/infra/launch_fleetclaude.sh @@ -196,15 +196,39 @@ USAGE left_pane=$($T new-session -d -s "$session" -n console -c "$cwd" -P -F '#{pane_id}') $T send-keys -t "$left_pane" "$left_cmd" C-m - # pane derecho = claude, dividiendo horizontalmente (split lado a lado). + # pane derecho = el ORQUESTADOR de la flota: un Claude que arranca ya en + # modo orquestador invocando el skill /orquestador como primer prompt. Es + # el Claude con el que el humano habla; vigila la flota por su DoD. right_pane=$($T split-window -h -t "$left_pane" -c "$cwd" -P -F '#{pane_id}') - $T send-keys -t "$right_pane" "exec claude --dangerously-skip-permissions" C-m + $T send-keys -t "$right_pane" "exec claude --dangerously-skip-permissions '/orquestador'" C-m # Fijar el ancho del pane izquierdo en columnas. $T resize-pane -t "$left_pane" -x "$cols" - # Foco inicial en el pane de claude (derecha). + # Foco inicial en el pane del orquestador (derecha). $T select-pane -t "$right_pane" + + # Marcar el orquestador con role=orchestrator en su goal.json para que la + # TUI lo pinee arriba (estrella). El sessionId no se conoce hasta que + # Claude escribe sessions/.json; mark_claude_role resuelve + # PID->sessionId esperando ese archivo. En background (no bloquea el + # arranque) y con sleep para que el `exec claude` reemplace al shell antes + # de leer el pane_pid. Fallo = no-fatal (el orquestador no se pinea). + if [[ -x "$repo_root/fn" ]]; then + ( sleep 1 + orch_pid=$($T display-message -p -t "$right_pane" '#{pane_pid}' 2>/dev/null || true) + [[ -n "$orch_pid" ]] && "$repo_root/fn" run mark_claude_role "$orch_pid" orchestrator >/dev/null 2>&1 + ) >/dev/null 2>&1 & + disown 2>/dev/null || true + fi + + # Sembrar 1 ejecutor idle: una window detached con un claude normal, + # listo para recibir tarea del orquestador. Aparece en la TUI bajo el + # orquestador (role executor por defecto). Hereda FLEET_SOCKET/SESSION + # del server (set-environment), asi apunta a este perfil. + local idle_pane + idle_pane=$($T new-window -d -t "$session" -n claude -c "$cwd" -P -F '#{pane_id}') + $T send-keys -t "$idle_pane" "exec claude --dangerously-skip-permissions" C-m fi # Si reutilizamos sesion (o por seguridad), derivar el pane ID de la TUI: diff --git a/python/functions/infra/mark_claude_role.md b/python/functions/infra/mark_claude_role.md new file mode 100644 index 00000000..8904e920 --- /dev/null +++ b/python/functions/infra/mark_claude_role.md @@ -0,0 +1,89 @@ +--- +name: mark_claude_role +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def mark_claude_role(pid: int, role: str, wait_s: float = 10.0, sessions_dir: str | None = None, goals_dir: str | None = None) -> dict" +description: "Marca el role (orchestrator | executor) de una sesion de Claude Code resolviendo PID -> sessionId. Sondea ~/.claude/sessions/.json (escrito por Claude Code unos segundos despues de arrancar) hasta wait_s segundos con deadline time.monotonic, extrae el sessionId y escribe SOLO la clave `role` en ~/.claude/goals/.json preservando el resto del goal (goal, phase, dod, dod_contract...). Escritura atomica tmp + os.replace. Si el sessions JSON no aparece a tiempo devuelve ok=False timeout sin lanzar. Pensado para el launcher del meta-orquestador de flota (fleetview) que necesita clasificar el orquestador frente a los executors." +tags: [fleet, claude-fleet, role, session, goal, orchestrator, launcher, pid] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["os", "json", "time"] +params: + - name: pid + desc: "PID del proceso Claude Code recien arrancado. Se usa para localizar ~/.claude/sessions/.json" + - name: role + desc: "role a marcar: 'orchestrator' o 'executor'. Cualquier otro valor lanza ValueError sin tocar disco" + - name: wait_s + desc: "segundos maximos a esperar (sondeo cada ~0.25s) a que aparezca sessions/.json con un sessionId no vacio. Default 10.0" + - name: sessions_dir + desc: "directorio de los sessions JSON. Default ~/.claude/sessions" + - name: goals_dir + desc: "directorio de los goal JSON. Default ~/.claude/goals (se crea si falta)" +output: "dict. En exito: ok=True, pid (int), session_id (str), role (str), path (ruta del goal escrito). En timeout del poll: ok=False, error (str con 'timeout esperando sessions/.json'), pid (int). NO lanza en timeout; el launcher decide" +tested: true +tests: + - "sessions presente resuelve y escribe role preservando otros campos" + - "role invalido lanza ValueError sin escribir" + - "sessions ausente devuelve timeout sin crash" + - "goal inexistente se crea con solo role" +test_file_path: "python/functions/infra/mark_claude_role_test.py" +file_path: "python/functions/infra/mark_claude_role.py" +--- + +## Ejemplo + +```python +import os +from infra.mark_claude_role import mark_claude_role + +# El launcher de flota acaba de arrancar un Claude Code orquestador (subprocess). +proc = launch_claude_orchestrator(...) # devuelve un objeto con .pid + +# Marca su role: espera a que Claude Code escriba ~/.claude/sessions/.json +# (puede tardar unos segundos) y luego pone role=orchestrator en su goal.json. +res = mark_claude_role(proc.pid, "orchestrator", wait_s=15.0) + +if res["ok"]: + print(f"marcado: {res['session_id']} -> {res['role']} en {res['path']}") +else: + # Timeout: el sessions/.json no aparecio a tiempo. El launcher decide + # (reintentar mas tarde, loguear, dejar sin role hasta el proximo barrido). + print("no se pudo marcar:", res["error"]) +``` + +## Cuando usarla + +Usala en el launcher del meta-orquestador de flota justo despues de arrancar un +proceso Claude Code, cuando ya conoces su PID pero todavia NO su sessionId. +Resuelve el mapeo PID -> sessionId esperando el archivo que Claude Code escribe +unos segundos despues, y deja marcado el role en el goal.json para que la TUI +fleetview clasifique al orquestador frente a los executors. Tambien sirve para +re-marcar el role de una sesion ya existente sin pisar el resto de su goal. + +## Gotchas + +- **Impura**: lee `~/.claude/sessions/.json` y escribe + `~/.claude/goals/.json` (crea `goals_dir` si falta). +- **Timing del sessions JSON**: Claude Code NO escribe `sessions/.json` al + instante de arrancar — tarda unos segundos. Por eso la funcion sondea hasta + `wait_s` (deadline con `time.monotonic`, no un unico `sleep` largo) y reacciona + en cuanto el archivo aparece con un `sessionId` no vacio. Ajusta `wait_s` segun + cuanto tarde tu maquina en arrancar la sesion. +- **Timeout NO lanza**: si el archivo no aparece a tiempo devuelve + `{"ok": False, "error": "timeout esperando sessions/.json", "pid": pid}`. + Es decision deliberada: el launcher decide si reintentar o continuar. Solo + `role` invalido lanza (ValueError), y lo hace antes de tocar disco. +- **NO pisa otros campos del goal**: abre el goal existente (dict vacio si no + existe o es JSON invalido / no-dict), setea UNICAMENTE la clave `role` y + conserva todo lo demas (`goal`, `phase`, `dod`, `dod_contract`, etc.). Si el + goal.json no existia, lo crea con solo `{"role": ...}`. +- **Escritura atomica**: escribe a un archivo temporal y hace `os.replace`, asi + un lector concurrente (la TUI fleetview) nunca ve un goal.json a medio escribir. +- **sessionId vacio o sessions JSON ilegible**: se trata como "aun no listo" y se + sigue sondeando hasta el deadline (no es un error inmediato). diff --git a/python/functions/infra/mark_claude_role.py b/python/functions/infra/mark_claude_role.py new file mode 100644 index 00000000..f770845e --- /dev/null +++ b/python/functions/infra/mark_claude_role.py @@ -0,0 +1,129 @@ +"""Marca el role de una sesion de Claude Code resolviendo PID -> sessionId. + +El meta-orquestador de flota arranca un Claude Code y necesita marcarlo con su +role (orchestrator | executor) en su `goal.json` para que la TUI fleetview lo +clasifique (p. ej. pinear el orquestador arriba). El problema es de timing: el +`sessionId` no se conoce al arrancar el proceso; Claude Code escribe el archivo +`~/.claude/sessions/.json` (que contiene el `sessionId`) unos segundos +despues. Esta funcion espera ese archivo sondeando, resuelve el sessionId y +escribe SOLO la clave `role` en `~/.claude/goals/.json` preservando +todos los demas campos del goal. +""" + +import json +import os +import time + +_VALID_ROLES = ("orchestrator", "executor") + + +def mark_claude_role( + pid: int, + role: str, + wait_s: float = 10.0, + sessions_dir: str | None = None, + goals_dir: str | None = None, +) -> dict: + """Resuelve PID -> sessionId esperando el sessions/.json y marca el role. + + Sondea `/.json` cada ~0.25s hasta `wait_s` segundos (medido + con time.monotonic) esperando que exista y contenga un `sessionId` no vacio. + Si lo encuentra, abre `/.json` (dict vacio si no existe), + setea SOLO la clave `role` preservando el resto (goal, phase, dod, etc.), y lo + escribe atomicamente (tmp + os.replace). Si expira, devuelve un dict de error + sin lanzar excepcion (el launcher decide que hacer). + + Args: + pid: PID del proceso Claude Code recien arrancado. + role: "orchestrator" o "executor". Cualquier otro valor lanza ValueError + sin tocar disco. + wait_s: segundos maximos a esperar el sessions/.json. Default 10.0. + sessions_dir: directorio de los sessions JSON. Default ~/.claude/sessions. + goals_dir: directorio de los goal JSON. Default ~/.claude/goals. + + Returns: + Si tuvo exito: dict con ok=True, pid, session_id, role y path (ruta del + goal escrito). Si expiro el poll: dict con ok=False, error (mensaje) y pid. + + Raises: + ValueError: si `role` no esta en ("orchestrator", "executor"). + """ + if role not in _VALID_ROLES: + raise ValueError( + f"role invalido: {role!r}. Debe ser uno de {_VALID_ROLES}" + ) + + home = os.path.expanduser("~") + if sessions_dir is None: + sessions_dir = os.path.join(home, ".claude", "sessions") + if goals_dir is None: + goals_dir = os.path.join(home, ".claude", "goals") + + session_path = os.path.join(sessions_dir, f"{pid}.json") + + # POLL: espera hasta wait_s segundos a que exista sessions/.json con un + # sessionId no vacio. Bucle corto con time.monotonic como deadline (no un + # unico sleep largo) para reaccionar en cuanto aparezca el archivo. + deadline = time.monotonic() + wait_s + sid = "" + while True: + sid = _read_session_id(session_path) + if sid: + break + if time.monotonic() >= deadline: + return { + "ok": False, + "error": f"timeout esperando sessions/{pid}.json", + "pid": pid, + } + time.sleep(0.25) + + goal_path = os.path.join(goals_dir, f"{sid}.json") + + # Lee el goal existente (dict vacio si no existe o es ilegible/no-dict). + goal: dict = {} + if os.path.exists(goal_path): + try: + with open(goal_path, "r", encoding="utf-8") as fh: + loaded = json.load(fh) + if isinstance(loaded, dict): + goal = loaded + except (json.JSONDecodeError, ValueError, OSError): + goal = {} + + # Setea SOLO la clave role, preservando todo lo demas. + goal["role"] = role + + # Escritura atomica: tmp + os.replace en el mismo directorio. + os.makedirs(goals_dir, exist_ok=True) + tmp_path = goal_path + f".tmp.{os.getpid()}" + with open(tmp_path, "w", encoding="utf-8") as fh: + json.dump(goal, fh, ensure_ascii=False, indent=2) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp_path, goal_path) + + return { + "ok": True, + "pid": pid, + "session_id": sid, + "role": role, + "path": goal_path, + } + + +def _read_session_id(session_path: str) -> str: + """Lee el sessionId del sessions/.json. Devuelve "" si falta/ilegible/vacio.""" + if not os.path.exists(session_path): + return "" + try: + with open(session_path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except (json.JSONDecodeError, ValueError, OSError): + return "" + if not isinstance(data, dict): + return "" + sid = data.get("sessionId") + if isinstance(sid, str) and sid: + return sid + return "" diff --git a/python/functions/infra/mark_claude_role_test.py b/python/functions/infra/mark_claude_role_test.py new file mode 100644 index 00000000..f0bf1d31 --- /dev/null +++ b/python/functions/infra/mark_claude_role_test.py @@ -0,0 +1,119 @@ +"""Tests para mark_claude_role.""" + +import json +import os + +import pytest + +from mark_claude_role import mark_claude_role + + +def _write_session(sessions_dir, pid, session_id): + """Escribe un sessions/.json con el sessionId dado.""" + os.makedirs(sessions_dir, exist_ok=True) + path = os.path.join(sessions_dir, f"{pid}.json") + with open(path, "w", encoding="utf-8") as fh: + json.dump({"sessionId": session_id, "cwd": "/tmp/whatever"}, fh) + return path + + +def _write_goal(goals_dir, session_id, goal): + """Escribe un goal/.json con el dict dado.""" + os.makedirs(goals_dir, exist_ok=True) + path = os.path.join(goals_dir, f"{session_id}.json") + with open(path, "w", encoding="utf-8") as fh: + json.dump(goal, fh) + return path + + +def _read_goal(goals_dir, session_id): + path = os.path.join(goals_dir, f"{session_id}.json") + with open(path, "r", encoding="utf-8") as fh: + return json.load(fh) + + +def test_sessions_presente_resuelve_y_escribe_role_preservando_otros_campos(tmp_path): + sessions_dir = str(tmp_path / "sessions") + goals_dir = str(tmp_path / "goals") + pid = 4242 + sid = "abc-123-uuid" + + _write_session(sessions_dir, pid, sid) + # Goal preexistente con campos que NO deben perderse. + _write_goal( + goals_dir, + sid, + { + "goal": "implementar fase 3", + "phase": "trabajando", + "dod": ["compila", "tests verdes"], + "dod_contract": {"capa1": "mecanica"}, + }, + ) + + res = mark_claude_role(pid, "orchestrator", wait_s=2.0, + sessions_dir=sessions_dir, goals_dir=goals_dir) + + assert res["ok"] is True + assert res["pid"] == pid + assert res["session_id"] == sid + assert res["role"] == "orchestrator" + assert res["path"] == os.path.join(goals_dir, f"{sid}.json") + + goal = _read_goal(goals_dir, sid) + assert goal["role"] == "orchestrator" + # Todos los demas campos del goal se preservan intactos. + assert goal["goal"] == "implementar fase 3" + assert goal["phase"] == "trabajando" + assert goal["dod"] == ["compila", "tests verdes"] + assert goal["dod_contract"] == {"capa1": "mecanica"} + + +def test_role_invalido_lanza_value_error_sin_escribir(tmp_path): + sessions_dir = str(tmp_path / "sessions") + goals_dir = str(tmp_path / "goals") + pid = 4242 + sid = "abc-123-uuid" + _write_session(sessions_dir, pid, sid) + + with pytest.raises(ValueError): + mark_claude_role(pid, "supervisor", wait_s=2.0, + sessions_dir=sessions_dir, goals_dir=goals_dir) + + # No escribio nada: el goals_dir ni siquiera deberia existir. + assert not os.path.exists(goals_dir) + + +def test_sessions_ausente_devuelve_timeout_sin_crash(tmp_path): + sessions_dir = str(tmp_path / "sessions") + goals_dir = str(tmp_path / "goals") + pid = 9999 # sin sessions/.json escrito + + res = mark_claude_role(pid, "executor", wait_s=0.5, + sessions_dir=sessions_dir, goals_dir=goals_dir) + + assert res["ok"] is False + assert res["pid"] == pid + assert "timeout" in res["error"] + assert f"{pid}.json" in res["error"] + # No se escribio ningun goal. + assert not os.path.exists(goals_dir) + + +def test_goal_inexistente_se_crea_con_solo_role(tmp_path): + sessions_dir = str(tmp_path / "sessions") + goals_dir = str(tmp_path / "goals") + pid = 7 + sid = "fresh-session-uuid" + _write_session(sessions_dir, pid, sid) + # No existe goal previo para esta sesion. + + res = mark_claude_role(pid, "executor", wait_s=2.0, + sessions_dir=sessions_dir, goals_dir=goals_dir) + + assert res["ok"] is True + assert res["session_id"] == sid + assert res["role"] == "executor" + + goal = _read_goal(goals_dir, sid) + assert goal == {"role": "executor"}