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:
@@ -24,7 +24,8 @@ type ClaudeFleet struct {
|
||||
Name string `json:"name"` // manual rename of the terminal (from goals .rename; "" if none)
|
||||
Status string `json:"status"` // idle|busy|waiting (from sessions/<pid>.json)
|
||||
Cwd string `json:"cwd"` // working directory of the session
|
||||
TmuxWindow string `json:"tmux_window"` // "" for now (populated in a later phase)
|
||||
TmuxWindow string `json:"tmux_window"` // window_id (@N) of the pane: REAL current position, used for focus/send-keys; migrates when the pane is swapped between windows
|
||||
PaneID string `json:"pane_id"` // pane_id (%N) of the pane: STABLE identity for the pane's whole life, immune to window swaps; "" if not resolvable. Prefer this as the agent's identifier over TmuxWindow
|
||||
Alive bool `json:"alive"` // process alive AND procStart matches (guards against PID recycling)
|
||||
UpdatedAt int64 `json:"updated_at"` // from sessions/<pid>.json .updatedAt (epoch millis)
|
||||
CtxPct int `json:"ctx_pct"` // context window used %, from runtime/<sessionId>.json; -1 if unknown
|
||||
|
||||
@@ -42,13 +42,47 @@ type runtimeFile struct {
|
||||
|
||||
// ListClaudeFleet scans the current user's ~/.claude directory and returns the
|
||||
// fleet of Claude Code sessions known to the machine. It is a thin wrapper over
|
||||
// ListClaudeFleetFrom resolving the home directory.
|
||||
// ListClaudeFleetFrom resolving the home directory, plus it populates each
|
||||
// member's PaneID ("%N") by resolving it against the fleet tmux socket.
|
||||
//
|
||||
// The socket comes from $FLEET_SOCKET, defaulting to "fleet". Resolution is
|
||||
// best-effort: if tmux/the socket is unavailable, every PaneID is left "" and
|
||||
// the fleet is still returned. PaneID is only populated here (the public
|
||||
// registry entry point), not in ListClaudeFleetFrom, so consumers that call the
|
||||
// core directly in a hot loop (and the unit tests) pay no tmux cost.
|
||||
func ListClaudeFleet() ([]ClaudeFleet, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve home dir: %w", err)
|
||||
}
|
||||
return ListClaudeFleetFrom(filepath.Join(home, ".claude"))
|
||||
fleet, err := ListClaudeFleetFrom(filepath.Join(home, ".claude"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
populatePaneIDs(fleet)
|
||||
return fleet, nil
|
||||
}
|
||||
|
||||
// populatePaneIDs resolves each alive member's pane_id ("%N") against the fleet
|
||||
// tmux socket ($FLEET_SOCKET, default "fleet") and writes it into PaneID. It
|
||||
// mutates fleet in place. Best-effort: tmux/socket down -> every PaneID stays ""
|
||||
// (ResolvePaneIDs returns an empty map), no crash. Only alive PIDs are queried;
|
||||
// a dead PID has no pane to resolve.
|
||||
func populatePaneIDs(fleet []ClaudeFleet) {
|
||||
socket := os.Getenv("FLEET_SOCKET")
|
||||
if socket == "" {
|
||||
socket = "fleet"
|
||||
}
|
||||
pids := make([]int, 0, len(fleet))
|
||||
for _, f := range fleet {
|
||||
if f.Alive {
|
||||
pids = append(pids, f.PID)
|
||||
}
|
||||
}
|
||||
byPID := ResolvePaneIDs(socket, pids)
|
||||
for i := range fleet {
|
||||
fleet[i].PaneID = byPID[fleet[i].PID]
|
||||
}
|
||||
}
|
||||
|
||||
// ListClaudeFleetFrom scans claudeDir (e.g. ~/.claude) and returns the fleet of
|
||||
|
||||
@@ -8,7 +8,7 @@ purity: impure
|
||||
signature: "func ListClaudeFleetFrom(claudeDir string) ([]ClaudeFleet, error) | func ListClaudeFleet() ([]ClaudeFleet, error)"
|
||||
description: "Lista la flota de procesos Claude Code de la maquina local (Linux). Escanea ~/.claude/sessions/*.json, cruza cada PID vivo contra /proc para validar liveness (anti-PID-reciclado via procStart == campo 22 de /proc/<pid>/stat), une el goal/phase de ~/.claude/goals/<sessionId>.json, extrae KITTY_PID del environ y deriva los campos de display (Target, Rename). Devuelve todas las sesiones ordenadas por status (idle, waiting, busy, otro) y por updatedAt desc; el caller filtra por Alive. Pieza de datos de la app TUI fleetview."
|
||||
tags: [claude-fleet, infra, claude, session, proc, fleet, tui, orchestration]
|
||||
uses_functions: []
|
||||
uses_functions: [resolve_pane_ids_go_infra]
|
||||
uses_types: [claude_fleet_go_infra]
|
||||
returns: [claude_fleet_go_infra]
|
||||
returns_optional: false
|
||||
@@ -17,7 +17,7 @@ imports: []
|
||||
params:
|
||||
- name: "claudeDir"
|
||||
desc: "Directorio raiz de Claude Code a escanear (ej. /home/enmanuel/.claude). ListClaudeFleetFrom lo recibe explicito (testeable con t.TempDir()); ListClaudeFleet lo resuelve via os.UserHomeDir() + .claude."
|
||||
output: "Slice de ClaudeFleet (claude_fleet_go_infra), una entrada por sesion con JSON parseable en sessions/. Cada entrada lleva PID, KittyPID, SessionID, Rename, Target, Goal, Phase, Status, Cwd, TmuxWindow (\"\"), Alive y UpdatedAt. Ordenado por rango de status y luego por UpdatedAt descendente. Devuelve slice vacio (sin error) si la carpeta sessions/ no existe; error si no se puede leer la carpeta por otra causa."
|
||||
output: "Slice de ClaudeFleet (claude_fleet_go_infra), una entrada por sesion con JSON parseable en sessions/. Cada entrada lleva PID, KittyPID, SessionID, Rename, Target, Goal, Phase, Status, Cwd, TmuxWindow (\"\"), PaneID, Alive y UpdatedAt. ListClaudeFleet() puebla PaneID (\"%N\", identificador estable del pane) cruzando cada PID vivo con los panes del socket $FLEET_SOCKET (default \"fleet\") via resolve_pane_ids_go_infra; ListClaudeFleetFrom() deja PaneID \"\" (no hace tmux). Ordenado por rango de status y luego por UpdatedAt descendente. Devuelve slice vacio (sin error) si la carpeta sessions/ no existe; error si no se puede leer la carpeta por otra causa."
|
||||
tested: true
|
||||
tests: ["TestListClaudeFleetFrom", "TestListClaudeFleetFromMissingDir"]
|
||||
test_file_path: "functions/infra/list_claude_fleet_test.go"
|
||||
@@ -69,4 +69,5 @@ Cuando necesites enumerar las sesiones de Claude Code vivas en la maquina local
|
||||
- **/proc no es portable.** Build tag `//go:build !windows`; depende de `/proc/<pid>/stat` y `/proc/<pid>/environ` (Linux). En macOS/BSD no funciona tal cual.
|
||||
- **environ ilegible -> KittyPID=0.** Si `/proc/<pid>/environ` no es legible (permisos, proceso de otro usuario, o el proceso ya murio entre el ReadDir y el ReadFile) `KittyPID` cae a 0 sin error. Tambien es 0 legitimamente cuando claude no corre bajo kitty (ej. tmux remoto).
|
||||
- **Devuelve TODAS las sesiones con JSON parseable**, vivas o muertas. El caller decide filtrar por `Alive`. Archivos no-`.json` y JSON corrupto se ignoran silenciosamente.
|
||||
- **TmuxWindow siempre "".** Reservado para una fase posterior; hoy no se rellena.
|
||||
- **TmuxWindow siempre "".** Esta funcion no resuelve el window_id (@N); lo rellena el consumidor (fleetview) cuando lo necesita para el focus.
|
||||
- **PaneID lo puebla solo `ListClaudeFleet()`, no `ListClaudeFleetFrom()`.** La variante con directorio (usada en tests y en bucles de render calientes) no llama a tmux: deja `PaneID` "". La publica resuelve el pane_id ("%N") contra `$FLEET_SOCKET` (default "fleet") via `resolve_pane_ids_go_infra`. Si el socket no existe o tmux no responde, todos los `PaneID` quedan "" sin error. El pane_id es estable de por vida del pane (inmune a los swaps de window que mueve el focus), a diferencia de `TmuxWindow`.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: resolve_pane_ids
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ResolvePaneIDs(socket string, claudePIDs []int) map[int]string"
|
||||
description: "Resuelve el pane_id (\"%N\") de tmux de cada proceso dado en un socket aislado (tmux -L <socket>), devolviendo un mapa claudePID -> pane_id. Lista los panes con `list-panes -a -F '#{pane_pid} #{pane_id}'` y, para cada PID, sube por el arbol de procesos (PPID en /proc/<pid>/stat) hasta dar con un pane_pid del socket; ese pane es el que aloja al proceso (normalmente pane_pid == PID porque el pane corre `exec claude`, pero un shell que lanzo el proceso como hijo se cubre con el ascenso). El pane_id es estable de por vida del pane, inmune a los swaps de window que mueve el focus de la flota, por eso es el identificador correcto de un agente frente al window_id (@N). Best-effort: socket vacio, sin PIDs o fallo de tmux -> mapa vacio; un PID sin pane resoluble se omite. Capa de control tmux de fleetview / orquestador."
|
||||
tags: [claude-fleet, infra, tmux, pane, fleet, orchestration]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "socket"
|
||||
desc: "Nombre del socket tmux aislado (tmux -L <socket>). En la flota suele ser 'fleet'/'fleet3' ($FLEET_SOCKET). Escanea TODOS los panes del servidor de ese socket (list-panes -a). \"\" -> mapa vacio."
|
||||
- name: "claudePIDs"
|
||||
desc: "PIDs de los procesos (normalmente claude) cuyo pane_id se quiere resolver. Vacio/nil -> mapa vacio sin llamar a tmux."
|
||||
output: "map[int]string con clave = PID de entrada y valor = pane_id ('%N') del pane que lo aloja. Un PID sin pane resoluble en su ascendencia se omite (el caller lo degrada a \"\"). Mapa vacio (sin panic, sin error) si socket viene vacio, claudePIDs viene vacio, o `tmux list-panes -a` falla (socket caido, tmux ausente)."
|
||||
tested: true
|
||||
tests: ["TestResolvePaneIDsFrom", "TestResolvePaneIDsFromUnresolvable", "TestResolvePaneIDsFromMalformedLines", "TestResolvePaneIDsEmptyInputs", "TestPaneAncestorBounded"]
|
||||
test_file_path: "functions/infra/resolve_pane_ids_test.go"
|
||||
file_path: "functions/infra/resolve_pane_ids.go"
|
||||
notes: "Build tag //go:build !windows (depende de /proc y de tmux, no portable a Windows). Comparte runTmux (tmux_new_claude_window) y procPPID (tmux_map_claude_panes) con el resto de la capa tmux del paquete infra. El nucleo resolvePaneIDsFrom(tmuxOut, ppidOf, pids) es testeable inyectando la salida de tmux y el resolvedor de PPID, sin procesos reales. El ascenso por el arbol esta acotado (64 saltos, corte en pid<=1) para no colgarse ante un /proc malformado o un ciclo. Hermana de tmux_map_claude_panes_go_infra: aquella mapea PID -> window_id (@N, posicion operativa que migra con el swap); esta mapea PID -> pane_id (%N, identidad estable). Pensada para que list_claude_fleet_go_infra (y consumidores como el orquestador) identifiquen a cada agente por un handle que no baila al hacer focus."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Resolver el pane_id estable de un par de procesos claude en el socket fleet.
|
||||
byPID := infra.ResolvePaneIDs("fleet", []int{3637133, 3640001})
|
||||
for pid, paneID := range byPID {
|
||||
fmt.Printf("pid=%d -> pane=%s\n", pid, paneID) // ej. pid=3637133 -> pane=%8
|
||||
}
|
||||
// Un PID sin pane resoluble simplemente no aparece en el mapa.
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Patron tipico: cruzar la flota con su pane_id estable.
|
||||
fleet, _ := infra.ListClaudeFleet()
|
||||
pids := make([]int, 0, len(fleet))
|
||||
for _, c := range fleet {
|
||||
pids = append(pids, c.PID)
|
||||
}
|
||||
panes := infra.ResolvePaneIDs("fleet", pids) // map[pid]"%N"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un identificador ESTABLE de un agente de la flota a partir de su PID: el pane_id ("%N") de tmux no cambia durante toda la vida del pane, aunque el pane migre de window al hacer focus (break-pane + join-pane). Usala en vez de referirte al window_id (`@N`, `TmuxWindow`), que baila cada vez que el agente entra/sale de la console. La consume `list_claude_fleet_go_infra` para poblar `ClaudeFleet.PaneID`, y el orquestador para referirse a un ejecutor por un handle que no se confunde de agente.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: ejecuta `tmux` y lee `/proc`.** No es determinista entre llamadas (la flota cambia). Solo lectura — no mueve ni mata panes.
|
||||
- **Best-effort, nunca crashea.** Socket vacio, lista de PIDs vacia, o un `tmux list-panes -a` que falla (socket caido, tmux no instalado) devuelven un mapa vacio. Un agente sin pane resoluble (proceso huerfano, pane cerrado) se omite del mapa; el caller lo degrada a "".
|
||||
- **Sube por el arbol de procesos.** Si el pane corre un shell que lanzo claude como hijo (en vez de `exec claude`), el pane_pid no es el claude PID; el ascenso por PPID lo cubre. El ascenso esta acotado a 64 saltos (corte en pid<=1) para no colgarse ante un ciclo o un /proc raro.
|
||||
- **Parseo del `comm` en /proc.** El PPID se saca de `/proc/<pid>/stat` tomando lo que hay tras el ULTIMO ')' (el comm va entre parentesis y puede contener espacios y ')'). Reutiliza el `procPPID` robusto del paquete.
|
||||
- **/proc + tmux no portables.** Build tag `//go:build !windows`; depende de `/proc/<pid>/stat` (Linux) y del binario `tmux`.
|
||||
- **Opera SIEMPRE sobre el socket aislado** (`tmux -L <socket>`), escaneando todos sus panes con `list-panes -a`. No mira el servidor tmux por defecto del usuario.
|
||||
@@ -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