diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java
index 823d9ae..c63aa07 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java
@@ -498,8 +498,11 @@ public final class Fleetd {
// MCP server face (CB-105): fleet_send/fleet_reply/fleet_status, mounted at /mcp.
// Caller identity is resolved from the connection (peer PID → herdr pane), not arguments.
+ // CB-185: a caller's pane can live on either daemon (a lead's on the lead daemon, a
+ // member's on the member daemon) — search both, lead first. Collapses to one scan when
+ // memberHerdrSocket is unset (herdr == memberHerdr).
ConnectionIdentity identity = new ConnectionIdentity(
- new PaneLocator(memberHerdr), new LsofPeerPidLookup(), new LsofProcessCwdLookup());
+ new PaneLocator(herdr, memberHerdr), new LsofPeerPidLookup(), new LsofProcessCwdLookup());
// CB-501: one resolver behind both entry paths. Worker identity still comes from the
// connection and is never token-gated, so enabling token mode cannot lock the fleet out.
@@ -599,7 +602,9 @@ public final class Fleetd {
router.close();
}));
- Javalin app = new FleetApp(herdr, workers, sessions, messages, presence, mcp.servlet(),
+ // CB-185: give FleetApp both daemons — /healthz must require both to answer and
+ // GET /sessions must merge across both, or a down/unpolled member daemon is invisible.
+ Javalin app = new FleetApp(herdr, memberHerdr, workers, sessions, messages, presence, mcp.servlet(),
callers, metrics, deliverable).build();
app.start(cfg.bind().host(), cfg.bind().port());
log.info("fleetd listening on {}:{}, herdr socket {}",
diff --git a/fleetd/src/main/java/dev/ltms/fleet/herdr/PaneLocator.java b/fleetd/src/main/java/dev/ltms/fleet/herdr/PaneLocator.java
index 9e2e355..f2a0af4 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/herdr/PaneLocator.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/herdr/PaneLocator.java
@@ -2,6 +2,7 @@ package dev.ltms.fleet.herdr;
import com.fasterxml.jackson.databind.JsonNode;
+import java.util.List;
import java.util.Map;
/**
@@ -13,33 +14,61 @@ import java.util.Map;
*
herdr owns the PID→pane truth: {@code pane.process_info} reports each pane's {@code shell_pid}
* and foreground process PIDs. This scans agent panes; a spawn-time {@code pid→terminal} cache is
* the obvious optimization once wired into {@code ClaudeCodeLauncher}.
+ *
+ *
CB-185 split the fleet across two herdr daemons — lead operations on one, members on the
+ * other ({@code memberHerdrSocket}). A caller's pane can live on either daemon (a lead's
+ * MCP connection resolves against the lead daemon; a member's against the member daemon), so this
+ * must be able to search more than one client. {@link #PaneLocator(HerdrClient, HerdrClient)}
+ * searches the lead client first, then the member client, and collapses to a single scan when the
+ * two are the same object (the historical single-daemon deployment).
*/
public final class PaneLocator {
- private final HerdrClient herdr;
+ private final List herdrs;
+ /** Search only this client — the single-daemon deployment. */
public PaneLocator(HerdrClient herdr) {
- this.herdr = herdr;
+ this.herdrs = List.of(herdr);
+ }
+
+ /**
+ * Search {@code lead} first, then {@code member} — the two-daemon deployment (CB-185). When
+ * the caller passes the same client for both (no {@code memberHerdrSocket} configured), this
+ * collapses to one client and one scan, exactly {@link #PaneLocator(HerdrClient)}'s behaviour.
+ */
+ public PaneLocator(HerdrClient lead, HerdrClient member) {
+ this.herdrs = lead == member ? List.of(lead) : List.of(lead, member);
}
/**
* The {@code terminal_id} of the agent pane whose process tree contains {@code pid}, or
- * {@code null} if no agent pane owns it (e.g. the caller is the primary, or off-host).
+ * {@code null} if no agent pane on any searched daemon owns it (e.g. the caller is the
+ * primary, or off-host).
*/
public String terminalForPid(long pid) {
if (pid <= 0) {
return null;
}
+ for (HerdrClient herdr : herdrs) {
+ String terminal = terminalForPid(herdr, pid);
+ if (terminal != null) {
+ return terminal;
+ }
+ }
+ return null;
+ }
+
+ private static String terminalForPid(HerdrClient herdr, long pid) {
for (JsonNode pane : herdr.call("pane.list", Map.of()).path("panes")) {
String paneId = pane.path("pane_id").asText(null);
- if (paneId != null && paneOwnsPid(paneId, pid)) {
+ if (paneId != null && paneOwnsPid(herdr, paneId, pid)) {
return pane.path("terminal_id").asText(null);
}
}
return null;
}
- private boolean paneOwnsPid(String paneId, long pid) {
+ private static boolean paneOwnsPid(HerdrClient herdr, String paneId, long pid) {
JsonNode info;
try {
info = herdr.call("pane.process_info", Map.of("pane_id", paneId)).path("process_info");
diff --git a/fleetd/src/main/java/dev/ltms/fleet/inject/StatusPoller.java b/fleetd/src/main/java/dev/ltms/fleet/inject/StatusPoller.java
index 5dda76c..a2e7966 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/inject/StatusPoller.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/inject/StatusPoller.java
@@ -47,6 +47,10 @@ public final class StatusPoller {
this.agents = null;
this.router = router;
this.injector = injector;
+ // CB-185: this refiner's own AgentControl (member) is only a default for the legacy 2-arg
+ // refine() overload — the loop below always calls the 3-arg refine(target, raw, control)
+ // with the per-target control from router.agentsFor(target), so a lead target is refined
+ // against the LEAD daemon even though this field points at the member one.
this.refiner = new StatusRefiner(router.memberAgents());
this.intervalMillis = intervalMillis;
}
@@ -67,8 +71,11 @@ public final class StatusPoller {
try {
// herdr's agent_status can misreport a settled worker as `unknown`; refine it
// against the pane content before it drives delivery/completion (CB-115).
+ // CB-185: refine THROUGH the same control the raw status came from — a router
+ // splits lead/member targets across two herdr daemons, and reading a lead's pane
+ // through the (fixed) member refiner never finds it, wedging that lead at UNKNOWN.
AgentControl control = router != null ? router.agentsFor(target) : agents;
- AgentStatus status = refiner.refine(target, control.status(target));
+ AgentStatus status = refiner.refine(target, control.status(target), control);
injector.onStatus(target, status);
} catch (HerdrException e) {
// The worker's agent is gone — stop trying and unblock its waiters.
diff --git a/fleetd/src/main/java/dev/ltms/fleet/inject/StatusRefiner.java b/fleetd/src/main/java/dev/ltms/fleet/inject/StatusRefiner.java
index bc55d02..309b9b5 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/inject/StatusRefiner.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/inject/StatusRefiner.java
@@ -41,15 +41,32 @@ public final class StatusRefiner {
}
/**
- * Return a trustworthy status for {@code target}. Any non-{@code UNKNOWN} {@code raw} is returned
- * unchanged; an {@code UNKNOWN} triggers a pane read and content classification. A read failure
- * leaves it {@code UNKNOWN} (the safe default: no delivery, and the stall path still applies).
+ * Return a trustworthy status for {@code target}, reading its pane through this refiner's own
+ * {@link AgentControl}. Equivalent to {@link #refine(String, AgentStatus, AgentControl)} with
+ * that control — kept for callers that only ever talk to one herdr daemon.
*/
public AgentStatus refine(String target, AgentStatus raw) {
+ return refine(target, raw, agents);
+ }
+
+ /**
+ * Return a trustworthy status for {@code target}. Any non-{@code UNKNOWN} {@code raw} is returned
+ * unchanged; an {@code UNKNOWN} triggers a pane read (through {@code control}) and content
+ * classification. A read failure leaves it {@code UNKNOWN} (the safe default: no delivery, and
+ * the stall path still applies).
+ *
+ * CB-185: {@code control} must be the {@link AgentControl} for the same daemon the
+ * raw status was sampled from — a router splits lead and member targets across two herdr
+ * daemons, and reading a lead's pane through the member client (or vice versa) fails to find
+ * the pane and leaves the target wedged at {@code UNKNOWN} forever. Callers that route per
+ * target (e.g. {@code StatusPoller}) must pass that target's control explicitly rather than
+ * relying on the control fixed at construction.
+ */
+ public AgentStatus refine(String target, AgentStatus raw, AgentControl control) {
if (raw != AgentStatus.UNKNOWN) return raw;
String pane;
try {
- pane = agents.read(target, PROBE_SOURCE);
+ pane = control.read(target, PROBE_SOURCE);
} catch (RuntimeException e) {
log.debug("status refine read for {} failed; leaving UNKNOWN: {}", target, e.getMessage());
return AgentStatus.UNKNOWN;
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 e634390..16a0cf0 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java
@@ -55,7 +55,8 @@ public final class FleetApp {
/** Context attribute under which the resolved caller is stashed by the auth filter. */
private static final String CALLER = "fleetd.caller";
- private final HerdrClient herdr;
+ private final HerdrClient herdr; // lead daemon
+ private final HerdrClient memberHerdr; // CB-185: member daemon (same object when unconfigured)
private final PeerLauncher workers;
private final SessionManager sessions; // CB-301: authoritative session registry
private final MessageService messages;
@@ -95,7 +96,23 @@ public final class FleetApp {
MessageService messages, MemberPresence presence,
HttpServlet mcpServlet, CallerResolver auth, Metrics metrics,
Predicate deliverable) {
+ this(herdr, herdr, workers, sessions, messages, presence, mcpServlet, auth, metrics, deliverable);
+ }
+
+ /**
+ * @param herdr the lead daemon's client
+ * @param memberHerdr the member daemon's client (CB-185); pass the same instance as
+ * {@code herdr} for a single-daemon deployment — {@code healthz}/{@code
+ * sessions} then make exactly one herdr call each, unchanged from before
+ * the two-daemon router existed
+ * @param deliverable the injector's readiness gate, shared so status reports its real result
+ */
+ public FleetApp(HerdrClient herdr, HerdrClient memberHerdr, PeerLauncher workers, SessionManager sessions,
+ MessageService messages, MemberPresence presence,
+ HttpServlet mcpServlet, CallerResolver auth, Metrics metrics,
+ Predicate deliverable) {
this.herdr = herdr;
+ this.memberHerdr = memberHerdr != null ? memberHerdr : herdr;
this.workers = workers;
this.sessions = sessions;
this.messages = messages;
@@ -190,30 +207,64 @@ public final class FleetApp {
ctx.status(200).contentType("text/plain; version=0.0.4; charset=utf-8").result(metrics.render());
}
- /** Liveness + herdr reachability. 200 when herdr answers ping, 503 otherwise. */
+ /**
+ * Liveness + herdr reachability. 200 only when BOTH daemons answer ping — 503 otherwise
+ * (CB-185). With no {@code memberHerdrSocket} configured {@code memberHerdr == herdr}, so this
+ * makes exactly the one {@code ping} call it always did and reports the same body; with a
+ * 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}.
+ */
private void healthz(Context ctx) {
+ JsonNode pong;
try {
- JsonNode pong = herdr.call("ping");
- ctx.status(200).json(Map.of(
- "status", "ok",
- "herdr", Map.of(
- "version", pong.path("version").asText(""),
- "protocol", pong.path("protocol").asInt())));
+ pong = herdr.call("ping");
} catch (HerdrException e) {
ctx.status(503).json(Map.of(
"status", "degraded",
"herdr", "unreachable",
"detail", e.getMessage()));
+ return;
}
+ if (memberHerdr != herdr) {
+ try {
+ memberHerdr.call("ping");
+ } catch (HerdrException e) {
+ ctx.status(503).json(Map.of(
+ "status", "degraded",
+ "herdr", "member unreachable",
+ "detail", e.getMessage()));
+ return;
+ }
+ }
+ ctx.status(200).json(Map.of(
+ "status", "ok",
+ "herdr", Map.of(
+ "version", pong.path("version").asText(""),
+ "protocol", pong.path("protocol").asInt())));
}
- /** Sessions view derived from herdr {@code workspace.list} (one workspace → one row). */
+ /**
+ * Sessions view derived from herdr {@code workspace.list} (one workspace → one row), merged
+ * across both daemons (CB-185). With no {@code memberHerdrSocket} configured {@code
+ * memberHerdr == herdr}, so this calls {@code workspace.list} exactly once, same as before the
+ * router existed; with a second daemon configured, calling it twice would silently drop every
+ * member workspace (they live on the member daemon only).
+ */
private void sessions(Context ctx) {
if (!allow(ctx, Authz.Action.READ, null)) {
return;
}
- JsonNode result = herdr.call("workspace.list");
List