feat: launcher arranca orquestador + idle, pin por role (flow 0012, fase 3b)
- mark_claude_role (python/functions/infra): resuelve PID->sessionId esperando sessions/<PID>.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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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/<PID>.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:
|
||||
|
||||
@@ -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/<pid>.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/<sessionId>.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/<pid>.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/<pid>.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/<pid>.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/<pid>.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/<pid>.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/<pid>.json` y escribe
|
||||
`~/.claude/goals/<sessionId>.json` (crea `goals_dir` si falta).
|
||||
- **Timing del sessions JSON**: Claude Code NO escribe `sessions/<pid>.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/<pid>.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).
|
||||
@@ -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/<PID>.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/<sessionId>.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/<pid>.json y marca el role.
|
||||
|
||||
Sondea `<sessions_dir>/<pid>.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 `<goals_dir>/<sessionId>.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/<pid>.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/<pid>.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/<pid>.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 ""
|
||||
@@ -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/<pid>.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/<sessionId>.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/<pid>.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"}
|
||||
Reference in New Issue
Block a user