753e16b84c
Helper py analogo a mark_claude_role: resuelve el sessionId de un Claude recien arrancado por su PID (sondeando sessions/<pid>.json) y escribe SOLO la clave parent_orchestrator en su goal.json, preservando el resto. Lo consume spawn_fleet_agent --parent para que el watcher de fleetview rutee los avisos del ejecutor a su orquestador padre. Tests: escribe+preserva, goal inexistente, parent vacio (ValueError), timeout sin crash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
136 lines
5.3 KiB
Python
136 lines
5.3 KiB
Python
"""Marca el orquestador padre de una sesion de Claude Code resolviendo PID -> sessionId.
|
|
|
|
El meta-orquestador de flota arranca un ejecutor (Claude Code) y necesita dejar
|
|
constancia de QUIEN lo lanzo, para que el watcher de fleetview rutee los avisos de
|
|
ese ejecutor (cierre, estancamiento) al orquestador correcto cuando hay mas de uno
|
|
activo. El problema es de timing: el `sessionId` del nuevo Claude 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
|
|
`parent_orchestrator` en `~/.claude/goals/<sessionId>.json` preservando todos los
|
|
demas campos del goal (goal, phase, dod, dod_contract, role, ...).
|
|
|
|
Es el equivalente de mark_claude_role para la clave `parent_orchestrator`: misma
|
|
mecanica de poll PID -> sessionId, distinta clave escrita. El orquestador lo invoca
|
|
desde spawn_fleet_agent (flag --parent) justo despues de lanzar el ejecutor.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
|
|
|
|
def mark_claude_parent(
|
|
pid: int,
|
|
parent_orchestrator: 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 padre.
|
|
|
|
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 `parent_orchestrator` preservando el resto (goal, phase,
|
|
dod, dod_contract, role, ...), y lo escribe atomicamente (tmp + os.replace). Si
|
|
expira, devuelve un dict de error sin lanzar excepcion (el launcher decide).
|
|
|
|
Args:
|
|
pid: PID del proceso Claude Code recien arrancado (el ejecutor).
|
|
parent_orchestrator: sessionId del orquestador que lanzo el ejecutor. No
|
|
puede ser vacio (un padre vacio no aporta routing). Cualquier string no
|
|
vacio es valido (es un sessionId arbitrario, no un enum).
|
|
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, parent_orchestrator y
|
|
path (ruta del goal escrito). Si expiro el poll: dict con ok=False, error
|
|
(mensaje) y pid.
|
|
|
|
Raises:
|
|
ValueError: si `parent_orchestrator` es vacio o solo espacios. No escribe
|
|
nada (se valida antes de tocar disco).
|
|
"""
|
|
if not parent_orchestrator or not parent_orchestrator.strip():
|
|
raise ValueError(
|
|
"parent_orchestrator no puede ser vacio: un padre vacio no aporta routing"
|
|
)
|
|
|
|
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 parent_orchestrator, preservando todo lo demas.
|
|
goal["parent_orchestrator"] = parent_orchestrator
|
|
|
|
# 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,
|
|
"parent_orchestrator": parent_orchestrator,
|
|
"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 ""
|