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
@@ -226,7 +226,8 @@ public final class BridgeMcp {
// CB-301-ext: optional isolated worktree for parallel implementers. // CB-301-ext: optional isolated worktree for parallel implementers.
String callerCwd = identity.cwdForPid(callerPid(exchange)); String callerCwd = identity.cwdForPid(callerPid(exchange));
return spawn(sessions, str(a, "profile"), str(a, "role"), str(a, "cwd"), callerCwd, 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, _) -> { .toolCall(listTool(), (exchange, _) -> {
McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null); 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). */ /** {@code bridge_spawn} without cwd/caller context (default resolution). */
static McpSchema.CallToolResult spawn(SessionManager sessions, String profile) { 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. * {@code callerCwd} (the primary's directory), else the daemon's.
* CB-301: the session is registered with {@code ownerTerminal} as its owner. * 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-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.
* *
* <p>{@code role} and {@code profile} are independent: the role picks the contract, the profile * <p>{@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. * 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, static McpSchema.CallToolResult spawn(SessionManager sessions, String profile, String role,
String requestedCwd, String callerCwd, String requestedCwd, String callerCwd,
String ownerTerminal, WorktreeRequest worktreeRequest) { String ownerTerminal, WorktreeRequest worktreeRequest,
String sessionName, String resumeSessionId) {
MemberRole memberRole; MemberRole memberRole;
try { try {
memberRole = isBlank(role) ? MemberRole.DEV : MemberRole.parse(role); memberRole = isBlank(role) ? MemberRole.DEV : MemberRole.parse(role);
@@ -681,12 +687,13 @@ public final class BridgeMcp {
} }
try { try {
MemberSession member = sessions.acquire(isBlank(profile) ? null : profile, memberRole, 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))); return text(json(memberView(member)));
} catch (GuardException e) { } catch (GuardException e) {
return error("subscription boundary: " + e.getMessage()); return error("subscription boundary: " + e.getMessage());
} catch (IllegalArgumentException e) { } 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) { } catch (PeerUnreachableException e) {
return error("spawn timed out — worker pane never reached injectable state: " + e.getMessage()); return error("spawn timed out — worker pane never reached injectable state: " + e.getMessage());
} catch (HerdrException e) { } 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 " + "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 " + "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 " + "cwd to pin a different one. Pass worktree:true (with ticket) or "
+ "worktree:<ticket-slug> to provision an isolated git worktree. Returns the member's " + "worktree:<ticket-slug> to provision an isolated git worktree. Pass resumeSessionId "
+ "sessionId (use with bridge_send) and paneId (use with bridge_stop).", + "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( objectSchema(Map.of(
"role", stringProp("What the member is for: architect, dev or reviewer (default dev)"), "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)"), "profile", stringProp("Which backend to run it on (omit for the default profile)"),
"cwd", stringProp("Working directory for the member (omit to inherit yours)"), "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"), "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())); List.of()));
} }
@@ -1015,7 +1029,9 @@ public final class BridgeMcp {
+ "discover a peer lead without being told its address. 'members' are the " + "discover a peer lead without being told its address. 'members' are the "
+ "sessions delegated to — each with sessionId, paneId, role (architect/dev/" + "sessions delegated to — each with sessionId, paneId, role (architect/dev/"
+ "reviewer), profile (the backend it runs on), state, optional " + "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 " + "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 " + "facts are configured, a 'capacity' row per profile also reports free: 0 for "
+ "a quarantined profile's credential (see bridge_profiles), whatever its " + "a quarantined profile's credential (see bridge_profiles), whatever its "
@@ -460,6 +460,18 @@ public final class CompositePeerLauncher implements PeerLauncher {
return defaultProfile; return defaultProfile;
} }
/**
* {@inheritDoc}
*
* <p>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<Capability> capabilitiesFor(String profileName) {
return route(profileName).capabilities();
}
/** Every herdr agent, deduplicated by pane id (all delegates share one herdr and list globally). */ /** Every herdr agent, deduplicated by pane id (all delegates share one herdr and list globally). */
@Override @Override
public List<Agent> list() { public List<Agent> list() {
@@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.HerdrException;
import dev.ltms.bridged.herdr.Tab; import dev.ltms.bridged.herdr.Tab;
import dev.ltms.bridged.herdr.Workspace; import dev.ltms.bridged.herdr.Workspace;
import dev.ltms.bridged.herdr.WorkspaceControl; import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.peer.Capability;
import dev.ltms.bridged.peer.CharterReceipt; import dev.ltms.bridged.peer.CharterReceipt;
import dev.ltms.bridged.peer.MemberRole; import dev.ltms.bridged.peer.MemberRole;
import dev.ltms.bridged.peer.PeerHandle; import dev.ltms.bridged.peer.PeerHandle;
@@ -248,6 +249,19 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
return defaultProfile; return defaultProfile;
} }
/**
* {@inheritDoc}
*
* <p>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<Capability> capabilitiesFor(String profileName) {
requireProfile(profileName);
return capabilities();
}
/** The configured profiles, for adapter capability decisions (e.g. any git-token grant). */ /** The configured profiles, for adapter capability decisions (e.g. any git-token grant). */
protected Collection<BridgedConfig.Profile> profileConfigs() { protected Collection<BridgedConfig.Profile> profileConfigs() {
return profiles.values(); return profiles.values();
@@ -62,11 +62,15 @@ public interface PeerHandle {
* non-null for a spawn that requested session identity, because it knows the id before the * non-null for a spawn that requested session identity, because it knows the id before the
* peer has written anything. * peer has written anything.
* *
* <p>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 * @return the peer's own session id, or {@code null} when not determinable
*/ */
default String agentSessionId() { String agentSessionId();
return null;
}
/** /**
* The charter receipt (CB-571) for this peer's launch — the fingerprint of the exact charter * The charter receipt (CB-571) for this peer's launch — the fingerprint of the exact charter
@@ -25,6 +25,19 @@ public interface PeerLauncher {
*/ */
Set<Capability> capabilities(); Set<Capability> 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
* <em>this</em> 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<Capability> capabilitiesFor(String profileName);
/** /**
* {@code profileName}/requestedCwd null/blank → default resolution. Returns after the peer * {@code profileName}/requestedCwd null/blank → default resolution. Returns after the peer
* process is live (env + argv + placement complete). Never returns {@code null}. * process is live (env + argv + placement complete). Never returns {@code null}.
@@ -26,6 +26,11 @@ import dev.ltms.bridged.peer.MemberRole;
* @param state current lifecycle state in the one-shot FSM * @param state current lifecycle state in the one-shot FSM
* @param charterReceipt the fingerprint (CB-571) of the charter bytes this member was started * @param charterReceipt the fingerprint (CB-571) of the charter bytes this member was started
* with; {@code null} for a session whose launcher recorded none * 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( public record MemberSession(
String paneId, String paneId,
@@ -40,7 +45,8 @@ public record MemberSession(
State state, State state,
String worktree, String worktree,
String branch, String branch,
CharterReceipt charterReceipt) { CharterReceipt charterReceipt,
String agentSessionId) {
/** One-shot worker lifecycle states. */ /** One-shot worker lifecycle states. */
public enum State { 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 * Backward-compatible shape: a session with no charter receipt and no agent session id (a test
* CB-571). A separate constructor rather than a new parameter on the canonical one, so existing * or a launcher before CB-571 / CB-584). A separate constructor rather than a new parameter on
* call sites that have nothing to record keep compiling unchanged. * 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, public MemberSession(String paneId, String terminalId, String profile, MemberRole role,
String cwd, String ownerTerminal, long spawnedAtNanos, String cwd, String ownerTerminal, long spawnedAtNanos,
long lastActivityAtNanos, int turnCount, State state, long lastActivityAtNanos, int turnCount, State state,
String worktree, String branch) { String worktree, String branch) {
this(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, 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}. */ /** Return a copy of this session in {@code state}. */
public MemberSession withState(State state) { public MemberSession withState(State state) {
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, 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}. */ /** Return a copy with {@code lastActivityAtNanos} updated to {@code nowNanos}. */
public MemberSession withActivity(long nowNanos) { public MemberSession withActivity(long nowNanos) {
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, 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}. */ /** Return a copy with the turn count incremented and activity timestamped at {@code nowNanos}. */
public MemberSession bumpTurn(long nowNanos) { public MemberSession bumpTurn(long nowNanos) {
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, 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);
} }
} }
@@ -5,6 +5,7 @@ import dev.ltms.bridged.herdr.Agent;
import dev.ltms.bridged.inject.TurnListener; import dev.ltms.bridged.inject.TurnListener;
import dev.ltms.bridged.inject.MemberPresence; import dev.ltms.bridged.inject.MemberPresence;
import dev.ltms.bridged.msg.TurnToken; import dev.ltms.bridged.msg.TurnToken;
import dev.ltms.bridged.peer.Capability;
import dev.ltms.bridged.peer.MemberRole; import dev.ltms.bridged.peer.MemberRole;
import dev.ltms.bridged.peer.PeerHandle; import dev.ltms.bridged.peer.PeerHandle;
import dev.ltms.bridged.peer.PeerLauncher; import dev.ltms.bridged.peer.PeerLauncher;
@@ -17,6 +18,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong; 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 * Spawn a member, optionally inside a fresh git worktree, with no session identity requested.
* worktree is provisioned, parity-overlaid, and its path becomes the member's cwd. On any * Equivalent to {@link #acquire(String, MemberRole, String, String, String, WorktreeRequest,
* failure before registration the worktree is removed so no dangling checkout is left. * String, String)} with both trailing args {@code null}.
* *
* @param profile which backend to run on — a {@code profiles:} key * @param profile which backend to run on — a {@code profiles:} key
* @param role which contract the member runs under; never {@code null} * @param role which contract the member runs under; never {@code null}
*/ */
public MemberSession acquire(String profile, MemberRole role, String requestedCwd, String callerCwd, public MemberSession acquire(String profile, MemberRole role, String requestedCwd, String callerCwd,
String ownerTerminal, WorktreeRequest wt) { 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.
*
* <p>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; MemberRole memberRole = (role == null) ? MemberRole.DEV : role;
requireResumeCapability(profile, resumeSessionId);
if (wt == null) { if (wt == null) {
// CB-557: the role must ride on the SpawnRequest, not stay a local. The launcher needs it // 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 // 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. // 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; PeerHandle handle;
try { try {
handle = launcher.spawn(req); handle = launcher.spawn(req);
@@ -164,7 +194,8 @@ public final class SessionManager implements TurnListener {
MemberSession.State.SPAWNING, MemberSession.State.SPAWNING,
null, null,
null, null,
handle.charterReceipt()); handle.charterReceipt(),
handle.agentSessionId());
registry.put(handle.id(), session); registry.put(handle.id(), session);
memberLifecycle.acquired(session.role(), session.profile(), session.terminalId()); memberLifecycle.acquired(session.role(), session.profile(), session.terminalId());
log.debug("acquired session id={} terminal={} profile={} owner={}", log.debug("acquired session id={} terminal={} profile={} owner={}",
@@ -172,7 +203,30 @@ public final class SessionManager implements TurnListener {
notifyAcquired(session.terminalId()); notifyAcquired(session.terminalId());
return session; 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<Capability> 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. */ /** 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, 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()) String preResolvedProfile = (profile == null || profile.isBlank())
? launcher.defaultProfile() : profile; ? launcher.defaultProfile() : profile;
// CB-507: resolve through the launcher's CB-112 chain (requested → profile cwd → caller → // 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 { try {
path = worktrees.add(repoRoot, branch, wt.baseRef()); path = worktrees.add(repoRoot, branch, wt.baseRef());
worktrees.overlayParity(repoRoot, path, launcher.parityOverlay(preResolvedProfile)); 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) { } catch (RuntimeException e) {
log.warn("spawn failed for profile={} role={} branch={} path={}: {}", log.warn("spawn failed for profile={} role={} branch={} path={}: {}",
preResolvedProfile, memberRole, branch, path, e.getMessage()); preResolvedProfile, memberRole, branch, path, e.getMessage());
@@ -430,7 +485,8 @@ public final class SessionManager implements TurnListener {
MemberSession.State.SPAWNING, MemberSession.State.SPAWNING,
path, path,
branch, branch,
handle.charterReceipt()); handle.charterReceipt(),
handle.agentSessionId());
registry.put(handle.id(), session); registry.put(handle.id(), session);
memberLifecycle.acquired(session.role(), session.profile(), session.terminalId()); memberLifecycle.acquired(session.role(), session.profile(), session.terminalId());
log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}", log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}",
@@ -496,6 +552,12 @@ public final class SessionManager implements TurnListener {
if (session.ownerTerminal() != null) { if (session.ownerTerminal() != null) {
m.put("owner", session.ownerTerminal()); 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 // 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 // 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.<role>") or only the reply // records whether a role charter was configured ("fleet.charters.<role>") or only the reply
@@ -427,7 +427,8 @@ class BridgeMcpTest {
void spawnPassesTheRequestedCwdToTheWorker() { void spawnPassesTheRequestedCwdToTheWorker() {
FakeHerdr h = new FakeHerdr(); FakeHerdr h = new FakeHerdr();
McpSchema.CallToolResult res = BridgeMcp.spawn( 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()); assertNotEquals(Boolean.TRUE, res.isError());
// Protocol 19: the requested cwd roots the worker's pane at creation (tab.create). // Protocol 19: the requested cwd roots the worker's pane at creation (tab.create).
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@@ -886,7 +887,8 @@ class BridgeMcpTest {
void spawnDefaultsTheRoleToDev() { void spawnDefaultsTheRoleToDev() {
FakeHerdr h = new FakeHerdr(); FakeHerdr h = new FakeHerdr();
McpSchema.CallToolResult res = BridgeMcp.spawn( 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()); assertNotEquals(Boolean.TRUE, res.isError());
assertTrue(textOf(res).contains("\"role\":\"dev\""), textOf(res)); assertTrue(textOf(res).contains("\"role\":\"dev\""), textOf(res));
@@ -897,7 +899,7 @@ class BridgeMcpTest {
FakeHerdr h = new FakeHerdr(); FakeHerdr h = new FakeHerdr();
McpSchema.CallToolResult res = BridgeMcp.spawn( McpSchema.CallToolResult res = BridgeMcp.spawn(
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "architect", 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()); assertNotEquals(Boolean.TRUE, res.isError());
assertTrue(textOf(res).contains("\"role\":\"architect\""), textOf(res)); assertTrue(textOf(res).contains("\"role\":\"architect\""), textOf(res));
@@ -908,9 +910,36 @@ class BridgeMcpTest {
FakeHerdr h = new FakeHerdr(); FakeHerdr h = new FakeHerdr();
McpSchema.CallToolResult res = BridgeMcp.spawn( McpSchema.CallToolResult res = BridgeMcp.spawn(
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "worker", 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()); assertEquals(Boolean.TRUE, res.isError());
assertTrue(textOf(res).contains("architect, dev, reviewer"), textOf(res)); 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 id() { return "pane-" + p; }
@Override public String terminalId() { return "term-" + p; } @Override public String terminalId() { return "term-" + p; }
@Override public String profile() { return p; } @Override public String profile() { return p; }
@Override public String agentSessionId() { return null; }
@Override public CharterReceipt charterReceipt() { 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.herdr.WorkspaceControl;
import dev.ltms.bridged.member.ClaudeCodeLauncher; import dev.ltms.bridged.member.ClaudeCodeLauncher;
import dev.ltms.bridged.msg.TestTurnTokens; import dev.ltms.bridged.msg.TestTurnTokens;
import dev.ltms.bridged.peer.Capability;
import dev.ltms.bridged.peer.CharterReceipt; import dev.ltms.bridged.peer.CharterReceipt;
import dev.ltms.bridged.peer.MemberRole; 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.PeerUnreachableException;
import dev.ltms.bridged.peer.SpawnRequest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory; 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). // 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, MemberSession s = new MemberSession("p1", "term1", "prof", MemberRole.DEV, "/cwd", null,
0, 0, 0, MemberSession.State.READY, null, 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); Map<String, Object> view = SessionManager.rosterView(s, null);
@@ -790,4 +794,127 @@ class SessionManagerTest {
assertTrue(worktrees.removeCalls().isEmpty(), "SHUTDOWN drain still preserves the worktree"); 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());
}
} }