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 2865f94..ce26254 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -15,6 +15,7 @@ import dev.ltms.bridged.msg.MessageService; import dev.ltms.bridged.msg.Rendezvous; import dev.ltms.bridged.peer.PeerUnreachableException; import dev.ltms.bridged.placement.BackendQuarantine; +import dev.ltms.bridged.placement.PlacementException; import dev.ltms.bridged.session.SessionManager; import dev.ltms.bridged.session.MemberSession; import dev.ltms.bridged.session.WorktreeRequest; @@ -692,6 +693,10 @@ public final class BridgeMcp { return text(json(memberView(member))); } catch (GuardException e) { return error("subscription boundary: " + e.getMessage()); + } catch (PlacementException e) { + // CB-599: no candidate had capacity (maxLoad, quarantine, or all-exhausted) — distinct + // from "profile does not exist" below. + return error("no capacity: " + e.getMessage()); } catch (IllegalArgumentException e) { return error(e.getMessage()); // unknown / no-default profile, or a refused resumeSessionId } catch (PeerUnreachableException e) { 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 f2b2345..754bb9b 100644 --- a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java +++ b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java @@ -13,6 +13,7 @@ import dev.ltms.bridged.herdr.HerdrClient; import dev.ltms.bridged.herdr.HerdrException; import dev.ltms.bridged.inject.MemberPresence; import dev.ltms.bridged.peer.PeerUnreachableException; +import dev.ltms.bridged.placement.PlacementException; import dev.ltms.bridged.msg.MessageService; import dev.ltms.bridged.session.SessionManager; import dev.ltms.bridged.peer.MemberRole; @@ -292,6 +293,11 @@ public final class BridgedApp { ctx.status(201).json(view(member)); } catch (GuardException e) { ctx.status(403).json(Map.of("error", "subscription_boundary", "detail", e.getMessage())); + } catch (PlacementException e) { + // CB-599: no candidate had capacity (maxLoad, quarantine, or all-exhausted) — a benign, + // likely-transient refusal, distinct from "profile does not exist" below. 503: the + // request was valid and will likely succeed later. + ctx.status(503).json(Map.of("error", "no_capacity", "detail", e.getMessage())); } catch (IllegalArgumentException e) { ctx.status(400).json(Map.of("error", "unknown_profile", "detail", e.getMessage())); } catch (PeerUnreachableException e) { 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 e4619f3..c584d08 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -16,12 +16,15 @@ import dev.ltms.bridged.inject.MemberPresence; import dev.ltms.bridged.session.MemberSession; import dev.ltms.bridged.session.WorktreeRequest; import dev.ltms.bridged.member.ClaudeCodeLauncher; +import dev.ltms.bridged.member.CompositePeerLauncher; import dev.ltms.bridged.placement.BackendQuarantine; +import dev.ltms.bridged.placement.PlacementPolicies; import io.modelcontextprotocol.spec.McpSchema; import dev.ltms.bridged.msg.InMemoryReplyInbox; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -423,6 +426,35 @@ class BridgeMcpTest { assertTrue(textOf(res).contains("unknown worker profile"), textOf(res)); } + /** + * CB-599: a profile at its {@code maxLoad} cap must surface a readable reason on the MCP + * surface too, not merely flip {@code isError} with an opaque or absent message. + */ + @Test + void spawnAtMaxLoadSurfacesTheCapacityReason() { + FakeHerdr h = new FakeHerdr(); + BridgedConfig.Profile wcfg = new BridgedConfig.Profile( + "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null, + "tab", "bridged-workers", "worker: {profile} #{n}", null, + null, null, null, null, null, null, null, 0, null, null, null); + Map profiles = Map.of(wcfg.profile(), wcfg); + ClaudeCodeLauncher delegate = new ClaudeCodeLauncher( + new AgentControl(h), new WorkspaceControl(h), new SubscriptionGuard(Set.of("gx00.gw")), + profiles, wcfg.profile(), k -> "BRIDGED_WORKER_TOKEN".equals(k) ? "tok" : null); + CompositePeerLauncher composite = new CompositePeerLauncher( + List.of(delegate), wcfg.profile(), profiles, PlacementPolicies.fixed(), _ -> 0); + SessionManager sm = new SessionManager(composite); + + McpSchema.CallToolResult res = BridgeMcp.spawn(sm, "ltms-local"); + + assertTrue(res.isError()); + String text = textOf(res); + assertTrue(text.contains("no capacity"), "surfaces a capacity reason, not a bare error: " + text); + assertTrue(text.contains("ltms-local"), "names the profile: " + text); + assertTrue(text.contains("maxLoad"), "explains the refusal: " + text); + assertFalse(h.called("agent.start"), "at cap, the spawn is refused before any herdr call"); + } + @Test void spawnPassesTheRequestedCwdToTheWorker() { FakeHerdr h = new FakeHerdr(); 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 f06d19c..1022545 100644 --- a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java @@ -18,6 +18,8 @@ import dev.ltms.bridged.session.GitWorktrees; import dev.ltms.bridged.session.SessionManager; import dev.ltms.bridged.session.Worktrees; import dev.ltms.bridged.member.ClaudeCodeLauncher; +import dev.ltms.bridged.member.CompositePeerLauncher; +import dev.ltms.bridged.placement.PlacementPolicies; import io.javalin.Javalin; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -240,6 +242,43 @@ class BridgedAppTest { assertFalse(herdr.called("agent.start"), "an unknown profile must not spawn anything"); } + /** + * CB-599: a profile at its {@code maxLoad} cap must not surface as a bare 500 — the caller + * needs a structured, readable reason, distinct from "unknown_profile". + */ + @Test + void spawnAtMaxLoadIs503WithTheCapacityReasonNotABare500() throws Exception { + FakeHerdr herdr = new FakeHerdr(); + BridgedConfig.Profile wcfg = new BridgedConfig.Profile( + "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null, + "tab", "bridged-workers", "worker: {profile} #{n}", null, + null, null, null, null, null, null, null, 0, null, null, null); + Map profiles = Map.of(wcfg.profile(), wcfg); + ClaudeCodeLauncher delegate = new ClaudeCodeLauncher( + new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")), + profiles, wcfg.profile(), k -> "BRIDGED_WORKER_TOKEN".equals(k) ? "tok-abc" : null); + CompositePeerLauncher workers = new CompositePeerLauncher( + List.of(delegate), wcfg.profile(), profiles, PlacementPolicies.fixed(), _ -> 0); + SessionManager sessions = new SessionManager(workers, new GitWorktrees()); + this.presence = sessions.asPresence(); + Injector injector = new Injector(new AgentControl(herdr)); + InMemoryReplyInbox inbox = new InMemoryReplyInbox(); + sessions.onAcquire(inbox::own); + MessageService messages = new MessageService(new AgentControl(herdr), injector, new Rendezvous(), inbox); + app = new BridgedApp(herdr, workers, sessions, messages, this.presence, null).build().start("127.0.0.1", 0); + int port = app.port(); + + HttpResponse res = req(port, "POST", "/members?profile=ltms-local"); + + assertEquals(503, res.statusCode(), res.body()); + JsonNode body = mapper.readTree(res.body()); + assertEquals("no_capacity", body.get("error").asText()); + String detail = body.get("detail").asText(); + assertTrue(detail.contains("ltms-local"), "detail names the profile: " + detail); + assertTrue(detail.contains("maxLoad"), "detail explains the refusal: " + detail); + assertFalse(herdr.called("agent.start"), "at cap, the spawn is refused before any herdr call"); + } + @Test void spawnWorkerReusesExistingWorkerSpace() throws Exception { // A space labelled "bridged-workers" already exists → no second workspace.create.