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 0527bc7..10bbdcc 100644
--- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java
+++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java
@@ -231,7 +231,7 @@ public final class BridgeMcp {
.toolCall(listTool(), (exchange, _) -> {
McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null);
if (denied != null) return denied;
- return listFleet(workers, sessions, messages, capacity, healthCoverage,
+ return listFleet(workers, sessions, messages, capacity, healthCoverage, quarantine,
callers == null ? Map.of() : callers.leads(),
callerTerminal(exchange));
})
@@ -767,17 +767,22 @@ public final class BridgeMcp {
* caller's own row is flagged {@code "self": true}: a peer needs to tell its own pane apart from
* a peer's, and the alternative is every lead calling {@code bridge_whoami} to subtract itself.
*
+ *
CB-583: the {@code capacity} rows reuse {@code quarantine} (the same {@link QuarantineSource}
+ * {@code bridge_profiles} reads) so the two surfaces cannot disagree about which profile is
+ * quarantined — see {@link #capacityView}.
+ *
* @param leads terminal_id → lead name, live from the resolver
* @param selfTerm the calling pane's terminal id, or blank for a caller with no pane
*/
static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions,
Map leads, String selfTerm) {
- return listFleet(workers, sessions, null, CapacitySource.none(), new HealthCoverageSource(() -> "off"), leads, selfTerm);
+ return listFleet(workers, sessions, null, CapacitySource.none(), new HealthCoverageSource(() -> "off"),
+ QuarantineSource.none(), leads, selfTerm);
}
static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions, MessageService messages,
CapacitySource capacity, HealthCoverageSource healthCoverage,
- Map leads, String selfTerm) {
+ QuarantineSource quarantine, Map leads, String selfTerm) {
try {
Map live = workers.list().stream()
.map(Agent.class::cast)
@@ -798,7 +803,7 @@ public final class BridgeMcp {
result.put("healthCoverage", healthCoverage.value().get());
if (capacity.available()) result.put("capacity", profiles.stream()
.map(profile -> capacityView(profile, capacity.liveCount(), capacity.maxLoad(), roster, messages,
- capacity.clock().getAsLong())).toList());
+ capacity.clock().getAsLong(), quarantine)).toList());
return text(json(result));
} catch (HerdrException e) {
return error("herdr error listing the fleet: " + e.getMessage());
@@ -823,9 +828,18 @@ public final class BridgeMcp {
return row;
}
+ /**
+ * CB-583: {@code free} alone cannot tell a lead "busy, will free up" from "refusing, and
+ * nothing changes for N seconds" — those need different decisions. So a quarantined profile
+ * forces {@code free} to 0, whatever its {@code maxLoad}/{@code live} say, and the row carries
+ * the same {@code credentialId}/{@code quarantinedForSeconds} facts {@code bridge_profiles}
+ * reports, reusing {@link QuarantineSource} rather than a second lookup. Both new keys are
+ * added only when the profile is actually quarantined, so an ordinary fleet's rows are
+ * byte-identical to before this change.
+ */
private static Map capacityView(String profile, Function liveCount,
Function maxLoad, List roster,
- MessageService messages, long nowNanos) {
+ MessageService messages, long nowNanos, QuarantineSource quarantine) {
Integer cap = maxLoad.apply(profile);
int live = liveCount.apply(profile);
int reclaimable = (int) roster.stream().filter(s -> profile.equals(s.profile()))
@@ -835,6 +849,14 @@ public final class BridgeMcp {
Map row = new LinkedHashMap<>();
row.put("profile", profile); row.put("maxLoad", cap); row.put("live", live);
row.put("free", cap == null ? null : Math.max(0, cap - live)); row.put("reclaimable", reclaimable);
+ String credentialId = quarantine.credentialIdFor().apply(profile);
+ if (credentialId != null) {
+ quarantine.quarantine().remainingSeconds(credentialId).ifPresent(remaining -> {
+ row.put("free", 0);
+ row.put("credentialId", credentialId);
+ row.put("quarantinedForSeconds", remaining);
+ });
+ }
return row;
}
@@ -994,7 +1016,12 @@ public final class BridgeMcp {
+ "sessions delegated to — each with sessionId, paneId, role (architect/dev/"
+ "reviewer), profile (the backend it runs on), state, optional "
+ "worktree/branch/owner, and live herdr status. An empty 'members' "
- + "means no members are spawned; it says nothing about peers.",
+ + "means no members are spawned; it says nothing about peers. When capacity "
+ + "facts are configured, a 'capacity' row per profile also reports free: 0 for "
+ + "a quarantined profile's credential (see bridge_profiles), whatever its "
+ + "maxLoad/live — with credentialId and quarantinedForSeconds naming the "
+ + "quarantine, so 'free: 0, busy' can be told apart from 'free: 0, refusing "
+ + "for N seconds'.",
objectSchema(Map.of(), List.of()));
}
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 7568244..d70610f 100644
--- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java
@@ -493,11 +493,14 @@ class BridgeMcpTest {
sessions.acquire("ltms-local", null, null, null);
McpSchema.CallToolResult res = BridgeMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")),
sessions, null, new BridgeMcp.CapacitySource(profile -> 2, profile -> 2,
- () -> Set.of("ltms-local"), () -> 0), new BridgeMcp.HealthCoverageSource(() -> "off"), Map.of(), "");
+ () -> Set.of("ltms-local"), () -> 0), new BridgeMcp.HealthCoverageSource(() -> "off"),
+ BridgeMcp.QuarantineSource.none(), Map.of(), "");
String out = textOf(res);
assertTrue(out.contains("\"maxLoad\":2"), out);
assertTrue(out.contains("\"live\":2"), out);
assertTrue(out.contains("\"free\":0"), out);
+ assertFalse(out.contains("credentialId"), "nothing is quarantined, so no new key appears: " + out);
+ assertFalse(out.contains("quarantinedForSeconds"), out);
}
@Test
@@ -506,7 +509,8 @@ class BridgeMcpTest {
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
String out = textOf(BridgeMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")),
sessions, null, new BridgeMcp.CapacitySource(profile -> 0, profile -> 2,
- () -> Set.of("terra"), () -> 0), new BridgeMcp.HealthCoverageSource(() -> "off"), Map.of(), ""));
+ () -> Set.of("terra"), () -> 0), new BridgeMcp.HealthCoverageSource(() -> "off"),
+ BridgeMcp.QuarantineSource.none(), Map.of(), ""));
assertTrue(out.contains("\"profile\":\"terra\""), out);
assertTrue(out.contains("\"live\":0"), out);
assertTrue(out.contains("\"free\":2"), out);
@@ -518,10 +522,90 @@ class BridgeMcpTest {
FakeHerdr h = new FakeHerdr();
String out = textOf(BridgeMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")),
new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"))), null,
- BridgeMcp.CapacitySource.none(), new BridgeMcp.HealthCoverageSource(() -> "off"), Map.of(), ""));
+ BridgeMcp.CapacitySource.none(), new BridgeMcp.HealthCoverageSource(() -> "off"),
+ BridgeMcp.QuarantineSource.none(), Map.of(), ""));
assertFalse(out.contains("\"capacity\":"), out);
}
+ @Test
+ void quarantinedProfileReportsZeroFreeRegardlessOfMaxLoadAndLive() {
+ FakeHerdr h = new FakeHerdr();
+ SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
+ BackendQuarantine quarantine = new BackendQuarantine(() -> 0L, TimeUnit.MINUTES.toNanos(20));
+ quarantine.quarantine("shared-openai");
+ BridgeMcp.QuarantineSource source = new BridgeMcp.QuarantineSource(
+ profile -> "terra".equals(profile) ? "shared-openai" : null, quarantine);
+
+ String out = textOf(BridgeMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")),
+ sessions, null, new BridgeMcp.CapacitySource(profile -> 0, profile -> 2,
+ () -> Set.of("terra"), () -> 0), new BridgeMcp.HealthCoverageSource(() -> "off"),
+ source, Map.of(), ""));
+
+ assertTrue(out.contains("\"profile\":\"terra\""), out);
+ assertTrue(out.contains("\"maxLoad\":2"), out);
+ assertTrue(out.contains("\"live\":0"), out);
+ assertTrue(out.contains("\"free\":0"), out);
+ assertTrue(out.contains("\"credentialId\":\"shared-openai\""), out);
+ assertTrue(out.contains("\"quarantinedForSeconds\":1200"), out);
+ }
+
+ @Test
+ void everyProfileSharingTheQuarantinedCredentialReportsZeroFree() {
+ FakeHerdr h = new FakeHerdr();
+ SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
+ BackendQuarantine quarantine = new BackendQuarantine(() -> 0L, TimeUnit.MINUTES.toNanos(20));
+ quarantine.quarantine("shared-openai");
+ BridgeMcp.QuarantineSource source = new BridgeMcp.QuarantineSource(_ -> "shared-openai", quarantine);
+
+ String out = textOf(BridgeMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")),
+ sessions, null, new BridgeMcp.CapacitySource(profile -> 0, profile -> 2,
+ () -> Set.of("terra", "sol"), () -> 0), new BridgeMcp.HealthCoverageSource(() -> "off"),
+ source, Map.of(), ""));
+
+ assertEquals(2, out.split("\"free\":0", -1).length - 1, out);
+ }
+
+ @Test
+ void listAndProfilesAgreeOnWhatIsQuarantined() {
+ FakeHerdr h = new FakeHerdr();
+ SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
+ BackendQuarantine quarantine = new BackendQuarantine(() -> 0L, TimeUnit.MINUTES.toNanos(20));
+ quarantine.quarantine("shared-openai");
+ BridgeMcp.QuarantineSource source = new BridgeMcp.QuarantineSource(
+ profile -> "ltms-local".equals(profile) ? "shared-openai" : null, quarantine);
+ var workers = workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"));
+
+ String listOut = textOf(BridgeMcp.listFleet(workers, sessions, null,
+ new BridgeMcp.CapacitySource(profile -> 0, profile -> 2, () -> Set.of("ltms-local"), () -> 0),
+ new BridgeMcp.HealthCoverageSource(() -> "off"), source, Map.of(), ""));
+ String profilesOut = textOf(BridgeMcp.profiles(workers, source));
+
+ assertTrue(listOut.contains("\"free\":0"), listOut);
+ assertTrue(listOut.contains("\"credentialId\":\"shared-openai\""), listOut);
+ assertTrue(profilesOut.contains("\"quarantined\""), profilesOut);
+ assertTrue(profilesOut.contains("shared-openai"), profilesOut);
+ }
+
+ @Test
+ void expiredQuarantineLeavesTheCapacityRowOrdinary() {
+ FakeHerdr h = new FakeHerdr();
+ SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
+ long[] clockNanos = {0L};
+ BackendQuarantine quarantine = new BackendQuarantine(() -> clockNanos[0], TimeUnit.MINUTES.toNanos(20));
+ quarantine.quarantine("shared-openai");
+ clockNanos[0] = TimeUnit.MINUTES.toNanos(21); // clock advances past the cooldown
+ BridgeMcp.QuarantineSource source = new BridgeMcp.QuarantineSource(
+ profile -> "terra".equals(profile) ? "shared-openai" : null, quarantine);
+
+ String out = textOf(BridgeMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")),
+ sessions, null, new BridgeMcp.CapacitySource(profile -> 0, profile -> 2,
+ () -> Set.of("terra"), () -> 0), new BridgeMcp.HealthCoverageSource(() -> "off"),
+ source, Map.of(), ""));
+
+ assertTrue(out.contains("\"free\":2"), out);
+ assertFalse(out.contains("quarantinedForSeconds"), out);
+ }
+
@Test
void listReportsLeadsAndFlagsTheCallersOwnRow() {
FakeHerdr h = new FakeHerdr();