Files
fn_registry/functions/infra/resolve_pane_ids.go
T
egutierrez fb76b53c17 feat(infra): exponer pane_id (%N) estable en el JSON de la flota
El orquestador identificaba cada agente por el campo tmux_window (@N), pero
el window_id de tmux cambia cuando un pane entra/sale de windows (el focus de
la flota usa break-pane + join-pane, que recrean windows). El pane_id (%N) en
cambio es estable durante toda la vida del pane: es el identificador correcto.

- claude_fleet.go: nuevo campo ClaudeFleet.PaneID `json:"pane_id"`. Se mantiene
  TmuxWindow (lo necesita el focus internamente); esto AÑADE pane_id, no lo
  reemplaza.
- resolve_pane_ids.go (+ .md, .go test): nueva función del registry
  ResolvePaneIDs(socket, pids) -> map[pid]pane_id. Lista los panes del socket
  (tmux -L <socket> list-panes -a) y para cada PID sube por el árbol de procesos
  (PPID en /proc) hasta dar con un pane_pid. Reutiliza runTmux y procPPID del
  paquete infra. Best-effort: tmux/socket caído o PID sin pane -> "" sin crash.
  Núcleo testeable con inyección de la salida tmux y del resolvedor de PPID.
- list_claude_fleet.go: ListClaudeFleet() puebla PaneID resolviendo cada PID
  vivo contra $FLEET_SOCKET (default "fleet"). Solo la entrada pública lo hace;
  ListClaudeFleetFrom() queda intacta (cero coste tmux en tests y en el bucle
  de render de fleetview).

Tag de grupo: orchestration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:19:55 +02:00

82 lines
3.0 KiB
Go

//go:build !windows
package infra
import (
"strconv"
"strings"
)
// ResolvePaneIDs crosses the PID of each Claude process with the panes of the
// given isolated tmux socket (tmux -L <socket> list-panes -a) and returns a
// claudePID -> pane_id ("%N") map.
//
// The pane_id is STABLE for the pane's whole life: it identifies a Claude even
// when its window_id (@N) migrates with the focus swap (break-pane + join-pane
// move the pane between windows). That is why it is the correct stable handle
// for an agent, as opposed to the window_id which changes by design.
//
// For each claudePID it climbs the process tree (PPID in /proc/<pid>/stat) until
// it finds a PID that is a pane_pid of the socket; that pane is the one hosting
// the Claude. Normally the pane_pid IS the claudePID because the pane runs
// `exec claude`, but a shell that launched claude as a child is covered by the
// ascent.
//
// Best-effort and crash-free: an empty socket, no PIDs, or a tmux failure
// (socket down, tmux absent) all yield an empty map; a Claude with no resolvable
// pane is simply omitted from the result (callers degrade it to ""). It reads
// /proc, hence the //go:build !windows tag.
func ResolvePaneIDs(socket string, claudePIDs []int) map[int]string {
if socket == "" || len(claudePIDs) == 0 {
return map[int]string{}
}
out, _, err := runTmux(socket, "list-panes", "-a", "-F", "#{pane_pid} #{pane_id}")
if err != nil {
return map[int]string{}
}
return resolvePaneIDsFrom(out, procPPID, claudePIDs)
}
// resolvePaneIDsFrom is the testable core of ResolvePaneIDs: it parses the
// `<pane_pid> <pane_id>` lines produced by tmux and, for each claudePID, climbs
// the process tree via ppidOf until it lands on a pane_pid, returning that
// pane's pane_id. ppidOf is injected so the ascent can be tested without real
// processes. Lines that do not parse are skipped; a PID with no pane ancestor is
// omitted.
func resolvePaneIDsFrom(tmuxOut string, ppidOf func(int) int, claudePIDs []int) map[int]string {
panePaneID := map[int]string{}
for _, line := range strings.Split(strings.TrimSpace(tmuxOut), "\n") {
f := strings.Fields(strings.TrimSpace(line))
if len(f) < 2 {
continue
}
pp, e := strconv.Atoi(f[0])
if e != nil {
continue
}
panePaneID[pp] = f[1]
}
res := make(map[int]string, len(claudePIDs))
for _, pid := range claudePIDs {
if paneID, ok := paneAncestor(pid, panePaneID, ppidOf); ok {
res[pid] = paneID
}
}
return res
}
// paneAncestor climbs the process tree from pid until it finds a PID that is a
// pane_pid (a key of panePaneID), returning its pane_id. The ascent is bounded
// (64 hops, stop at pid<=1) so a malformed /proc or a cycle cannot hang it.
// Returns ("", false) when no ancestor is a pane. ppidOf is injected for tests.
func paneAncestor(pid int, panePaneID map[int]string, ppidOf func(int) int) (string, bool) {
for i := 0; pid > 1 && i < 64; i++ {
if paneID, ok := panePaneID[pid]; ok {
return paneID, true
}
pid = ppidOf(pid)
}
return "", false
}