From 045d22972895a5031d4951a5f8fe5c836ab05809 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Mon, 31 Aug 2026 09:20:14 +0700 Subject: [PATCH 1/2] CB-185: fix two blockers to switching on memberHerdrSocket (#185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. CompositePeerLauncher.stop() was permanently un-stoppable for any member that survived a daemon restart, because spawnedBy is in-memory only. On a cache miss with more than one configured herdr daemon, probe each distinct daemon's agent.list() for the pane instead of refusing outright: exactly one owner routes and caches; zero owners is treated as already-stopped (a no-op, matching the tolerance HerdrPeerLauncher already gives an already-gone pane); more than one owner is the genuine per-daemon-pane-id ambiguity and still throws. 2. FleetApp#healthz always reported the LEAD daemon's herdr version/ protocol even when a second (member) daemon was configured, so a member-daemon protocol mismatch was invisible behind a green /healthz while every spawn silently failed. Added a separate "member" key alongside the unchanged "herdr" key, and a "protocolMismatch" flag when the two differ. Verified scripts/redeploy-fleetd.sh and scripts/rename-checkout.sh only check the HTTP status code and print the body verbatim — neither parses a specific field — so adding a key is safe. Both fixes are covered by tests written to fail without the fix (verified by reverting each fix and watching the new tests fail, then restoring). Full `mvn clean install`: 1018 tests, 0 failures, 0 errors. --- .../fleet/member/CompositePeerLauncher.java | 63 ++++++++++++++++-- .../java/dev/ltms/fleet/rest/FleetApp.java | 33 ++++++++-- .../java/dev/ltms/fleet/herdr/FakeHerdr.java | 15 ++++- .../member/CompositePeerLauncherTest.java | 64 ++++++++++++++++++- .../fleet/rest/FleetAppTwoDaemonTest.java | 51 +++++++++++++++ 5 files changed, 213 insertions(+), 13 deletions(-) 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 70cf73e..c329741 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java @@ -440,12 +440,23 @@ public final class CompositePeerLauncher implements PeerLauncher { public void stop(String id) { HerdrPeerLauncher d = spawnedBy.get(id); if (d == null) { - if (herdrDaemonCount() != 1) { - throw new IllegalArgumentException("ambiguous paneId '" + id - + "': no owning herdr daemon was recorded"); + if (herdrDaemonCount() == 1) { + log.debug("stop({}) — no recorded owner in a single-daemon fleet", id); + d = delegates.getFirst(); + } else { + d = probeOwner(id); + if (d == null) { + // No configured herdr daemon has ever heard of this pane. CB-185 blocker 1: this + // is the normal case right after a daemon restart empties spawnedBy for a member + // that has ALREADY been torn down since — the caller retried a stop that already + // succeeded. Nothing to close and no owner to cache; matching the tolerance + // HerdrPeerLauncher#stop already gives an already-gone pane (agent.close swallows + // that as success), stop() here is a no-op rather than a refusal. + log.debug("stop({}) — no configured herdr daemon knows this pane; " + + "treating as already stopped", id); + return; + } } - log.debug("stop({}) — no recorded owner in a single-daemon fleet", id); - d = delegates.getFirst(); } // Drop the owner record only after the delegate accepted the stop. Removing it first meant a // delegate that threw left the pane alive with its owner forgotten, so the retry fell into @@ -454,6 +465,48 @@ public final class CompositePeerLauncher implements PeerLauncher { spawnedBy.remove(id); } + /** + * CB-185 blocker 1: recover a spawnedBy cache miss by asking every distinct herdr daemon which + * one actually knows {@code id} — the fix for "after a restart, every surviving member becomes + * un-stoppable" (spawnedBy is in-memory only, so a restart empties it, and members intentionally + * outlive the daemon). + * + *

Grouped by daemon identity, not by delegate, for the same reason {@link #list()} groups + * that way: two adapters (claude-code, opencode) sharing one herdr connection would otherwise be + * probed twice, and a pane on their shared daemon would look owned by two adapters instead of + * one daemon. + * + * @return the owning delegate — cached into {@link #spawnedBy} so the next call is free — or + * {@code null} when no daemon knows the pane + * @throws IllegalArgumentException when more than one daemon claims the pane: pane ids are + * per-daemon counters, so two daemons really can both hold, say, {@code w1:p1}, and there + * is no way to tell which one the caller means + */ + private HerdrPeerLauncher probeOwner(String id) { + Map byDaemon = new IdentityHashMap<>(); + for (HerdrPeerLauncher delegate : delegates) { + byDaemon.putIfAbsent(delegate.herdr(), delegate); + } + List owners = new ArrayList<>(); + for (HerdrPeerLauncher representative : byDaemon.values()) { + boolean knows = representative.list().stream() + .anyMatch(a -> id.equals(a.paneId())); + if (knows) { + owners.add(representative); + } + } + if (owners.size() > 1) { + throw new IllegalArgumentException("ambiguous paneId '" + id + + "': no owning herdr daemon was recorded"); + } + if (owners.isEmpty()) { + return null; + } + HerdrPeerLauncher owner = owners.get(0); + spawnedBy.put(id, owner); + return owner; + } + /** * 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. diff --git a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java index 16a0cf0..5e479f1 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java @@ -214,6 +214,17 @@ public final class FleetApp { * second daemon configured, a member daemon that is down must not be masked by a healthy lead * daemon — every spawn goes through the member daemon and would otherwise fail silently behind * a green {@code /healthz}. + * + *

CB-185 blocker 2: the {@code herdr} key always carries the lead daemon's + * version/protocol, unchanged, because two consumers — {@code scripts/redeploy-fleetd.sh} and + * {@code scripts/rename-checkout.sh} — read this endpoint already (both only check the HTTP + * status code and print the body verbatim; neither parses a specific field, so adding a key + * alongside {@code herdr} is safe). But it is the member daemon's protocol that decides + * whether a spawn works, so when a second daemon is configured its version/protocol is reported + * too, under a separate {@code member} key — never folded into {@code herdr}, which would make a + * mismatch invisible to whichever consumer only reads that key. If the two protocol numbers + * differ, {@code protocolMismatch: true} calls it out explicitly rather than leaving it to be + * spotted by comparing two numbers by eye. */ private void healthz(Context ctx) { JsonNode pong; @@ -226,9 +237,15 @@ public final class FleetApp { "detail", e.getMessage())); return; } + Map body = new LinkedHashMap<>(); + body.put("status", "ok"); + body.put("herdr", Map.of( + "version", pong.path("version").asText(""), + "protocol", pong.path("protocol").asInt())); if (memberHerdr != herdr) { + JsonNode memberPong; try { - memberHerdr.call("ping"); + memberPong = memberHerdr.call("ping"); } catch (HerdrException e) { ctx.status(503).json(Map.of( "status", "degraded", @@ -236,12 +253,16 @@ public final class FleetApp { "detail", e.getMessage())); return; } + int leadProtocol = pong.path("protocol").asInt(); + int memberProtocol = memberPong.path("protocol").asInt(); + body.put("member", Map.of( + "version", memberPong.path("version").asText(""), + "protocol", memberProtocol)); + if (leadProtocol != memberProtocol) { + body.put("protocolMismatch", true); + } } - ctx.status(200).json(Map.of( - "status", "ok", - "herdr", Map.of( - "version", pong.path("version").asText(""), - "protocol", pong.path("protocol").asInt()))); + ctx.status(200).json(body); } /** diff --git a/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java b/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java index 591b39c..b1ff5f2 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java +++ b/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java @@ -31,6 +31,8 @@ public final class FakeHerdr implements HerdrClient { */ public final List calls = new CopyOnWriteArrayList<>(); private boolean healthy = true; + private String pingVersion = "0.8.0"; + private int pingProtocol = 19; private final List extraWorkspaces = new ArrayList<>(); private final List extraAgents = new ArrayList<>(); /** workspaceId → extra tabs that {@code tab.list} reports for it (CB-558 lead scans). */ @@ -52,6 +54,16 @@ public final class FakeHerdr implements HerdrClient { return this; } + /** + * Make {@code ping} report this version/protocol instead of the default 0.8.0/19 — CB-185 + * blocker 2's fixture for a lead and a member daemon running mismatched herdr versions. + */ + public FakeHerdr pingReports(String version, int protocol) { + this.pingVersion = version; + this.pingProtocol = protocol; + return this; + } + /** Reject the first {@code n} {@code agent.start} calls with {@code agent_name_taken}. */ public FakeHerdr agentNameTakenTimes(int n) { this.agentNameTakenFor = n; @@ -166,7 +178,8 @@ public final class FakeHerdr implements HerdrClient { try { return switch (method) { case "ping" -> mapper.readTree( - "{\"type\":\"pong\",\"version\":\"0.8.0\",\"protocol\":19}"); + ("{\"type\":\"pong\",\"version\":\"%s\",\"protocol\":%d}") + .formatted(pingVersion, pingProtocol)); case "workspace.list" -> mapper.readTree((""" {"type":"workspace_list","workspaces":[ {"workspace_id":"w1","label":"dev-mgnl","focused":true,"pane_count":7,"agent_status":"unknown"}, 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 ade228e..cf6cbe7 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java @@ -346,8 +346,13 @@ class CompositePeerLauncherTest { @Test void stopRejectsAnUnownedPaneIdWhenMultipleDaemonsCouldOwnIt() { + // CB-185 blocker 1: genuine ambiguity — pane ids are per-daemon counters, so two daemons + // can each really hold an agent at "w1:p1". Neither claims ownership through spawnedBy + // (empty, as after a restart), so the probe must find BOTH and refuse rather than guess. + FakeHerdr first = new FakeHerdr().withAgent("x", "term_x", "w1:p1", "w1:t1"); + FakeHerdr second = new FakeHerdr().withAgent("y", "term_y", "w1:p1", "w1:t1"); PeerLauncher composite = new CompositePeerLauncher( - List.of(claudeAdapter(new FakeHerdr()), opencodeAdapter(new FakeHerdr())), "claude"); + List.of(claudeAdapter(first), opencodeAdapter(second)), "claude"); IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> composite.stop("w1:p1")); @@ -355,6 +360,63 @@ class CompositePeerLauncherTest { assertEquals("ambiguous paneId 'w1:p1': no owning herdr daemon was recorded", error.getMessage()); } + @Test + void stopOnAPaneNoConfiguredDaemonKnowsIsTreatedAsAlreadyStopped() { + // CB-185 blocker 1, the zero-owner branch: spawnedBy is empty (as after a restart) and + // neither daemon's agent.list mentions this pane at all — it is already gone. A retried + // stop() on an already-gone pane must succeed quietly, not refuse forever. + FakeHerdr first = new FakeHerdr(); + FakeHerdr second = new FakeHerdr(); + PeerLauncher composite = new CompositePeerLauncher( + List.of(claudeAdapter(first), opencodeAdapter(second)), "claude"); + + assertDoesNotThrow(() -> composite.stop("w1:p1")); + + assertFalse(first.called("pane.close"), "no owner was found, so no delegate is told to close anything"); + assertFalse(second.called("pane.close"), "no owner was found, so no delegate is told to close anything"); + } + + @Test + void stopWithEmptySpawnedByResolvesTheOwnerThroughAProbeAndSkipsTheOtherDaemon() { + // CB-185 blocker 1, the main fix: after a restart spawnedBy is empty for every surviving + // member. stop() must still find the one daemon that actually knows the pane and route + // only to it — never touching the daemon that never held it. + FakeHerdr first = new FakeHerdr().withAgent("x", "term_x", "w1:p1", "w1:t1"); + FakeHerdr second = new FakeHerdr(); + PeerLauncher composite = new CompositePeerLauncher( + List.of(claudeAdapter(first), opencodeAdapter(second)), "claude"); + + composite.stop("w1:p1"); + + assertTrue(first.calls.stream().anyMatch(c -> c.method().equals("pane.close") + && "w1:p1".equals(((Map) c.params()).get("pane_id"))), + "the daemon that actually knows the pane closes it"); + assertFalse(second.called("pane.close"), "the daemon that never held the pane is never touched"); + } + + @Test + void aProbedOwnerIsCachedSoARetryAfterAFailedStopNeedsNoSecondProbe() { + // CB-185 blocker 1: the probe's whole point is to be cheap on repeat — a failed stop (e.g. + // "pane_busy") must not force another agent.list() round trip on every retry. + FakeHerdr first = new FakeHerdr().withAgent("x", "term_x", "w1:p1", "w1:t1") + .paneCloseFailsWith("pane_busy"); + FakeHerdr second = new FakeHerdr(); + CompositePeerLauncher composite = new CompositePeerLauncher( + List.of(claudeAdapter(first), opencodeAdapter(second)), "claude"); + + assertThrows(HerdrException.class, () -> composite.stop("w1:p1")); + long listCallsAfterFirst = first.calls.stream().filter(c -> c.method().equals("agent.list")).count() + + second.calls.stream().filter(c -> c.method().equals("agent.list")).count(); + assertTrue(listCallsAfterFirst > 0, "the first stop needed a probe"); + + assertThrows(HerdrException.class, () -> composite.stop("w1:p1"), + "still failing on the retry, but through the cached owner"); + long listCallsAfterSecond = first.calls.stream().filter(c -> c.method().equals("agent.list")).count() + + second.calls.stream().filter(c -> c.method().equals("agent.list")).count(); + assertEquals(listCallsAfterFirst, listCallsAfterSecond, + "the retry is served from the cache — no additional agent.list probe"); + } + @Test void opencodeContextResetIsANoOpAndWarnsOnlyOnce() { FakeHerdr herdr = new FakeHerdr(); diff --git a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppTwoDaemonTest.java b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppTwoDaemonTest.java index efbba3d..4ece135 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppTwoDaemonTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppTwoDaemonTest.java @@ -96,4 +96,55 @@ class FleetAppTwoDaemonTest { long calls = shared.calls.stream().filter(c -> c.method().equals("workspace.list")).count(); assertEquals(1, calls, "single-daemon deployment must call workspace.list exactly once"); } + + // ── CB-185 blocker 2: /healthz must report the MEMBER daemon's protocol too ──────────────── + + @Test + void healthzReportsBothDaemonsWhenTheirProtocolsDiffer() throws Exception { + FakeHerdr lead = new FakeHerdr().pingReports("0.8.0", 19); + FakeHerdr member = new FakeHerdr().pingReports("0.7.0", 18); + int port = start(lead, member); + + HttpResponse res = get(port, "/healthz"); + + assertEquals(200, res.statusCode(), res.body()); + assertTrue(res.body().contains("\"protocol\":19"), + "the herdr key keeps reporting the LEAD's protocol, unchanged: " + res.body()); + assertTrue(res.body().contains("\"member\""), "a separate member key is present: " + res.body()); + assertTrue(res.body().contains("\"protocol\":18"), + "the member key reports the member daemon's own protocol: " + res.body()); + assertTrue(res.body().contains("\"protocolMismatch\":true"), + "a differing protocol is called out explicitly, not left to be spotted by eye: " + res.body()); + } + + @Test + void healthzReportsBothDaemonsWithNoMismatchWhenProtocolsMatch() throws Exception { + int port = start(new FakeHerdr(), new FakeHerdr()); + + HttpResponse res = get(port, "/healthz"); + + assertEquals(200, res.statusCode(), res.body()); + assertTrue(res.body().contains("\"member\""), "the member key is present whenever a second daemon " + + "is configured, even when the protocols happen to agree: " + res.body()); + assertFalse(res.body().contains("protocolMismatch"), + "matching protocols must not raise a mismatch flag: " + res.body()); + } + + @Test + void healthzWithOneDaemonCarriesNoMemberOrMismatchKey() throws Exception { + // The single-daemon deployment (no memberHerdrSocket) must see no change at all beyond the + // historical body: no "member" key, no "protocolMismatch" key. (Map.of()'s own key order is + // JVM-salted regardless of this fix, so this checks content, not exact key order.) + FakeHerdr shared = new FakeHerdr(); + int port = start(shared, shared); + + HttpResponse res = get(port, "/healthz"); + + assertEquals(200, res.statusCode()); + assertTrue(res.body().contains("\"status\":\"ok\""), res.body()); + assertTrue(res.body().contains("\"protocol\":19"), res.body()); + assertTrue(res.body().contains("\"version\":\"0.8.0\""), res.body()); + assertFalse(res.body().contains("\"member\""), "no second daemon configured, so no member key: " + res.body()); + assertFalse(res.body().contains("protocolMismatch"), res.body()); + } } -- 2.52.0 From bc99d647862aa6abb324fe55295367b9b7f1fece Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Mon, 31 Aug 2026 09:28:29 +0700 Subject: [PATCH 2/2] CB-185: fix ambiguous-pane message and unreachable-daemon abort in probeOwner (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lead review of PR #196 found two issues in CompositePeerLauncher.probeOwner: 1. The "more than one daemon claims this pane" throw kept the old pre-fix message ("no owning herdr daemon was recorded"), which was only true of the code it replaced. Reworded to say what actually happened: N configured herdr daemons report this pane, so it is genuinely ambiguous. Updated the one test pinning the old string. 2. probeOwner let list() propagate straight out of the probe loop, so one unreachable daemon aborted the whole probe and made a pane on a DIFFERENT, healthy daemon un-stoppable too — resurrecting the exact bug blocker 1 fixes. Now catches HerdrException per daemon, logs the exception class only, and treats that daemon as not knowing the pane so probing continues. New test proves this: verified it fails with the try/catch removed (HerdrException propagates and the stop that should succeed via the healthy daemon throws instead), then restored. Full mvn clean install: 1019 tests, 0 failures, 0 errors. --- .../fleet/member/CompositePeerLauncher.java | 32 +++++++++++++++---- .../member/CompositePeerLauncherTest.java | 21 +++++++++++- 2 files changed, 45 insertions(+), 8 deletions(-) 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 c329741..ee83b17 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.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.HerdrClient; +import dev.ltms.fleet.herdr.HerdrException; import dev.ltms.fleet.peer.Capability; import dev.ltms.fleet.peer.MemberRole; import dev.ltms.fleet.peer.PeerHandle; @@ -46,9 +47,13 @@ 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 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.
  • + * (recorded at spawn time). A pane the composite never spawned, or one whose record was lost + * to a daemon restart (CB-185 blocker 1 — {@link #spawnedBy} is in-memory only), can use the + * fallback route in a one-daemon fleet. With more than one herdr daemon, {@link #probeOwner} + * asks each configured daemon which one actually knows the pane: exactly one match routes + * (and caches); no match is treated as already-gone; more than one match is a genuine + * ambiguity (pane ids are per-daemon counters, so two daemons really can both hold, say, + * {@code w1:p1}) and stop refuses 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 (owning daemon, pane id): delegates that share * one herdr connection report the same global agent set, but two daemons can each hold a pane @@ -476,6 +481,11 @@ public final class CompositePeerLauncher implements PeerLauncher { * probed twice, and a pane on their shared daemon would look owned by two adapters instead of * one daemon. * + *

    A daemon that fails to answer {@code list()} (e.g. it is down) is treated as "does not know + * this pane" rather than aborting the whole probe — one unreachable daemon must never make a + * pane that a different, healthy daemon actually owns un-stoppable too, which would + * resurrect the exact bug this method exists to fix. + * * @return the owning delegate — cached into {@link #spawnedBy} so the next call is free — or * {@code null} when no daemon knows the pane * @throws IllegalArgumentException when more than one daemon claims the pane: pane ids are @@ -489,15 +499,23 @@ public final class CompositePeerLauncher implements PeerLauncher { } List owners = new ArrayList<>(); for (HerdrPeerLauncher representative : byDaemon.values()) { - boolean knows = representative.list().stream() - .anyMatch(a -> id.equals(a.paneId())); + List agents; + try { + agents = representative.list(); + } catch (HerdrException e) { + log.warn("stop({}) probe: a configured herdr daemon was unreachable ({}); " + + "treating it as not knowing this pane", id, e.getClass().getSimpleName()); + continue; + } + boolean knows = agents.stream().anyMatch(a -> id.equals(a.paneId())); if (knows) { owners.add(representative); } } if (owners.size() > 1) { - throw new IllegalArgumentException("ambiguous paneId '" + id - + "': no owning herdr daemon was recorded"); + throw new IllegalArgumentException("ambiguous paneId '" + id + "': " + + owners.size() + " configured herdr daemons report this pane — " + + "no way to tell which one the caller means"); } if (owners.isEmpty()) { return null; 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 cf6cbe7..1e791d5 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java @@ -357,7 +357,8 @@ class CompositePeerLauncherTest { IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> composite.stop("w1:p1")); - assertEquals("ambiguous paneId 'w1:p1': no owning herdr daemon was recorded", error.getMessage()); + assertEquals("ambiguous paneId 'w1:p1': 2 configured herdr daemons report this pane — " + + "no way to tell which one the caller means", error.getMessage()); } @Test @@ -394,6 +395,24 @@ class CompositePeerLauncherTest { assertFalse(second.called("pane.close"), "the daemon that never held the pane is never touched"); } + @Test + void aProbeSurvivesOneUnreachableDaemonAndStillFindsTheOwnerOnTheOtherOne() { + // CB-185 blocker 1 (lead review): a daemon that is DOWN while we probe must not abort the + // whole probe — the pane the OPERATOR actually wants stopped can live on a different, + // healthy daemon, and that pane must not become un-stoppable because a third one is down. + FakeHerdr down = new FakeHerdr().healthy(false); + FakeHerdr owner = new FakeHerdr().withAgent("x", "term_x", "w1:p1", "w1:t1"); + PeerLauncher composite = new CompositePeerLauncher( + List.of(claudeAdapter(down), opencodeAdapter(owner)), "claude"); + + assertDoesNotThrow(() -> composite.stop("w1:p1"), + "the unreachable daemon must be skipped, not fail the whole stop"); + + assertTrue(owner.calls.stream().anyMatch(c -> c.method().equals("pane.close") + && "w1:p1".equals(((Map) c.params()).get("pane_id"))), + "the healthy daemon that actually owns the pane still closes it"); + } + @Test void aProbedOwnerIsCachedSoARetryAfterAFailedStopNeedsNoSecondProbe() { // CB-185 blocker 1: the probe's whole point is to be cheap on repeat — a failed stop (e.g. -- 2.52.0