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 10bbdcc..2865f94 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -226,7 +226,8 @@ public final class BridgeMcp { // CB-301-ext: optional isolated worktree for parallel implementers. String callerCwd = identity.cwdForPid(callerPid(exchange)); return spawn(sessions, str(a, "profile"), str(a, "role"), str(a, "cwd"), callerCwd, - callerTerminal(exchange), worktreeRequest(a)); + callerTerminal(exchange), worktreeRequest(a), + str(a, "sessionName"), str(a, "resumeSessionId")); }) .toolCall(listTool(), (exchange, _) -> { McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null); @@ -656,7 +657,7 @@ public final class BridgeMcp { /** {@code bridge_spawn} without cwd/caller context (default resolution). */ static McpSchema.CallToolResult spawn(SessionManager sessions, String profile) { - return spawn(sessions, profile, null, null, null, null, null); + return spawn(sessions, profile, null, null, null, null, null, null, null); } /** @@ -666,13 +667,18 @@ public final class BridgeMcp { * {@code callerCwd} (the primary's directory), else the daemon's. * CB-301: the session is registered with {@code ownerTerminal} as its owner. * CB-301-ext: {@code worktreeRequest} non-null provisions an isolated git worktree. + * CB-584: {@code sessionName}/{@code resumeSessionId} request agent session identity — a resumed + * conversation requires an explicit {@code profile} whose adapter declares + * {@link dev.ltms.bridged.peer.Capability#SESSION_RESUME}, or the spawn is refused rather than + * silently starting a cold session. * *

{@code role} and {@code profile} are independent: the role picks the contract, the profile * picks the backend. A reviewer on the same profile as the dev it reviews is a normal spawn. */ static McpSchema.CallToolResult spawn(SessionManager sessions, String profile, String role, String requestedCwd, String callerCwd, - String ownerTerminal, WorktreeRequest worktreeRequest) { + String ownerTerminal, WorktreeRequest worktreeRequest, + String sessionName, String resumeSessionId) { MemberRole memberRole; try { memberRole = isBlank(role) ? MemberRole.DEV : MemberRole.parse(role); @@ -681,12 +687,13 @@ public final class BridgeMcp { } try { MemberSession member = sessions.acquire(isBlank(profile) ? null : profile, memberRole, - requestedCwd, callerCwd, ownerTerminal, worktreeRequest); + requestedCwd, callerCwd, ownerTerminal, worktreeRequest, + isBlank(sessionName) ? null : sessionName, isBlank(resumeSessionId) ? null : resumeSessionId); return text(json(memberView(member))); } catch (GuardException e) { return error("subscription boundary: " + e.getMessage()); } catch (IllegalArgumentException e) { - return error(e.getMessage()); // unknown / no-default profile + return error(e.getMessage()); // unknown / no-default profile, or a refused resumeSessionId } catch (PeerUnreachableException e) { return error("spawn timed out — worker pane never reached injectable state: " + e.getMessage()); } catch (HerdrException e) { @@ -987,14 +994,21 @@ public final class BridgeMcp { + "for the default. The two are independent: a reviewer may run on the same profile " + "as the dev it reviews. The member opens your current directory by default; pass " + "cwd to pin a different one. Pass worktree:true (with ticket) or " - + "worktree: to provision an isolated git worktree. Returns the member's " - + "sessionId (use with bridge_send) and paneId (use with bridge_stop).", + + "worktree: to provision an isolated git worktree. Pass resumeSessionId " + + "to relaunch onto a prior conversation instead of starting cold — this requires an " + + "explicit profile whose backend supports it (bridge_list shows agentSessionId for " + + "resumable members), and is refused otherwise rather than silently starting fresh. " + + "sessionName gives the member a display name in its own UI when the backend supports " + + "one. Returns the member's sessionId (use with bridge_send) and paneId (use with " + + "bridge_stop).", objectSchema(Map.of( "role", stringProp("What the member is for: architect, dev or reviewer (default dev)"), "profile", stringProp("Which backend to run it on (omit for the default profile)"), "cwd", stringProp("Working directory for the member (omit to inherit yours)"), "worktree", Map.of("type", "string", "description", "'true' or a ticket slug — requests an isolated git worktree"), - "ticket", stringProp("Ticket slug when worktree:true")), + "ticket", stringProp("Ticket slug when worktree:true"), + "sessionName", stringProp("Logical display name for the member's own session, when its backend supports one"), + "resumeSessionId", stringProp("A prior member's agentSessionId (from bridge_list) to resume — requires an explicit profile that supports it")), List.of())); } @@ -1015,7 +1029,9 @@ public final class BridgeMcp { + "discover a peer lead without being told its address. 'members' are the " + "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' " + + "worktree/branch/owner/agentSessionId (the id to pass as bridge_spawn's " + + "resumeSessionId to relaunch onto that same conversation, when the backend " + + "supports it), and live herdr status. An empty 'members' " + "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 " diff --git a/bridged/src/main/java/dev/ltms/bridged/member/CompositePeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/CompositePeerLauncher.java index 73e08b4..3262766 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/CompositePeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/CompositePeerLauncher.java @@ -460,6 +460,18 @@ public final class CompositePeerLauncher implements PeerLauncher { return defaultProfile; } + /** + * {@inheritDoc} + * + *

Routes to the specific delegate {@code profileName} resolves to, not the fleet-wide + * union {@link #capabilities()} returns — the whole reason this method exists (CB-584): in a + * mixed fleet, one adapter's capability must never be read as every profile's. + */ + @Override + public Set capabilitiesFor(String profileName) { + return route(profileName).capabilities(); + } + /** Every herdr agent, deduplicated by pane id (all delegates share one herdr and list globally). */ @Override public List list() { diff --git a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java index 320509d..39a252e 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java @@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.HerdrException; import dev.ltms.bridged.herdr.Tab; import dev.ltms.bridged.herdr.Workspace; import dev.ltms.bridged.herdr.WorkspaceControl; +import dev.ltms.bridged.peer.Capability; import dev.ltms.bridged.peer.CharterReceipt; import dev.ltms.bridged.peer.MemberRole; import dev.ltms.bridged.peer.PeerHandle; @@ -248,6 +249,19 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { return defaultProfile; } + /** + * {@inheritDoc} + * + *

One {@link HerdrPeerLauncher} instance always serves exactly one adapter kind, so every + * profile it owns shares that adapter's {@link #capabilities()} — {@code profileName} only + * needs validating (throwing on an unknown profile, same as {@link #spawn}), not routing. + */ + @Override + public Set capabilitiesFor(String profileName) { + requireProfile(profileName); + return capabilities(); + } + /** The configured profiles, for adapter capability decisions (e.g. any git-token grant). */ protected Collection profileConfigs() { return profiles.values(); diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java index 8d4827c..ad7d3f0 100644 --- a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java +++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java @@ -62,11 +62,15 @@ public interface PeerHandle { * non-null for a spawn that requested session identity, because it knows the id before the * peer has written anything. * + *

Deliberately not a {@code default} (CB-584, the same fix CB-571 made for + * {@link #charterReceipt()} one method below): a decorator that forgets to override this + * silently answers {@code null} for a question it has no basis to answer, and the gap surfaces + * only as a resume that quietly starts a cold session, not a compile error. Every + * implementation must answer explicitly. + * * @return the peer's own session id, or {@code null} when not determinable */ - default String agentSessionId() { - return null; - } + String agentSessionId(); /** * The charter receipt (CB-571) for this peer's launch — the fingerprint of the exact charter diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java index 5ebd958..1bfd1cd 100644 --- a/bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java @@ -25,6 +25,19 @@ public interface PeerLauncher { */ Set capabilities(); + /** + * The capabilities of the adapter that {@code profileName} resolves to (null/blank → the + * default profile, the same resolution {@link #spawn} uses). Distinct from {@link + * #capabilities()}, which unions every configured adapter: a caller that must know whether + * this profile's backend supports a capability — e.g. {@link Capability#SESSION_RESUME} + * before honoring {@link SpawnRequest#resumeSessionId()} — needs the per-profile answer, not + * the fleet-wide union, or a mixed fleet could OK a resume that lands on a non-supporting + * adapter (CB-584). + * + * @throws IllegalArgumentException if the profile is unknown and no default is configured + */ + Set capabilitiesFor(String profileName); + /** * {@code profileName}/requestedCwd null/blank → default resolution. Returns after the peer * process is live (env + argv + placement complete). Never returns {@code null}. diff --git a/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java b/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java index 9726e06..d7c7c13 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java @@ -26,6 +26,11 @@ import dev.ltms.bridged.peer.MemberRole; * @param state current lifecycle state in the one-shot FSM * @param charterReceipt the fingerprint (CB-571) of the charter bytes this member was started * with; {@code null} for a session whose launcher recorded none + * @param agentSessionId the peer's OWN session id (CB-584) — the handle a later + * {@code resumeSessionId} spawn would pass back to resume this exact + * conversation; {@code null} when the launcher could not determine one + * (an adapter that declines {@code Capability.SESSION_RESUME}, or one + * that resolves it lazily and has not yet) */ public record MemberSession( String paneId, @@ -40,7 +45,8 @@ public record MemberSession( State state, String worktree, String branch, - CharterReceipt charterReceipt) { + CharterReceipt charterReceipt, + String agentSessionId) { /** One-shot worker lifecycle states. */ public enum State { @@ -53,33 +59,34 @@ public record MemberSession( } /** - * Backward-compatible shape: a session with no charter receipt (a test or a launcher before - * CB-571). A separate constructor rather than a new parameter on the canonical one, so existing - * call sites that have nothing to record keep compiling unchanged. + * Backward-compatible shape: a session with no charter receipt and no agent session id (a test + * or a launcher before CB-571 / CB-584). A separate constructor rather than a new parameter on + * the canonical one, so existing call sites that have nothing to record keep compiling + * unchanged. */ public MemberSession(String paneId, String terminalId, String profile, MemberRole role, String cwd, String ownerTerminal, long spawnedAtNanos, long lastActivityAtNanos, int turnCount, State state, String worktree, String branch) { this(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, - lastActivityAtNanos, turnCount, state, worktree, branch, null); + lastActivityAtNanos, turnCount, state, worktree, branch, null, null); } /** Return a copy of this session in {@code state}. */ public MemberSession withState(State state) { return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, - lastActivityAtNanos, turnCount, state, worktree, branch, charterReceipt); + lastActivityAtNanos, turnCount, state, worktree, branch, charterReceipt, agentSessionId); } /** Return a copy with {@code lastActivityAtNanos} updated to {@code nowNanos}. */ public MemberSession withActivity(long nowNanos) { return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, - nowNanos, turnCount, state, worktree, branch, charterReceipt); + nowNanos, turnCount, state, worktree, branch, charterReceipt, agentSessionId); } /** Return a copy with the turn count incremented and activity timestamped at {@code nowNanos}. */ public MemberSession bumpTurn(long nowNanos) { return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, - nowNanos, turnCount + 1, state, worktree, branch, charterReceipt); + nowNanos, turnCount + 1, state, worktree, branch, charterReceipt, agentSessionId); } } diff --git a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java index 2cb7a4a..2b417ed 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java @@ -5,6 +5,7 @@ import dev.ltms.bridged.herdr.Agent; import dev.ltms.bridged.inject.TurnListener; import dev.ltms.bridged.inject.MemberPresence; import dev.ltms.bridged.msg.TurnToken; +import dev.ltms.bridged.peer.Capability; import dev.ltms.bridged.peer.MemberRole; import dev.ltms.bridged.peer.PeerHandle; import dev.ltms.bridged.peer.PeerLauncher; @@ -17,6 +18,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; @@ -126,21 +128,49 @@ public final class SessionManager implements TurnListener { } /** - * Spawn a member, optionally inside a fresh git worktree. When {@code wt} is non-null the - * worktree is provisioned, parity-overlaid, and its path becomes the member's cwd. On any - * failure before registration the worktree is removed so no dangling checkout is left. + * Spawn a member, optionally inside a fresh git worktree, with no session identity requested. + * Equivalent to {@link #acquire(String, MemberRole, String, String, String, WorktreeRequest, + * String, String)} with both trailing args {@code null}. * * @param profile which backend to run on — a {@code profiles:} key * @param role which contract the member runs under; never {@code null} */ public MemberSession acquire(String profile, MemberRole role, String requestedCwd, String callerCwd, String ownerTerminal, WorktreeRequest wt) { + return acquire(profile, role, requestedCwd, callerCwd, ownerTerminal, wt, null, null); + } + + /** + * Spawn a member, optionally inside a fresh git worktree, optionally onto a chosen or resumed + * agent session (CB-547a / CB-584). When {@code wt} is non-null the worktree is provisioned, + * parity-overlaid, and its path becomes the member's cwd. On any failure before registration the + * worktree is removed so no dangling checkout is left. + * + *

A non-blank {@code resumeSessionId} requires an explicit {@code profile}: a resumed + * conversation is tied to the specific backend that started it, so an unqualified spawn (whose + * backend a placement policy picks at spawn time) has no safe candidate to check the capability + * against. It also requires that profile's adapter to declare {@link Capability#SESSION_RESUME}; + * refusing rather than silently delivering a cold session on a non-supporting adapter is the + * whole point of checking before spawn, not after (CB-584). + * + * @param profile which backend to run on — a {@code profiles:} key + * @param role which contract the member runs under; never {@code null} + * @param sessionName the bridge's logical name for the session, or {@code null} + * @param resumeSessionId the prior agent session to resume, or {@code null} for a fresh one + * @throws IllegalArgumentException if {@code resumeSessionId} is set with no explicit profile, + * or the resolved profile's adapter lacks + * {@link Capability#SESSION_RESUME} + */ + public MemberSession acquire(String profile, MemberRole role, String requestedCwd, String callerCwd, + String ownerTerminal, WorktreeRequest wt, + String sessionName, String resumeSessionId) { MemberRole memberRole = (role == null) ? MemberRole.DEV : role; + requireResumeCapability(profile, resumeSessionId); if (wt == null) { // CB-557: the role must ride on the SpawnRequest, not stay a local. The launcher needs it // to pick the profile out of that role's pool and to label the tab; a role kept only on // the MemberSession is recorded after the spawn it was supposed to steer. - SpawnRequest req = new SpawnRequest(profile, requestedCwd, callerCwd, null, null, memberRole); + SpawnRequest req = new SpawnRequest(profile, requestedCwd, callerCwd, sessionName, resumeSessionId, memberRole); PeerHandle handle; try { handle = launcher.spawn(req); @@ -164,7 +194,8 @@ public final class SessionManager implements TurnListener { MemberSession.State.SPAWNING, null, null, - handle.charterReceipt()); + handle.charterReceipt(), + handle.agentSessionId()); registry.put(handle.id(), session); memberLifecycle.acquired(session.role(), session.profile(), session.terminalId()); log.debug("acquired session id={} terminal={} profile={} owner={}", @@ -172,7 +203,30 @@ public final class SessionManager implements TurnListener { notifyAcquired(session.terminalId()); return session; } - return acquireWithWorktree(profile, memberRole, requestedCwd, callerCwd, ownerTerminal, wt); + return acquireWithWorktree(profile, memberRole, requestedCwd, callerCwd, ownerTerminal, wt, + sessionName, resumeSessionId); + } + + /** + * Refuse a {@code resumeSessionId} that either names no explicit profile or names one whose + * adapter does not declare {@link Capability#SESSION_RESUME}. A no-op when + * {@code resumeSessionId} is blank — the ordinary, no-identity spawn path. + */ + private void requireResumeCapability(String profile, String resumeSessionId) { + if (resumeSessionId == null || resumeSessionId.isBlank()) { + return; + } + if (profile == null || profile.isBlank()) { + throw new IllegalArgumentException("resumeSessionId requires an explicit profile — " + + "a resumed conversation is tied to the backend that started it, so it cannot " + + "be left to placement to pick"); + } + Set caps = launcher.capabilitiesFor(profile); + if (!caps.contains(Capability.SESSION_RESUME)) { + throw new IllegalArgumentException("worker profile '" + profile + "' does not declare " + + "Capability.SESSION_RESUME — refusing resumeSessionId rather than silently " + + "starting a cold session"); + } } /** Tear a worker down by pane id and remove it from the registry. Idempotent. */ @@ -385,7 +439,8 @@ public final class SessionManager implements TurnListener { } private MemberSession acquireWithWorktree(String profile, MemberRole memberRole, String requestedCwd, String callerCwd, - String ownerTerminal, WorktreeRequest wt) { + String ownerTerminal, WorktreeRequest wt, + String sessionName, String resumeSessionId) { String preResolvedProfile = (profile == null || profile.isBlank()) ? launcher.defaultProfile() : profile; // CB-507: resolve through the launcher's CB-112 chain (requested → profile cwd → caller → @@ -401,7 +456,7 @@ public final class SessionManager implements TurnListener { try { path = worktrees.add(repoRoot, branch, wt.baseRef()); worktrees.overlayParity(repoRoot, path, launcher.parityOverlay(preResolvedProfile)); - handle = launcher.spawn(new SpawnRequest(profile, path, callerCwd, null, null, memberRole)); + handle = launcher.spawn(new SpawnRequest(profile, path, callerCwd, sessionName, resumeSessionId, memberRole)); } catch (RuntimeException e) { log.warn("spawn failed for profile={} role={} branch={} path={}: {}", preResolvedProfile, memberRole, branch, path, e.getMessage()); @@ -430,7 +485,8 @@ public final class SessionManager implements TurnListener { MemberSession.State.SPAWNING, path, branch, - handle.charterReceipt()); + handle.charterReceipt(), + handle.agentSessionId()); registry.put(handle.id(), session); memberLifecycle.acquired(session.role(), session.profile(), session.terminalId()); log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}", @@ -496,6 +552,12 @@ public final class SessionManager implements TurnListener { if (session.ownerTerminal() != null) { m.put("owner", session.ownerTerminal()); } + // CB-584: which conversation this member holds — the id a later resumeSessionId spawn would + // pass back. Absent for an adapter that declines Capability.SESSION_RESUME, or one that + // resolves it lazily and has not yet. + if (session.agentSessionId() != null) { + m.put("agentSessionId", session.agentSessionId()); + } // CB-571: which charter this member was started with — never the charter text itself. The // digest lets a lead tell at a glance whether all members got the same charter; the source // records whether a role charter was configured ("fleet.charters.") or only the reply 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 d70610f..e4619f3 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -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)); + } } diff --git a/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java index 3028831..f1a7fb6 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java @@ -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; } }; } diff --git a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java index ffaa64b..8a5472b 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java @@ -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 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 capabilities() { + return Set.of(Capability.WORKTREE); // deliberately no SESSION_RESUME + } + + @Override + public Set capabilitiesFor(String profileName) { + return capabilities(); + } + + @Override + public PeerHandle spawn(SpawnRequest req) { + throw new UnsupportedOperationException("not reachable — the capability check refuses first"); + } + + @Override + public Set 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 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 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()); + } }