diff --git a/fleetd/src/main/java/dev/ltms/fleet/herdr/AgentControl.java b/fleetd/src/main/java/dev/ltms/fleet/herdr/AgentControl.java index 75541a7..f98675a 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/herdr/AgentControl.java +++ b/fleetd/src/main/java/dev/ltms/fleet/herdr/AgentControl.java @@ -38,6 +38,11 @@ public final class AgentControl { this.herdr = herdr; } + /** The herdr daemon this control object sends its agent calls to. */ + public HerdrClient herdr() { + return herdr; + } + /** One agent-targeted call, translating a terminal id to its pane id (retrying once fresh). */ private JsonNode agentCall(String method, String target, Map extra) { String resolved = resolveTarget(target); diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java index 465a6fe..30daf3b 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java @@ -2,6 +2,7 @@ package dev.ltms.fleet.member; import dev.ltms.fleet.config.FleetConfig; import dev.ltms.fleet.herdr.Agent; +import dev.ltms.fleet.herdr.HerdrClient; import dev.ltms.fleet.peer.Capability; import dev.ltms.fleet.peer.MemberRole; import dev.ltms.fleet.peer.PeerHandle; @@ -21,6 +22,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -44,8 +46,8 @@ import java.util.stream.Collectors; * the single adapter that declares it. Profiles partition cleanly across adapters: the * constructor rejects a name claimed by two. *
  • By pane id — {@link #stop} routes to the adapter that spawned that pane - * (recorded at spawn time). A pane the composite never spawned can use the single delegate - * in a one-daemon fleet. With more than one delegate, its owner is unknown, so stop refuses + * (recorded at spawn time). A pane the composite never spawned can use the fallback route + * in a one-daemon fleet. With more than one herdr daemon, its owner is unknown, so stop refuses * the ambiguous id rather than closing a pane on an arbitrary herdr daemon.
  • *
  • Fleet-wide — {@link #reapOrphanWorkers} and {@link #capabilities} fan out * and combine. {@link #list} is deduplicated by pane id because every herdr-backed delegate @@ -437,7 +439,7 @@ public final class CompositePeerLauncher implements PeerLauncher { public void stop(String id) { HerdrPeerLauncher d = spawnedBy.remove(id); if (d == null) { - if (delegates.size() != 1) { + if (herdrDaemonCount() != 1) { throw new IllegalArgumentException("ambiguous paneId '" + id + "': no owning herdr daemon was recorded"); } @@ -447,6 +449,18 @@ public final class CompositePeerLauncher implements PeerLauncher { d.stop(id); } + /** + * Count actual herdr daemons, not peer adapter kinds. Identity is intentional: separate client + * objects may represent different daemons even if a client later implements value equality. + */ + private int herdrDaemonCount() { + Set daemons = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + for (HerdrPeerLauncher delegate : delegates) { + daemons.add(delegate.herdr()); + } + return daemons.size(); + } + @Override public boolean clearContext(String id) { HerdrPeerLauncher delegate = spawnedBy.get(id); diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java index 6871dda..76c8f40 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java @@ -3,6 +3,7 @@ package dev.ltms.fleet.member; import dev.ltms.fleet.config.FleetConfig; import dev.ltms.fleet.herdr.Agent; import dev.ltms.fleet.herdr.AgentControl; +import dev.ltms.fleet.herdr.HerdrClient; import dev.ltms.fleet.herdr.HerdrException; import dev.ltms.fleet.herdr.Tab; import dev.ltms.fleet.herdr.Workspace; @@ -520,6 +521,11 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { req.sessionName(), spawned.agentSessionId(), spawned.receipt()); } + /** The herdr daemon that owns this launcher's pane coordinates. */ + public HerdrClient herdr() { + return agents.herdr(); + } + @Override public String effectiveCwd(SpawnRequest req) { return effectiveCwd(req.profileName(), req.requestedCwd(), req.callerCwd()); diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java index 060fc28..8fc49ef 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java @@ -285,6 +285,18 @@ class CompositePeerLauncherTest { "one daemon keeps the legacy bare-pane routing behaviour"); } + @Test + void stopAllowsAnUnownedPaneIdWithTwoAdaptersSharingOneDaemon() { + FakeHerdr herdr = new FakeHerdr(); + PeerLauncher composite = composite(herdr); + + composite.stop("w9:pRoot_1"); + + assertTrue(herdr.calls.stream().anyMatch(c -> c.method().equals("pane.close") + && "w9:pRoot_1".equals(((Map) c.params()).get("pane_id"))), + "two adapter kinds sharing one daemon keep the fallback route"); + } + @Test void stopRejectsAnUnownedPaneIdWhenMultipleDaemonsCouldOwnIt() { PeerLauncher composite = new CompositePeerLauncher(