CB-584: persist agentSessionId at acquire and expose it via bridge_spawn/bridge_list
CI / contract (pull_request) Successful in 1m5s
CI / build (pull_request) Successful in 2m18s

Wires up the two links that made session resume unreachable: MemberSession
now records agentSessionId from PeerHandle at acquire (both the plain and
worktree paths), and it survives onto the roster (rosterView, so both
bridge_list and GET /members show it). bridge_spawn accepts sessionName and
resumeSessionId, threading them into the SpawnRequest fields that already
existed but were never reachable from the MCP surface.

A resumeSessionId now requires an explicit profile (a resumed conversation
is tied to the specific backend that started it, so an unqualified spawn
routed by placement has no safe candidate to check) and is refused, naming
Capability.SESSION_RESUME, when that profile's adapter does not declare it
— PeerLauncher gains capabilitiesFor(profileName) so a mixed fleet is
checked per-adapter rather than against the fleet-wide capability union.

PeerHandle.agentSessionId() loses its default, the same fix CB-571 (c00a86b)
made for charterReceipt() one method above it — both existing adapters
already overrode it, so this only closes the landmine for a future one.

Out of scope, left for follow-up: carrying agentSessionId on a failed
ticket's ReleaseDetail (issue #65 criterion 5) — CB-578 stage C just
landed in that file and this ticket deliberately stayed out of it.
This commit is contained in:
Dai Ha
2026-08-15 15:48:06 +02:00
parent 5fe02b7c98
commit 5d5b3bdc76
10 changed files with 319 additions and 34 deletions
@@ -427,7 +427,8 @@ class BridgeMcpTest {
void spawnPassesTheRequestedCwdToTheWorker() {
FakeHerdr h = new FakeHerdr();
McpSchema.CallToolResult res = BridgeMcp.spawn(
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, null, "/req/dir", null, null, null);
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, null, "/req/dir", null, null, null,
null, null);
assertNotEquals(Boolean.TRUE, res.isError());
// Protocol 19: the requested cwd roots the worker's pane at creation (tab.create).
@SuppressWarnings("unchecked")
@@ -886,7 +887,8 @@ class BridgeMcpTest {
void spawnDefaultsTheRoleToDev() {
FakeHerdr h = new FakeHerdr();
McpSchema.CallToolResult res = BridgeMcp.spawn(
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, null, null, null, null, null);
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, null, null, null, null, null,
null, null);
assertNotEquals(Boolean.TRUE, res.isError());
assertTrue(textOf(res).contains("\"role\":\"dev\""), textOf(res));
@@ -897,7 +899,7 @@ class BridgeMcpTest {
FakeHerdr h = new FakeHerdr();
McpSchema.CallToolResult res = BridgeMcp.spawn(
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "architect",
null, null, null, null);
null, null, null, null, null, null);
assertNotEquals(Boolean.TRUE, res.isError());
assertTrue(textOf(res).contains("\"role\":\"architect\""), textOf(res));
@@ -908,9 +910,36 @@ class BridgeMcpTest {
FakeHerdr h = new FakeHerdr();
McpSchema.CallToolResult res = BridgeMcp.spawn(
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "worker",
null, null, null, null);
null, null, null, null, null, null);
assertEquals(Boolean.TRUE, res.isError());
assertTrue(textOf(res).contains("architect, dev, reviewer"), textOf(res));
}
// ── CB-584: bridge_spawn accepts sessionName/resumeSessionId; roster shows agentSessionId ──
@Test
void spawnWithResumeSessionIdPutsTheIdOnTheRoster() {
FakeHerdr h = new FakeHerdr();
SessionManager sessions = sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw"));
McpSchema.CallToolResult res = BridgeMcp.spawn(sessions, "ltms-local", null, null, null, null, null,
null, "cb-resume-99");
assertNotEquals(Boolean.TRUE, res.isError(), textOf(res));
String listOut = textOf(BridgeMcp.listFleet(
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, Map.of(), ""));
assertTrue(listOut.contains("\"agentSessionId\":\"cb-resume-99\""), listOut);
}
@Test
void spawnRefusesResumeSessionIdWithoutAnExplicitProfile() {
FakeHerdr h = new FakeHerdr();
McpSchema.CallToolResult res = BridgeMcp.spawn(
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, null,
null, null, null, null, null, "cb-resume-99");
assertEquals(Boolean.TRUE, res.isError());
assertTrue(textOf(res).contains("explicit profile"), textOf(res));
}
}
@@ -97,6 +97,7 @@ class CompositePeerLauncherTest {
@Override public String id() { return "pane-" + p; }
@Override public String terminalId() { return "term-" + p; }
@Override public String profile() { return p; }
@Override public String agentSessionId() { return null; }
@Override public CharterReceipt charterReceipt() { return null; }
};
}
@@ -11,9 +11,13 @@ import dev.ltms.bridged.herdr.FakeHerdr;
import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.member.ClaudeCodeLauncher;
import dev.ltms.bridged.msg.TestTurnTokens;
import dev.ltms.bridged.peer.Capability;
import dev.ltms.bridged.peer.CharterReceipt;
import dev.ltms.bridged.peer.MemberRole;
import dev.ltms.bridged.peer.PeerHandle;
import dev.ltms.bridged.peer.PeerLauncher;
import dev.ltms.bridged.peer.PeerUnreachableException;
import dev.ltms.bridged.peer.SpawnRequest;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
@@ -196,7 +200,7 @@ class SessionManagerTest {
// see which charter a member got, without ever carrying the charter prose itself (CB-571).
MemberSession s = new MemberSession("p1", "term1", "prof", MemberRole.DEV, "/cwd", null,
0, 0, 0, MemberSession.State.READY, null, null,
CharterReceipt.compose(MemberRole.DEV, "prof", "role charter", "role charter\n\nreply"));
CharterReceipt.compose(MemberRole.DEV, "prof", "role charter", "role charter\n\nreply"), null);
Map<String, Object> view = SessionManager.rosterView(s, null);
@@ -790,4 +794,127 @@ class SessionManagerTest {
assertTrue(worktrees.removeCalls().isEmpty(), "SHUTDOWN drain still preserves the worktree");
}
// ── CB-584: agentSessionId recorded at acquire, and gated by Capability.SESSION_RESUME ─────
/**
* A minimal {@link PeerLauncher} whose capabilities exclude SESSION_RESUME — the "does not
* support it" case. {@link #requireResumeCapability} throws before ever reaching {@link #spawn},
* so every method beyond {@link #capabilitiesFor} is unreachable in these tests and left unimplemented.
*/
private static final class NoResumeLauncher implements PeerLauncher {
@Override
public Set<Capability> capabilities() {
return Set.of(Capability.WORKTREE); // deliberately no SESSION_RESUME
}
@Override
public Set<Capability> capabilitiesFor(String profileName) {
return capabilities();
}
@Override
public PeerHandle spawn(SpawnRequest req) {
throw new UnsupportedOperationException("not reachable — the capability check refuses first");
}
@Override
public Set<String> profiles() {
return Set.of("stub-profile");
}
@Override
public String defaultProfile() {
return "stub-profile";
}
@Override
public String effectiveCwd(SpawnRequest req) {
throw new UnsupportedOperationException("not reachable — the capability check refuses first");
}
@Override
public List<String> parityOverlay(String profileName) {
return List.of();
}
@Override
public List<?> list() {
return List.of();
}
@Override
public int reapOrphanWorkers() {
return 0;
}
@Override
public void stop(String id) {
}
@Override
public boolean clearContext(String id) {
return false;
}
}
@Test
void acquireRecordsTheAgentSessionIdFromTheHandle() {
FakeHerdr herdr = new FakeHerdr();
SessionManager sessions = sessionManager(herdr);
MemberSession s = sessions.acquire("ltms-local", MemberRole.DEV, null, null, null, null,
"my-session-name", null);
assertNotNull(s.agentSessionId(), "a spawn that asked for session identity gets one back");
Map<String, Object> view = SessionManager.rosterView(s, null);
assertEquals(s.agentSessionId(), view.get("agentSessionId"),
"the roster exposes the same id the session recorded");
}
@Test
void acquireWithNeitherSessionFieldLeavesAgentSessionIdNull() {
FakeHerdr herdr = new FakeHerdr();
SessionManager sessions = sessionManager(herdr);
MemberSession s = sessions.acquire("ltms-local", null, null, null);
assertNull(s.agentSessionId(), "no identity requested — unchanged from before CB-584");
assertFalse(SessionManager.rosterView(s, null).containsKey("agentSessionId"),
"a null id is omitted from the roster, like charterSha256 for a receipt-less session");
}
@Test
void resumeSessionIdResumesTheSameConversationOnASupportingAdapter() {
FakeHerdr herdr = new FakeHerdr();
SessionManager sessions = sessionManager(herdr);
MemberSession s = sessions.acquire("ltms-local", MemberRole.DEV, null, null, null, null,
null, "cb-resume-77");
assertEquals("cb-resume-77", s.agentSessionId(),
"a resume adopts the prior id as its own agentSessionId");
}
@Test
void resumeSessionIdWithoutAnExplicitProfileIsRefused() {
FakeHerdr herdr = new FakeHerdr();
SessionManager sessions = sessionManager(herdr);
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () ->
sessions.acquire(null, MemberRole.DEV, null, null, null, null, null, "cb-resume-1"));
assertTrue(e.getMessage().contains("explicit profile"), e.getMessage());
}
@Test
void resumeSessionIdOnAnAdapterWithoutTheCapabilityIsRefusedNamingIt() {
SessionManager sessions = new SessionManager(new NoResumeLauncher());
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () ->
sessions.acquire("stub-profile", MemberRole.DEV, null, null, null, null, null, "cb-resume-1"));
assertTrue(e.getMessage().contains("SESSION_RESUME"), e.getMessage());
assertTrue(e.getMessage().contains("stub-profile"), e.getMessage());
}
}