"""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/.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/.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/.json y marca el padre. 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 `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/.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/.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/.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 ""