feat(infra): mark_claude_parent — escribe parent_orchestrator en goal.json (PID->sessionId)

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>
This commit is contained in:
2026-06-21 13:27:47 +02:00
parent b6f4b4eb03
commit 753e16b84c
3 changed files with 352 additions and 0 deletions
@@ -0,0 +1,135 @@
"""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 ""