fb76b53c17
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>
82 lines
3.0 KiB
Go
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
|
|
}
|