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>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakePPID builds a ppidOf closure from a child->parent map. Unknown PIDs map to
|
||||
// 1 (init), which terminates the ascent without matching any pane.
|
||||
func fakePPID(tree map[int]int) func(int) int {
|
||||
return func(pid int) int {
|
||||
if p, ok := tree[pid]; ok {
|
||||
return p
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaneIDsFrom(t *testing.T) {
|
||||
// Two panes. Pane %3 runs claude directly (pane_pid == claude PID 100).
|
||||
// Pane %7 runs a shell (pane_pid 200) that launched claude as a child (300).
|
||||
tmuxOut := "100 %3\n200 %7\n"
|
||||
tree := map[int]int{
|
||||
300: 200, // claude (300) -> shell (200) which is the pane_pid of %7
|
||||
}
|
||||
|
||||
got := resolvePaneIDsFrom(tmuxOut, fakePPID(tree), []int{100, 300})
|
||||
want := map[int]string{
|
||||
100: "%3", // direct: pane_pid IS the claude PID
|
||||
300: "%7", // ascent: claude's parent is the pane_pid
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("resolvePaneIDsFrom = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaneIDsFromUnresolvable(t *testing.T) {
|
||||
// PID 999 has no pane in its ancestry -> omitted (caller degrades to "").
|
||||
tmuxOut := "100 %3\n"
|
||||
got := resolvePaneIDsFrom(tmuxOut, fakePPID(map[int]int{}), []int{999})
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty map for unresolvable PID, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaneIDsFromMalformedLines(t *testing.T) {
|
||||
// Garbage / short / non-numeric lines are skipped without crashing; the one
|
||||
// valid line still resolves.
|
||||
tmuxOut := "\n \nnotapid %9\n42\n100 %3\n"
|
||||
got := resolvePaneIDsFrom(tmuxOut, fakePPID(map[int]int{}), []int{100})
|
||||
want := map[int]string{100: "%3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("resolvePaneIDsFrom (malformed) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaneIDsEmptyInputs(t *testing.T) {
|
||||
// Empty socket or no PIDs -> empty map, no tmux call attempted.
|
||||
if got := ResolvePaneIDs("", []int{1, 2}); len(got) != 0 {
|
||||
t.Errorf("empty socket: expected empty map, got %v", got)
|
||||
}
|
||||
if got := ResolvePaneIDs("fleet", nil); len(got) != 0 {
|
||||
t.Errorf("nil pids: expected empty map, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaneAncestorBounded(t *testing.T) {
|
||||
// A cycle in the process tree must not hang: the 64-hop bound cuts it.
|
||||
cycle := func(pid int) int {
|
||||
if pid == 500 {
|
||||
return 501
|
||||
}
|
||||
return 500 // 501 -> 500 -> 501 ... never reaches a pane
|
||||
}
|
||||
if id, ok := paneAncestor(500, map[int]string{100: "%3"}, cycle); ok {
|
||||
t.Fatalf("expected no resolution for cyclic tree, got %q", id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user