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,96 @@
---
name: mark_claude_parent
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "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"
description: "Marca el orquestador padre 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 `parent_orchestrator` en ~/.claude/goals/<sessionId>.json preservando el resto del goal (goal, phase, dod, dod_contract, role...). Escritura atomica tmp + os.replace. Si el sessions JSON no aparece a tiempo devuelve ok=False timeout sin lanzar. Equivalente de mark_claude_role para la clave parent_orchestrator: lo invoca spawn_fleet_agent (--parent) para que el watcher de fleetview rutee los avisos del ejecutor al orquestador que lo lanzo cuando hay mas de uno activo."
tags: [fleet, claude-fleet, orchestration, parent, 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 (el ejecutor). Se usa para localizar ~/.claude/sessions/<pid>.json"
- name: parent_orchestrator
desc: "sessionId del orquestador que lanzo el ejecutor. No puede ser vacio (lanza ValueError). Cualquier string no vacio es valido: es un sessionId arbitrario, no un enum"
- 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), parent_orchestrator (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 parent_orchestrator preservando otros campos (incl role)"
- "parent vacio lanza ValueError sin escribir"
- "sessions ausente devuelve timeout sin crash"
- "goal inexistente se crea con solo parent_orchestrator"
test_file_path: "python/functions/infra/mark_claude_parent_test.py"
file_path: "python/functions/infra/mark_claude_parent.py"
---
## Ejemplo
```python
from infra.mark_claude_parent import mark_claude_parent
# El orquestador acaba de lanzar un ejecutor (Claude Code) y conoce su PID, pero
# todavia NO su sessionId. Deja constancia de quien lo lanzo para que el watcher
# de fleetview rutee al orquestador correcto el aviso de cierre del ejecutor.
res = mark_claude_parent(executor_pid, my_session_id, wait_s=15.0)
if res["ok"]:
print(f"padre marcado: {res['session_id']} -> {res['parent_orchestrator']}")
else:
# Timeout: el sessions/<pid>.json no aparecio a tiempo. El launcher decide.
print("no se pudo marcar el padre:", res["error"])
```
Invocacion canonica via `fn run` (asi lo llama `spawn_fleet_agent --parent`):
```bash
./fn run mark_claude_parent <pid_del_ejecutor> <sessionId_del_orquestador>
```
## Cuando usarla
Usala desde el launcher del orquestador de flota (`spawn_fleet_agent --parent`)
justo despues de arrancar un ejecutor, cuando conoces su PID pero todavia NO su
sessionId. Resuelve PID -> sessionId esperando el archivo que Claude Code escribe
unos segundos despues, y deja `parent_orchestrator` en el goal.json del ejecutor.
El watcher de fleetview consume esa clave para rutear el aviso de `DICE_TERMINADO`
/ estancamiento de cada ejecutor al pane tmux del orquestador que lo lanzo, en vez
de a toda la flota — necesario cuando hay mas de un orquestador activo. Es el par
de `mark_claude_role` (misma mecanica de poll, distinta clave escrita).
## 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.
- **Timeout NO lanza**: si el archivo no aparece a tiempo devuelve
`{"ok": False, "error": "timeout esperando sessions/<pid>.json", "pid": pid}`.
Solo `parent_orchestrator` vacio 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
`parent_orchestrator` y conserva todo lo demas (`goal`, `phase`, `dod`,
`dod_contract`, `role`, ...). Si el goal.json no existia, lo crea con solo
`{"parent_orchestrator": ...}`.
- **Escritura atomica**: escribe a un archivo temporal y hace `os.replace`, asi un
lector concurrente (la TUI / watcher fleetview) nunca ve un goal.json a medio
escribir.
- **Orden con mark_claude_role**: cuando `spawn_fleet_agent` aplica `--role` y
`--parent` a la vez, los encadena secuencialmente en el mismo subshell (primero
role, luego parent) para que el segundo lea el goal ya con la primera clave
escrita y no haya carrera de lectura-modificacion-escritura entre ambos.