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();