CB-301: SessionManager — authoritative worker session registry + one-shot FSM
Adds dev.ltms.bridged.session with WorkerSession (immutable record) and SessionManager wrapping WorkerService: a ConcurrentHashMap registry keyed by paneId, the one-shot lifecycle FSM (SPAWNING->READY->BUSY->DONE, ->FAILED on drop/turn-failure, ->RELEASED on teardown), ownership (ownerTerminal), and recycle = release + fresh acquire (no-reuse invariant). Driven by TurnListener (BUSY/DONE/FAILED) and a WorkerPresence bridge (READY). Wiring: Bridged.main constructs it and composes it into the TurnListener alongside CompletionResolver; bridge_spawn / POST /workers route through acquire (carrying caller identity as owner); bridge_stop / DELETE /workers route through release. WorkerService gains effectiveCwd(); WorkerPresence de-finalized so the manager can present a READY-driving view. asPresence() returns a single cached bridge (a fresh one per call would fragment the shared present set). roster() is the registry snapshot; the live herdr join is left for CB-304. 6 fake-based acceptance tests; full suite green (155/155). Delegated to an off-subscription worker against docs/CB-301-Session-Manager.md; primary verified (ide diagnostics clean, mvn clean install green) + fixed the asPresence caching bug.
This commit is contained in:
@@ -142,10 +142,12 @@ public final class FakeHerdr implements HerdrClient {
|
||||
"herdr error [agent_name_taken]: agent name already used",
|
||||
"agent_name_taken", null);
|
||||
}
|
||||
yield mapper.readTree("""
|
||||
long n = starts - agentNameTakenFor;
|
||||
yield mapper.readTree(("""
|
||||
{"type":"agent_started","agent":{
|
||||
"terminal_id":"term_new","name":"claude","agent_status":"unknown",
|
||||
"workspace_id":"w9","tab_id":"w9:t2","pane_id":"w9:pW"}}""");
|
||||
"terminal_id":"term_new_%d","name":"claude","agent_status":"unknown",
|
||||
"workspace_id":"w9","tab_id":"w9:t2","pane_id":"w9:pW_%d"}}""")
|
||||
.formatted(n, n));
|
||||
}
|
||||
case "workspace.create" -> mapper.readTree("""
|
||||
{"type":"workspace_created",
|
||||
|
||||
@@ -8,6 +8,7 @@ import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||
import dev.ltms.bridged.inject.Injector;
|
||||
import dev.ltms.bridged.msg.MessageService;
|
||||
import dev.ltms.bridged.msg.Rendezvous;
|
||||
import dev.ltms.bridged.session.SessionManager;
|
||||
import dev.ltms.bridged.worker.WorkerService;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -43,6 +44,10 @@ class BridgeMcpTest {
|
||||
new SubscriptionGuard(allow), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> "tok");
|
||||
}
|
||||
|
||||
private static SessionManager sessionManager(FakeHerdr h, String baseUrl, Set<String> allow) {
|
||||
return new SessionManager(workerService(h, baseUrl, allow));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendThenReplyRoundTrips() throws Exception {
|
||||
// bridge_send blocks; bridge_reply resolves it with the worker's structured answer.
|
||||
@@ -180,18 +185,20 @@ class BridgeMcpTest {
|
||||
@Test
|
||||
void spawnReturnsTheNewWorkersSessionAndPane() {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
McpSchema.CallToolResult res = BridgeMcp.spawn(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null);
|
||||
McpSchema.CallToolResult res = BridgeMcp.spawn(
|
||||
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null);
|
||||
assertNotEquals(Boolean.TRUE, res.isError());
|
||||
String out = textOf(res);
|
||||
assertTrue(out.contains("\"sessionId\":\"term_new\""), out);
|
||||
assertTrue(out.contains("\"paneId\":\"w9:pW\""), out);
|
||||
assertTrue(out.contains("\"sessionId\":\"term_new_1\""), out);
|
||||
assertTrue(out.contains("\"paneId\":\"w9:pW_1\""), out);
|
||||
assertTrue(out.contains("\"status\":\"spawning\""), out);
|
||||
}
|
||||
|
||||
@Test
|
||||
void spawnRejectsAnOffAllowlistProfileWithoutTouchingHerdr() {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
McpSchema.CallToolResult res =
|
||||
BridgeMcp.spawn(workerService(h, "https://api.anthropic.com", Set.of("gx00.gw")), null);
|
||||
BridgeMcp.spawn(sessionManager(h, "https://api.anthropic.com", Set.of("gx00.gw")), null);
|
||||
assertTrue(res.isError());
|
||||
assertTrue(textOf(res).contains("subscription boundary"));
|
||||
assertFalse(h.called("agent.start"), "the guard must block before any spawn");
|
||||
@@ -201,7 +208,7 @@ class BridgeMcpTest {
|
||||
void spawnRejectsAnUnknownProfileAsAnError() {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
McpSchema.CallToolResult res =
|
||||
BridgeMcp.spawn(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), "nope");
|
||||
BridgeMcp.spawn(sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), "nope");
|
||||
assertTrue(res.isError());
|
||||
assertTrue(textOf(res).contains("unknown worker profile"), textOf(res));
|
||||
}
|
||||
@@ -210,7 +217,7 @@ class BridgeMcpTest {
|
||||
void spawnPassesTheRequestedCwdToTheWorker() {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
McpSchema.CallToolResult res = BridgeMcp.spawn(
|
||||
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "/req/dir", null);
|
||||
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "/req/dir", null, null);
|
||||
assertNotEquals(Boolean.TRUE, res.isError());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> start = (Map<String, Object>) h.lastCall("agent.start").params();
|
||||
@@ -238,7 +245,8 @@ class BridgeMcpTest {
|
||||
@Test
|
||||
void stopTearsDownAWorkerByPane() {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
McpSchema.CallToolResult res = BridgeMcp.stop(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), "w9:pW");
|
||||
McpSchema.CallToolResult res = BridgeMcp.stop(
|
||||
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), "w9:pW");
|
||||
assertNotEquals(Boolean.TRUE, res.isError());
|
||||
assertEquals("stopped w9:pW", textOf(res));
|
||||
assertTrue(h.called("pane.close"));
|
||||
@@ -247,7 +255,8 @@ class BridgeMcpTest {
|
||||
@Test
|
||||
void stopRequiresAPaneId() {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
assertTrue(BridgeMcp.stop(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), " ").isError());
|
||||
assertTrue(BridgeMcp.stop(
|
||||
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), " ").isError());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -12,6 +12,7 @@ import dev.ltms.bridged.inject.StatusPoller;
|
||||
import dev.ltms.bridged.inject.WorkerPresence;
|
||||
import dev.ltms.bridged.msg.MessageService;
|
||||
import dev.ltms.bridged.msg.Rendezvous;
|
||||
import dev.ltms.bridged.session.SessionManager;
|
||||
import dev.ltms.bridged.worker.WorkerService;
|
||||
import io.javalin.Javalin;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -37,7 +38,7 @@ class BridgedAppTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
private final WorkerPresence presence = new WorkerPresence();
|
||||
private WorkerPresence presence;
|
||||
private Javalin app;
|
||||
private StatusPoller poller;
|
||||
|
||||
@@ -60,12 +61,14 @@ class BridgedAppTest {
|
||||
agents, new WorkspaceControl(herdr), new SubscriptionGuard(allow),
|
||||
Map.of(wcfg.profile(), wcfg), wcfg.profile(),
|
||||
k -> "BRIDGED_WORKER_TOKEN".equals(k) ? "tok-abc" : null);
|
||||
SessionManager sessions = new SessionManager(workers);
|
||||
this.presence = sessions.asPresence();
|
||||
Injector injector = new Injector(agents);
|
||||
poller = new StatusPoller(agents, injector, 5); // delivers when the fake reports idle
|
||||
poller.start();
|
||||
Rendezvous rendezvous = new Rendezvous();
|
||||
MessageService messages = new MessageService(agents, injector, rendezvous);
|
||||
app = new BridgedApp(herdr, workers, messages, rendezvous, this.presence, null)
|
||||
app = new BridgedApp(herdr, workers, sessions, messages, rendezvous, this.presence, null)
|
||||
.build().start("127.0.0.1", 0);
|
||||
return app.port();
|
||||
}
|
||||
@@ -146,8 +149,8 @@ class BridgedAppTest {
|
||||
HttpResponse<String> res = req(port, "POST", "/workers");
|
||||
assertEquals(201, res.statusCode());
|
||||
JsonNode body = mapper.readTree(res.body());
|
||||
assertEquals("w9:pW", body.get("paneId").asText());
|
||||
assertEquals("w9:t2", body.get("tabId").asText());
|
||||
assertEquals("w9:pW_1", body.get("paneId").asText());
|
||||
assertEquals("spawning", body.get("state").asText());
|
||||
|
||||
// Subscription boundary: agent.start carried base_url + token in its env map.
|
||||
Map<String, Object> start = params(herdr, "agent.start");
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package dev.ltms.bridged.session;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
import dev.ltms.bridged.guard.SubscriptionGuard;
|
||||
import dev.ltms.bridged.herdr.AgentControl;
|
||||
import dev.ltms.bridged.herdr.FakeHerdr;
|
||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||
import dev.ltms.bridged.worker.WorkerService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* CB-301 acceptance tests for the authoritative session registry and one-shot lifecycle FSM.
|
||||
* No live herdr — everything runs against the same {@link FakeHerdr} the rest of the project uses.
|
||||
*/
|
||||
class SessionManagerTest {
|
||||
|
||||
private SessionManager sessionManager(FakeHerdr herdr) {
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("ccs", "ltms-local"), "tab", "bridged-workers",
|
||||
"worker: {profile} #{n}", null, null);
|
||||
WorkerService workers = new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
|
||||
return new SessionManager(workers);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acquireRegistersSpawningSessionWithDistinctPaneId() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
SessionManager sessions = sessionManager(herdr);
|
||||
|
||||
WorkerSession a = sessions.acquire("ltms-local", "/work/a", "/caller/a", "term_primary");
|
||||
WorkerSession b = sessions.acquire("ltms-local", "/work/b", "/caller/b", "term_primary");
|
||||
|
||||
assertEquals(WorkerSession.State.SPAWNING, a.state(), "fresh session starts spawning");
|
||||
assertEquals("ltms-local", a.profile());
|
||||
assertEquals("/work/a", a.cwd(), "explicit requested cwd is recorded");
|
||||
assertEquals("term_primary", a.ownerTerminal());
|
||||
assertTrue(a.spawnedAtNanos() > 0);
|
||||
assertNotNull(a.paneId());
|
||||
assertNotNull(a.terminalId());
|
||||
|
||||
assertNotEquals(a.paneId(), b.paneId(), "no pane reuse");
|
||||
assertNotEquals(a.terminalId(), b.terminalId(), "no terminal reuse");
|
||||
assertEquals(2, sessions.roster().size(), "both sessions are registered");
|
||||
}
|
||||
|
||||
@Test
|
||||
void presenceMovesSpawningToReadyAndDeliveredTurnMovesToDone() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
SessionManager sessions = sessionManager(herdr);
|
||||
WorkerSession session = sessions.acquire("ltms-local", null, "/caller", "term_primary");
|
||||
String terminal = session.terminalId();
|
||||
|
||||
sessions.asPresence().markPresent(terminal);
|
||||
assertEquals(WorkerSession.State.READY, sessions.get(session.paneId()).orElseThrow().state(),
|
||||
"MCP presence moves SPAWNING → READY");
|
||||
assertTrue(sessions.asPresence().isPresent(terminal), "presence is also recorded");
|
||||
|
||||
sessions.onDelivered(terminal);
|
||||
assertEquals(WorkerSession.State.BUSY, sessions.get(session.paneId()).orElseThrow().state(),
|
||||
"delivery moves READY → BUSY");
|
||||
|
||||
sessions.onTurnComplete(terminal);
|
||||
assertEquals(WorkerSession.State.DONE, sessions.get(session.paneId()).orElseThrow().state(),
|
||||
"turn completion moves BUSY → DONE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseTearsDownWorkerAndRemovesFromRosterAndIsIdempotent() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
SessionManager sessions = sessionManager(herdr);
|
||||
WorkerSession session = sessions.acquire("ltms-local", null, "/caller", null);
|
||||
String paneId = session.paneId();
|
||||
|
||||
sessions.release(paneId);
|
||||
|
||||
assertTrue(herdr.called("pane.close"), "release tears the worker pane down");
|
||||
assertTrue(sessions.get(paneId).isEmpty(), "released session is no longer retrievable");
|
||||
assertTrue(sessions.roster().isEmpty(), "released session is no longer in the roster");
|
||||
|
||||
assertDoesNotThrow(() -> sessions.release(paneId), "a second release is harmless");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onTurnFailedMovesSessionToFailed() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
SessionManager sessions = sessionManager(herdr);
|
||||
WorkerSession session = sessions.acquire("ltms-local", null, "/caller", "term_primary");
|
||||
String terminal = session.terminalId();
|
||||
sessions.asPresence().markPresent(terminal);
|
||||
sessions.onDelivered(terminal);
|
||||
|
||||
sessions.onTurnFailed(terminal);
|
||||
|
||||
WorkerSession updated = sessions.get(session.paneId()).orElseThrow();
|
||||
assertEquals(WorkerSession.State.FAILED, updated.state(), "turn failure moves to FAILED");
|
||||
assertTrue(sessions.roster().contains(updated), "FAILED is still in acquired-minus-released roster");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recycleProducesNewPaneIdAndOldOneIsGone() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
SessionManager sessions = sessionManager(herdr);
|
||||
WorkerSession oldSession = sessions.acquire("ltms-local", null, "/caller", "term_primary");
|
||||
String oldPane = oldSession.paneId();
|
||||
String oldTerminal = oldSession.terminalId();
|
||||
|
||||
WorkerSession fresh = sessions.recycle(oldPane);
|
||||
|
||||
assertNotEquals(oldPane, fresh.paneId(), "recycle yields a new pane id");
|
||||
assertNotEquals(oldTerminal, fresh.terminalId(), "recycle yields a new terminal id");
|
||||
assertEquals(oldSession.profile(), fresh.profile(), "profile is preserved");
|
||||
assertEquals(oldSession.cwd(), fresh.cwd(), "cwd is preserved");
|
||||
assertEquals(oldSession.ownerTerminal(), fresh.ownerTerminal(), "owner is preserved");
|
||||
|
||||
assertTrue(sessions.get(oldPane).isEmpty(), "old pane is deregistered");
|
||||
assertEquals(1, sessions.roster().size(), "only the fresh session remains");
|
||||
assertEquals(fresh.paneId(), sessions.roster().getFirst().paneId());
|
||||
|
||||
long paneCloseCount = herdr.calls.stream()
|
||||
.filter(c -> "pane.close".equals(c.method()))
|
||||
.filter(c -> oldPane.equals(((Map<?, ?>) c.params()).get("pane_id")))
|
||||
.count();
|
||||
assertEquals(1, paneCloseCount, "the old worker was torn down");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rosterReflectsAcquiredMinusReleased() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
SessionManager sessions = sessionManager(herdr);
|
||||
WorkerSession a = sessions.acquire("ltms-local", "/a", "/caller", "ownerA");
|
||||
WorkerSession b = sessions.acquire("ltms-local", "/b", "/caller", "ownerB");
|
||||
|
||||
assertEquals(2, sessions.roster().size());
|
||||
assertTrue(sessions.roster().stream().anyMatch(s -> s.paneId().equals(a.paneId())));
|
||||
assertTrue(sessions.roster().stream().anyMatch(s -> s.paneId().equals(b.paneId())));
|
||||
|
||||
sessions.release(a.paneId());
|
||||
|
||||
assertEquals(1, sessions.roster().size());
|
||||
assertEquals(b.paneId(), sessions.roster().getFirst().paneId());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user