diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java index 37c72ac..07f0a59 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -575,15 +575,19 @@ public final class BridgeMcp { "default", workers.defaultProfile() == null ? "" : workers.defaultProfile()))); } - /** {@code bridge_list}: bridge-owned roster merged with live herdr status by paneId. */ + /** + * {@code bridge_list}: bridge-owned roster merged with live herdr status. CB-519 decoupled the + * registry key (a host-unique id) from the herdr pane coordinate, so the join is on the + * terminal id, which both the session and the live agent carry. + */ static McpSchema.CallToolResult listWorkers(PeerLauncher workers, SessionManager sessions) { try { Map live = workers.list().stream() .map(Agent.class::cast) - .filter(a -> a.paneId() != null) - .collect(Collectors.toMap(Agent::paneId, Function.identity(), (_, b) -> b)); + .filter(a -> a.terminalId() != null) + .collect(Collectors.toMap(Agent::terminalId, Function.identity(), (_, b) -> b)); List> out = sessions.roster().stream() - .map(s -> SessionManager.rosterView(s, live.get(s.paneId()))) + .map(s -> SessionManager.rosterView(s, live.get(s.terminalId()))) .toList(); return text(json(Map.of("workers", out))); } catch (HerdrException e) { diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java index dd8ae7c..bf46bd9 100644 --- a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java +++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java @@ -12,9 +12,13 @@ package dev.ltms.bridged.peer; public interface PeerHandle { /** - * The registry/routing key — an opaque, launcher-assigned identifier. For the herdr-backed - * launcher this is the herdr pane id; for other launchers it is whatever their transport - * uses. Guaranteed to be non-null and unique among live peers within a single daemon process. + * The registry/routing key — an opaque, launcher-assigned identifier (CB-519). Multiple + * daemon processes may run on one host, so the contract is host-unique, not merely + * process-unique: the herdr-backed launcher mints a fresh UUID per spawn, and a non-herdr + * launcher is likewise expected to return an identifier that cannot collide across processes + * on the same host. This id is the routing key and is deliberately decoupled from any launcher + * transport coordinate (e.g. a herdr pane id), which stays launcher-private. Guaranteed to be + * non-null and unique among live peers on the host. */ String id(); diff --git a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java index 49a7667..3e7dd3c 100644 --- a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java +++ b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java @@ -225,12 +225,13 @@ public final class BridgedApp { if (!allow(ctx, Authz.Action.READ, null)) { return; } + // CB-519: the registry key is a host-unique id, not the pane coordinate — join on terminal. Map live = workers.list().stream() .map(Agent.class::cast) - .filter(a -> a.paneId() != null) - .collect(Collectors.toMap(Agent::paneId, Function.identity(), (_, b) -> b)); + .filter(a -> a.terminalId() != null) + .collect(Collectors.toMap(Agent::terminalId, Function.identity(), (_, b) -> b)); List> out = sessions.roster().stream() - .map(s -> SessionManager.rosterView(s, live.get(s.paneId()))) + .map(s -> SessionManager.rosterView(s, live.get(s.terminalId()))) .toList(); ctx.status(200).json(Map.of("workers", out)); } diff --git a/bridged/src/main/java/dev/ltms/bridged/session/WorkerSession.java b/bridged/src/main/java/dev/ltms/bridged/session/WorkerSession.java index 3c96113..d66be76 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/WorkerSession.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/WorkerSession.java @@ -5,7 +5,10 @@ package dev.ltms.bridged.session; * process spawned. Immutable; state transitions are performed by replacing the record in * {@link SessionManager}'s registry. * - * @param paneId herdr pane handle — the registry key and the argument to teardown + * @param paneId the host-unique opaque id (CB-519) — the registry key and the argument to + * teardown. Despite the historical name this is the {@link + * dev.ltms.bridged.peer.PeerHandle#id()}, a UUID, and is distinct from the + * launcher-private herdr pane coordinate. * @param terminalId herdr terminal handle — the {@code target} for send/read/status * @param profile the worker profile name that spawned this session * @param cwd the resolved working directory the worker started in diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java index cdd2005..f5d4ccc 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java @@ -21,6 +21,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; import java.util.function.LongSupplier; @@ -81,6 +84,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { // collide with same-profile peers that outlived a restart. See startUniquelyNamed. private final String nameNonce = String.format("%06x", new SecureRandom().nextInt(1 << 24)); + // CB-519: PeerHandle.id() is a host-unique opaque UUID, decoupled from the herdr pane id. The + // routing/registry key is the UUID; the herdr pane id is a launcher-private placement/teardown + // coordinate. This map bridges the two so stop(id) can resolve a host-unique key back to the + // exact pane it must tear down. The pane id is launcher-private (never the routing key) — see + // PeerHandle.id(). + private final ConcurrentMap paneByAgentId = new ConcurrentHashMap<>(); + /** * @param namePrefix label prefix for this peer kind (drives naming and reap) * @param agents herdr agent control (start, status, close) @@ -189,10 +199,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * {@inheritDoc} * *

Delegates to {@link #spawnInternal} and wraps the resulting herdr {@link Agent} in a - * {@link WorkerHandle} whose {@link PeerHandle#id()} equals the agent's paneId. When - * {@code spawnReadyTimeoutMs > 0}, blocks until the peer's herdr status is injectable or the - * timeout elapses; on timeout the pane is closed (no orphan) and a - * {@link PeerUnreachableException} is thrown. + * {@link WorkerHandle} whose {@link PeerHandle#id()} is a fresh host-unique opaque + * UUID (CB-519), deliberately decoupled from the herdr pane id: the id is the registry/routing + * key and must never collide across daemon processes on the same host, while the herdr pane id + * stays a launcher-private placement/teardown coordinate, remembered here so {@link #stop} + * can resolve the host-unique key back to its pane. When {@code spawnReadyTimeoutMs > 0}, + * blocks until the peer's herdr status is injectable or the timeout elapses; on timeout the + * pane is closed (no orphan) and a {@link PeerUnreachableException} is thrown. */ @Override public PeerHandle spawn(SpawnRequest req) { @@ -201,7 +214,9 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { if (spawnReadyTimeoutMs > 0) { waitUntilInjectableOrThrow(paneId); } - return new WorkerHandle(paneId, agent.terminalId()); + String id = UUID.randomUUID().toString(); + paneByAgentId.put(id, paneId); + return new WorkerHandle(id, agent.terminalId()); } @Override @@ -426,21 +441,31 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { // --- teardown ------------------------------------------------------------------------------ /** - * Tear a peer down by pane id: close the pane, and close its tab only when the peer is - * that tab's sole occupant. The single-pane check is what makes this safe regardless of how the - * peer was placed (or a placement-config change across a restart): a pane-placement peer sitting - * in one of the user's shared tabs has siblings, so its tab is never closed — we only ever - * remove a tab we created to hold one peer. + * Tear a peer down: close the pane, and close its tab only when the peer is that tab's + * sole occupant. The single-pane check is what makes this safe regardless of how the peer was + * placed (or a placement-config change across a restart): a pane-placement peer sitting in one + * of the user's shared tabs has siblings, so its tab is never closed — we only ever remove a + * tab we created to hold one peer. + * + *

{@code idOrPane} is the {@link PeerHandle#id()} of a peer this launcher spawned (CB-519's + * host-unique opaque UUID), resolved through {@link #paneByAgentId} to the pane it must tear + * down. An argument that is not one of our ids is treated as a raw herdr pane id — the + * {@link #reapOrphanWorkers() orphan-reap} and spawn-gate-timeout paths, plus any caller that + * passes a pane directly, keep working without an owning id. * *

Resolves the tab from the pane before closing it. An already-gone pane/tab * (repeated DELETE, crashed peer) is treated as success; any other failure propagates so a * genuinely failed teardown is not reported as done. */ @Override - public void stop(String paneId) { - // Teardown knows only the paneId, not which profile spawned it. Attempt tab cleanup when any + public void stop(String idOrPane) { + // Teardown knows only the pane, not which profile spawned it. Attempt tab cleanup when any // profile uses tab placement (so the bridge may have created a dedicated peer tab); the // single-occupant check below is what actually protects the user's shared tabs. + String paneId = paneByAgentId.remove(idOrPane); + if (paneId == null) { + paneId = idOrPane; // raw-pane fallback (reap, gate timeout, pane-addressed callers) + } WorkspaceControl.PaneLocation loc = usesTabPlacement() ? spaces.locatePane(paneId) : null; try { agents.close(paneId); diff --git a/bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java b/bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java index ae3dc49..8b999dc 100644 --- a/bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java +++ b/bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java @@ -31,6 +31,9 @@ public final class FakeHerdr implements HerdrClient { private String agentSendErrorCode = null; private volatile String agentStatus = "idle"; // steady-state agent.get status private volatile String readText = "worker transcript tail"; // canned agent.read output + private int pinnedStarts = 0; // how many upcoming agent.start calls report a fixed pane + private String pinnedStartTerminal; + private String pinnedStartPane; public FakeHerdr healthy(boolean h) { this.healthy = h; @@ -79,6 +82,19 @@ public final class FakeHerdr implements HerdrClient { return this; } + /** + * Force the next {@code n} {@code agent.start} calls to report this terminal/pane coordinate, + * instead of the fake's usual incrementing {@code term_new_n}/{@code w9:pW_n}. Lets a test make + * two spawns report the same herdr pane, to prove the host-unique id (CB-519) never + * collides on that coordinate. + */ + public FakeHerdr pinNextStarts(int n, String terminalId, String paneId) { + this.pinnedStarts = n; + this.pinnedStartTerminal = terminalId; + this.pinnedStartPane = paneId; + return this; + } + /** * Seed a named agent into {@code agent.list} (e.g. an orphaned worker for CB-117 reaper tests). @@ -175,12 +191,20 @@ public final class FakeHerdr implements HerdrClient { "agent_name_taken", null); } long n = busyAdjusted - agentNameTakenFor; - // The agent starts INTO the requested pane, so its pane_id echoes the param. + boolean pinned = pinnedStarts > 0; + if (pinned) { + pinnedStarts--; + } + // Protocol 19: the agent starts INTO the requested pane, so its pane_id normally + // echoes the param. A pin overrides both coordinates, which is the only way to + // make two spawns report one pane — what CB-519's collision test needs. + String terminal = pinned ? pinnedStartTerminal : ("term_new_" + n); + Object pane = pinned ? pinnedStartPane : p.get("pane_id"); yield mapper.readTree((""" {"type":"agent_started","agent":{ - "terminal_id":"term_new_%d","name":"claude","agent_status":"unknown", + "terminal_id":"%s","name":"claude","agent_status":"unknown", "workspace_id":"w9","tab_id":"w9:t2","pane_id":"%s"}}""") - .formatted(n, p.get("pane_id"))); + .formatted(terminal, pane)); } case "pane.split" -> mapper.readTree(""" {"type":"pane_info","pane":{"pane_id":"w1:pSplit","workspace_id":"w1", diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java index 44fa75b..d026846 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -216,12 +216,15 @@ class BridgeMcpTest { @Test void spawnReturnsTheNewWorkersSessionAndPane() { FakeHerdr h = new FakeHerdr(); - McpSchema.CallToolResult res = BridgeMcp.spawn( - sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null); + SessionManager sm = sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")); + McpSchema.CallToolResult res = BridgeMcp.spawn(sm, null); assertNotEquals(Boolean.TRUE, res.isError()); String out = textOf(res); assertTrue(out.contains("\"sessionId\":\"term_new_1\""), out); - assertTrue(out.contains("\"paneId\":\"w9:pRoot_1\""), out); + // CB-519: the "paneId" wire field now carries the host-unique opaque id, not the herdr pane. + WorkerSession s = sm.roster().getFirst(); + assertTrue(out.contains("\"paneId\":\"" + s.paneId() + "\""), out); + assertNotEquals("w9:pRoot_1", s.paneId(), "the id is decoupled from the herdr pane coordinate"); assertTrue(out.contains("\"status\":\"spawning\""), out); } diff --git a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java index eb6feab..81a52ca 100644 --- a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java @@ -28,6 +28,7 @@ import java.net.http.HttpResponse; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; @@ -156,7 +157,10 @@ class BridgedAppTest { HttpResponse res = req(port, "POST", "/workers"); assertEquals(201, res.statusCode()); JsonNode body = mapper.readTree(res.body()); - assertEquals("w9:pRoot_1", body.get("paneId").asText()); + // CB-519: the responded paneId is a host-unique opaque UUID, not the herdr pane coordinate. + String id = body.get("paneId").asText(); + assertNotEquals("w9:pRoot_1", id, "paneId is the host-unique id, not the herdr pane"); + assertDoesNotThrow(() -> UUID.fromString(id), "paneId must be a UUID: " + id); assertEquals("spawning", body.get("state").asText()); // Subscription boundary (protocol 19): tab.create carried base_url + token in its env diff --git a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java index 1c0bb79..282cc79 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java @@ -172,9 +172,11 @@ class SessionManagerTest { assertEquals(1, sessions.roster().size(), "only the fresh session remains"); assertEquals(fresh.paneId(), sessions.roster().getFirst().paneId()); + // The old session was the first spawn → pane w9:pW_1 (CB-519: the registry key is the + // uuid id, so teardown is asserted on the real pane coordinate). long paneCloseCount = herdr.calls.stream() .filter(c -> "pane.close".equals(c.method())) - .filter(c -> oldPane.equals(((Map) c.params()).get("pane_id"))) + .filter(c -> "w9:pW_1".equals(((Map) c.params()).get("pane_id"))) .count(); assertEquals(1, paneCloseCount, "the old worker was torn down"); } @@ -307,7 +309,7 @@ class SessionManagerTest { WorkerSession updated = sessions.get(session.paneId()).orElseThrow(); assertEquals(WorkerSession.State.DONE, updated.state(), "session finishes second turn"); assertEquals(2, updated.turnCount(), "turn count tracks both deliveries"); - long releaseCloseCount = paneCloseCallsFor(herdr, session.paneId()); + long releaseCloseCount = paneCloseCallsFor(herdr, "w9:pW_1"); // the real pane coordinate assertEquals(0, releaseCloseCount, "cap disabled — no forced release of the worker pane"); } @@ -330,7 +332,7 @@ class SessionManagerTest { assertTrue(sessions.get(session.paneId()).isEmpty(), "session released after cap reached"); assertTrue(sessions.roster().isEmpty(), "released session leaves roster"); - assertEquals(1, paneCloseCallsFor(herdr, session.paneId()), + assertEquals(1, paneCloseCallsFor(herdr, "w9:pW_1"), "forced release tears the worker pane down exactly once"); } @@ -351,9 +353,10 @@ class SessionManagerTest { assertTrue(sessions.roster().isEmpty(), "drain clears the roster"); assertTrue(sessions.get(ready.paneId()).isEmpty(), "ready session is released"); assertTrue(sessions.get(busy.paneId()).isEmpty(), "busy session is released after timeout"); - assertEquals(1, paneCloseCallsFor(herdr, ready.paneId()), + // ready is the first spawn → pane w9:pW_1, busy the second → w9:pW_2 (FakeHerdr order). + assertEquals(1, paneCloseCallsFor(herdr, "w9:pW_1"), "ready worker pane is torn down"); - assertEquals(1, paneCloseCallsFor(herdr, busy.paneId()), + assertEquals(1, paneCloseCallsFor(herdr, "w9:pW_2"), "busy worker pane is torn down"); } diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java index d4510c0..b0e68f7 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.function.Function; import static org.junit.jupiter.api.Assertions.*; @@ -258,14 +259,18 @@ class ClaudeCodeLauncherTest { // --- PeerHandle indirection ---------------------------------------------------------------- @Test - void spawnReturnsPeerHandleWithIdEqualToPaneId() { + void spawnReturnsPeerHandleWithHostUniqueOpaqueId() { FakeHerdr herdr = new FakeHerdr(); ClaudeCodeLauncher svc = service(herdr, List.of("ccs", "ltms-local"), null); PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null)); assertNotNull(handle, "spawn must return a non-null handle"); - assertEquals("w9:pRoot_1", handle.id(), "handle.id() must equal the agent's paneId"); + // CB-519: id() is a host-unique opaque UUID, decoupled from the herdr pane coordinate. + assertNotEquals("w9:pRoot_1", handle.id(), + "handle.id() must NOT be the herdr pane id"); + assertDoesNotThrow(() -> UUID.fromString(handle.id()), + "handle.id() must be a UUID: " + handle.id()); } @Test @@ -351,6 +356,40 @@ class ClaudeCodeLauncherTest { assertTrue(herdr.called("pane.close"), "stop via handle.id() must close the pane"); } + // --- CB-519: host-unique id, decoupled from the pane coordinate ------------------------------ + + @Test + void twoSpawnsOnTheSamePaneNeverCollideOnHostUniqueId() { + // Two spawns may be placed on the same herdr pane coordinate (e.g. a pane that was reused + // or re-reported after a restart); the host-unique id must not collide even then. + FakeHerdr herdr = new FakeHerdr().pinNextStarts(2, "term_shared", "w9:pShared"); + ClaudeCodeLauncher svc = service(herdr, List.of("ccs", "ltms-local"), null); + + PeerHandle a = svc.spawn(new SpawnRequest(null, null, null)); + PeerHandle b = svc.spawn(new SpawnRequest(null, null, null)); + + assertNotEquals(a.id(), b.id(), + "two spawns on the same pane coordinate get distinct host-unique ids"); + assertNotEquals("w9:pShared", a.id(), "id is not the pane coordinate"); + assertNotEquals("w9:pShared", b.id(), "id is not the pane coordinate"); + } + + @Test + void stopResolvesTheHostUniqueIdToThePaneThatSpawnedIt() { + // CB-519: id() != paneId, so stop(id) must tear down the exact pane the id names — and no + // other live peer's pane. + FakeHerdr herdr = new FakeHerdr(); // deterministic panes w9:pW_1, w9:pW_2 per spawn + ClaudeCodeLauncher svc = service(herdr, List.of("ccs", "ltms-local"), null); + + PeerHandle a = svc.spawn(new SpawnRequest(null, null, null)); + PeerHandle b = svc.spawn(new SpawnRequest(null, null, null)); + + svc.stop(b.id()); + + assertEquals(1, paneCloseCount(herdr, "w9:pW_2"), "stop(b.id()) closes only b's pane"); + assertEquals(0, paneCloseCount(herdr, "w9:pW_1"), "a's pane is untouched"); + } + // --- CB-306 spawn-readiness gate ----------------------------------------------------------- private static Map workerConfigMap(String profile, String mcpUrl) { @@ -386,7 +425,8 @@ class ClaudeCodeLauncherTest { PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null)); assertNotNull(handle, "spawn returns a handle when worker becomes injectable"); - assertEquals("w9:pRoot_1", handle.id(), "handle id matches the started pane"); + assertNotEquals("w9:pRoot_1", handle.id(), + "handle id is a host-unique opaque id, not the started pane"); assertEquals(0, paneCloseCount(herdr, "w9:pRoot_1"), "no pane.close when worker becomes injectable before timeout"); } @@ -445,8 +485,9 @@ class ClaudeCodeLauncherTest { PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null)); assertNotNull(handle, "spawn still succeeds with zero timeout"); - assertEquals(0, paneCloseCount(herdr, handle.id()), + assertEquals(0, paneCloseCount(herdr, "w9:pW_1"), "no orphan pane close from the gate path"); + assertDoesNotThrow(() -> UUID.fromString(handle.id())); } // --- CB-511: worker environment seeding ----------------------------------------------------- diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/CompositePeerLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/CompositePeerLauncherTest.java index dfc7227..c888fc1 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/CompositePeerLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/CompositePeerLauncherTest.java @@ -127,15 +127,18 @@ class CompositePeerLauncherTest { @Test void stopTearsDownAPaneSpawnedThroughTheComposite() { + // CB-519: handle.id() is a host-unique opaque UUID, not the herdr pane — stop(id) must + // resolve it through the owning adapter down to the actual pane coordinate it spawned. FakeHerdr herdr = new FakeHerdr(); PeerLauncher composite = composite(herdr); PeerHandle handle = composite.spawn(new SpawnRequest("gemini", null, null)); + assertNotEquals("w9:pW_1", handle.id(), "the id is decoupled from the pane coordinate"); composite.stop(handle.id()); assertTrue(herdr.calls.stream() .anyMatch(c -> c.method().equals("pane.close") - && handle.id().equals(((Map) c.params()).get("pane_id"))), - "stop routes to the spawning adapter and closes that worker's pane"); + && "w9:pW_1".equals(((Map) c.params()).get("pane_id"))), + "stop routes to the spawning adapter and closes exactly that worker's pane"); } @Test