Merge remote-tracking branch 'origin/worker/756-758-observer-pane-discovery-7e6ffd-1' into vfy/762
This commit is contained in:
@@ -284,8 +284,13 @@ public final class CallerResolver {
|
||||
&& !collaboratorTerminals.get().containsKey(target);
|
||||
}
|
||||
|
||||
/** Whether {@code terminal} is bound to a configured slot the live roster still confirms as an architect. */
|
||||
private boolean boundToArchitectSlot(String terminal) {
|
||||
/**
|
||||
* Whether {@code terminal} is bound to a configured slot the live roster still confirms as an
|
||||
* architect — the one classifier {@link #sendableObserverTarget()} and {@code FleetMcp}'s
|
||||
* {@code panes} row both read, so a pane's reported role and its {@code SEND} reachability can
|
||||
* never drift apart.
|
||||
*/
|
||||
public boolean boundToArchitectSlot(String terminal) {
|
||||
String slot = architectTerminals.get().get(terminal);
|
||||
return slot != null && memberSlotRoles.apply(slot) == MemberRole.ARCHITECT;
|
||||
}
|
||||
|
||||
@@ -308,17 +308,25 @@ public final class FleetMcp {
|
||||
|
||||
/**
|
||||
* Pane-discovery facts for {@code fleet_list}'s {@code panes} row — every herdr tab's display
|
||||
* label, and the one deliverability gate the status-gated injector itself reads.
|
||||
* label, the one deliverability gate the status-gated injector itself reads, whether a
|
||||
* terminal is bound to a configured architect slot, and whether an observer caller may
|
||||
* {@code SEND} to it.
|
||||
*
|
||||
* @param tabLabels tab id → its display label, read lazily (only once the row is actually
|
||||
* assembled) since it costs a herdr {@code workspace.list}/{@code tab.list}
|
||||
* scan; a tab herdr reports with no label maps to a {@code null} value
|
||||
* @param deliverable the same gate {@link dev.ltms.fleet.Fleetd#deliverableTo} builds for the
|
||||
* injector, keyed by terminal id — never a second, separately-derived check
|
||||
* @param architectSlot the same classifier {@link CallerResolver#boundToArchitectSlot} resolves
|
||||
* a caller against — never a second, separately-derived check
|
||||
* @param sendableToObserver the same predicate {@link CallerResolver#sendableObserverTarget}
|
||||
* builds for the {@code SEND} gate — never a second, separately-derived
|
||||
* check
|
||||
*/
|
||||
public record PaneSource(Supplier<Map<String, String>> tabLabels, Predicate<String> deliverable) {
|
||||
/** Inert source — no labels, and every target reports non-deliverable. */
|
||||
public static PaneSource none() { return new PaneSource(Map::of, _ -> false); }
|
||||
public record PaneSource(Supplier<Map<String, String>> tabLabels, Predicate<String> deliverable,
|
||||
Predicate<String> architectSlot, Predicate<String> sendableToObserver) {
|
||||
/** Inert source — no labels, no deliverable targets, no architect slots, nothing sendable. */
|
||||
public static PaneSource none() { return new PaneSource(Map::of, _ -> false, _ -> false, _ -> false); }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -589,7 +597,8 @@ public final class FleetMcp {
|
||||
// A Supplier: the label lookup costs a herdr scan, and must stay behind
|
||||
// panesVisible so it only runs for a caller that receives the row at all.
|
||||
PaneSource panes = new PaneSource(() -> identity.panes().tabLabelsByTabId(),
|
||||
Fleetd.deliverableTo(presence, callers::leads, callers::collaborators));
|
||||
Fleetd.deliverableTo(presence, callers::leads, callers::collaborators),
|
||||
callers::boundToArchitectSlot, callers.sendableObserverTarget());
|
||||
return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, outage,
|
||||
leadSeats, leadContextGauge, leadConfigDirs, callers.leads(),
|
||||
callerTerminal(exchange),
|
||||
@@ -597,7 +606,7 @@ public final class FleetMcp {
|
||||
new CoordinationSource(leadChannel, peers),
|
||||
coordinatorVisibleTo(principal(exchange)),
|
||||
leadsVisibleTo(principal(exchange)), membersVisibleTo(principal(exchange)),
|
||||
panes, panesVisibleTo(principal(exchange)));
|
||||
panes, panesVisibleTo(principal(exchange)), principal(exchange).isObserver());
|
||||
};
|
||||
BiFunction<McpSyncServerExchange, McpSchema.CallToolRequest, McpSchema.CallToolResult> stopHandler =
|
||||
(exchange, req) -> {
|
||||
@@ -824,13 +833,14 @@ public final class FleetMcp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may see {@code fleet_list}'s {@code panes} array — every herdr-tracked agent pane on the
|
||||
* host, carrying a tab label and a member's {@code cwd}. Visible to exactly the roles that may
|
||||
* {@link Authz.Action#SEND} to a named peer; a plain worker or an observer holds {@code READ}
|
||||
* but never {@code SEND}, so it does not see this array.
|
||||
* Who may see {@code fleet_list}'s {@code panes} array — every role that may
|
||||
* {@link Authz.Action#SEND} to some other pane. A plain worker holds {@code READ} but never
|
||||
* {@code SEND}, so it still does not see this array. An observer does hold {@code SEND}, to
|
||||
* another observer pane only, so it sees the array too — but {@code listFleet} filters its rows
|
||||
* to {@link CallerResolver#sendableObserverTarget} and reduces each one; see {@code paneRows}.
|
||||
*/
|
||||
static boolean panesVisibleTo(Principal caller) {
|
||||
return caller.isPrimary() || caller.isArchitect() || caller.isCollaborator();
|
||||
return caller.isPrimary() || caller.isArchitect() || caller.isCollaborator() || caller.isObserver();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2069,6 +2079,32 @@ public final class FleetMcp {
|
||||
CoordinationSource coordination, boolean callerIsPrimary,
|
||||
boolean leadsVisible, boolean membersVisible,
|
||||
PaneSource panes, boolean panesVisible) {
|
||||
return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, outage,
|
||||
leadSeats, contextGauge, leadConfigDirs, leads, selfTerm, collaborators, collaboratorsVisible,
|
||||
coordination, callerIsPrimary, leadsVisible, membersVisible, panes, panesVisible, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* As above, plus fleetd #758: an observer sees the {@code panes} array too, but filtered to
|
||||
* {@link CallerResolver#sendableObserverTarget} and each row reduced to the five fields an
|
||||
* observer may learn — see {@code paneRows}/{@code paneRow}.
|
||||
*
|
||||
* @param callerIsObserver whether the {@code fleet_list} caller is an observer; every wrapper
|
||||
* overload above passes {@code false}, so a test that wants the
|
||||
* filtered, reduced view must call this overload with an explicit
|
||||
* {@code true}
|
||||
*/
|
||||
static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions, MessageService messages,
|
||||
CapacitySource capacity, HealthCoverageSource healthCoverage,
|
||||
LoopHealthSource loopHealth,
|
||||
QuarantineSource quarantine, OutageSource outage,
|
||||
LeadSeatSource leadSeats, LeadContextGauge contextGauge,
|
||||
LeadConfigDirSource leadConfigDirs,
|
||||
Map<String, String> leads, String selfTerm,
|
||||
Map<String, String> collaborators, boolean collaboratorsVisible,
|
||||
CoordinationSource coordination, boolean callerIsPrimary,
|
||||
boolean leadsVisible, boolean membersVisible,
|
||||
PaneSource panes, boolean panesVisible, boolean callerIsObserver) {
|
||||
try {
|
||||
// Neither row's assembly (leadView/memberCapacityView probing herdr for live status)
|
||||
// runs unless at least one of them needs the live-agent lookup backing it.
|
||||
@@ -2118,7 +2154,7 @@ public final class FleetMcp {
|
||||
}
|
||||
// gate BEFORE assembling the row, so the key is absent rather than present-and-empty.
|
||||
if (panesVisible) {
|
||||
result.put("panes", paneRows(live, roster, leads, collaborators, panes));
|
||||
result.put("panes", paneRows(live, roster, leads, collaborators, panes, callerIsObserver));
|
||||
}
|
||||
// fleetd #439: coordinator/coordinatorView is lead-to-lead coordination state and must
|
||||
// never reach a worker or an architect -- gate BEFORE assembling it, not after, so the
|
||||
@@ -2463,16 +2499,23 @@ public final class FleetMcp {
|
||||
* is the same terminal-keyed {@link Agent} map {@code leadView}/{@code memberCapacityView}
|
||||
* already read, so a pane neither configured as a lead nor spawned as a member — a hand-opened
|
||||
* tab — still gets a row here.
|
||||
*
|
||||
* <p>fleetd #758: for an observer caller ({@code observerView}), the rows are filtered to
|
||||
* {@link PaneSource#sendableToObserver} before being built, and each row is reduced — see
|
||||
* {@code paneRow}.
|
||||
*/
|
||||
private static List<Map<String, Object>> paneRows(Map<String, Agent> live, List<MemberSession> roster,
|
||||
Map<String, String> leads, Map<String, String> collaborators, PaneSource panes) {
|
||||
Map<String, String> leads, Map<String, String> collaborators, PaneSource panes,
|
||||
boolean observerView) {
|
||||
final Map<String, String> tabLabels = tabLabelsOrEmpty(panes);
|
||||
Map<String, MemberSession> byTerminal = roster.stream()
|
||||
.filter(s -> s.terminalId() != null)
|
||||
.collect(Collectors.toMap(MemberSession::terminalId, Function.identity(), (_, b) -> b));
|
||||
return live.values().stream()
|
||||
.filter(a -> !observerView || panes.sendableToObserver().test(a.terminalId()))
|
||||
.sorted(Comparator.comparing(Agent::terminalId))
|
||||
.map(a -> paneRow(a, byTerminal.get(a.terminalId()), leads, collaborators, tabLabels, panes))
|
||||
.map(a -> paneRow(a, byTerminal.get(a.terminalId()), leads, collaborators, tabLabels, panes,
|
||||
observerView))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -2481,38 +2524,53 @@ public final class FleetMcp {
|
||||
* daemon never spawned as a member (a hand-opened tab, or a configured lead)
|
||||
* @param tabLabels tab id → its herdr display label; a tab absent here, or carrying a
|
||||
* {@code null} label itself, projects as a {@code null} "label"
|
||||
* @param observerView fleetd #758: an observer's row carries only {@code sessionId}, {@code
|
||||
* label}, {@code status}, {@code role}, {@code deliverable} — never {@code
|
||||
* paneId} (the {@code fleet_stop} handle), {@code workspaceId}, {@code tabId},
|
||||
* {@code agentType}, or {@code cwd} (a member's worktree path is the lead's
|
||||
* business)
|
||||
*/
|
||||
private static Map<String, Object> paneRow(Agent a, MemberSession session, Map<String, String> leads,
|
||||
Map<String, String> collaborators, Map<String, String> tabLabels, PaneSource panes) {
|
||||
Map<String, String> collaborators, Map<String, String> tabLabels, PaneSource panes,
|
||||
boolean observerView) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("sessionId", a.terminalId());
|
||||
m.put("paneId", a.paneId());
|
||||
m.put("workspaceId", a.workspaceId());
|
||||
m.put("tabId", a.tabId());
|
||||
if (!observerView) {
|
||||
m.put("paneId", a.paneId());
|
||||
m.put("workspaceId", a.workspaceId());
|
||||
m.put("tabId", a.tabId());
|
||||
}
|
||||
m.put("label", a.tabId() == null ? null : tabLabels.get(a.tabId()));
|
||||
m.put("agentType", a.agentType());
|
||||
if (!observerView) {
|
||||
m.put("agentType", a.agentType());
|
||||
}
|
||||
m.put("status", a.status() == null ? "unknown" : a.status().name().toLowerCase());
|
||||
m.put("role", paneRole(a.terminalId(), session, leads, collaborators));
|
||||
m.put("role", paneRole(a.terminalId(), session, leads, collaborators, panes));
|
||||
m.put("deliverable", panes.deliverable().test(a.terminalId()));
|
||||
if (session != null && session.cwd() != null) {
|
||||
if (!observerView && session != null && session.cwd() != null) {
|
||||
m.put("cwd", session.cwd());
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* The role this pane resolves as: a spawned member's own {@link MemberRole}, else "lead" or
|
||||
* "collaborator" for a configured but currently-unoccupied slot, else "observer" for a pane
|
||||
* this daemon neither spawned nor configured.
|
||||
* The role this pane resolves as: a spawned member's own {@link MemberRole}, else "lead" for a
|
||||
* configured but currently-unoccupied lead pane, else "architect" for a pane bound to a
|
||||
* configured architect slot with no live member session, else "collaborator" for a configured
|
||||
* but currently-unoccupied collaborator tab, else "observer" for a pane this daemon neither
|
||||
* spawned nor configured.
|
||||
*/
|
||||
private static String paneRole(String terminal, MemberSession session, Map<String, String> leads,
|
||||
Map<String, String> collaborators) {
|
||||
Map<String, String> collaborators, PaneSource panes) {
|
||||
if (session != null) {
|
||||
return session.role().wireName();
|
||||
}
|
||||
if (leads.containsKey(terminal)) {
|
||||
return "lead";
|
||||
}
|
||||
if (panes.architectSlot().test(terminal)) {
|
||||
return "architect";
|
||||
}
|
||||
if (collaborators.containsKey(terminal)) {
|
||||
return "collaborator";
|
||||
}
|
||||
|
||||
@@ -512,19 +512,21 @@ class FleetMcpAuthzTest {
|
||||
|
||||
/**
|
||||
* {@link FleetMcp#panesVisibleTo} is the whole policy decision for {@code fleet_list}'s
|
||||
* {@code panes} array: visible to exactly the roles that may {@code SEND} to a named peer --
|
||||
* the primary, an architect, and a collaborator -- never a worker, never an unconfigured
|
||||
* observer pane, and never an anonymous caller.
|
||||
* {@code panes} array: visible to every role that may {@code SEND} to some other pane -- the
|
||||
* primary, an architect, a collaborator, and an observer (to another observer pane only, with
|
||||
* its row filtered and reduced -- see {@code listFleet}) -- never a worker, never an
|
||||
* anonymous caller.
|
||||
*/
|
||||
@Test
|
||||
void onlyPrimaryArchitectAndCollaboratorMaySeeThePanesArray() {
|
||||
void onlyPrimaryArchitectCollaboratorAndObserverMaySeeThePanesArray() {
|
||||
assertTrue(FleetMcp.panesVisibleTo(PRIMARY), "the primary must see the panes array");
|
||||
assertTrue(FleetMcp.panesVisibleTo(ARCH_DESIGN), "an architect must see the panes array");
|
||||
assertTrue(FleetMcp.panesVisibleTo(COLLABORATOR), "a collaborator must see its own peer roster");
|
||||
assertFalse(FleetMcp.panesVisibleTo(WORKER_A),
|
||||
"a worker holds READ but can never SEND, so it must not see the panes array");
|
||||
assertFalse(FleetMcp.panesVisibleTo(Principal.observer("term_obs", 700)),
|
||||
"an unconfigured observer pane must not see every other pane's label and cwd");
|
||||
assertTrue(FleetMcp.panesVisibleTo(Principal.observer("term_obs", 700)),
|
||||
"an observer holds SEND to another observer pane, so it must see the (filtered, "
|
||||
+ "reduced) panes array");
|
||||
assertFalse(FleetMcp.panesVisibleTo(ANON), "authenticated as nothing must not see it either");
|
||||
}
|
||||
|
||||
|
||||
@@ -1138,7 +1138,7 @@ class FleetMcpTest {
|
||||
presence.markPresent("term_a");
|
||||
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
|
||||
() -> new PaneLocator(h).tabLabelsByTabId(),
|
||||
Fleetd.deliverableTo(presence, Map::of, Map::of));
|
||||
Fleetd.deliverableTo(presence, Map::of, Map::of), _ -> false, _ -> false);
|
||||
|
||||
String out = textOf(listFleetWithPanes(h, panes, true));
|
||||
|
||||
@@ -1159,7 +1159,7 @@ class FleetMcpTest {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
|
||||
() -> new PaneLocator(h).tabLabelsByTabId(),
|
||||
Fleetd.deliverableTo(new MemberPresence(), Map::of, Map::of));
|
||||
Fleetd.deliverableTo(new MemberPresence(), Map::of, Map::of), _ -> false, _ -> false);
|
||||
|
||||
String out = textOf(listFleetWithPanes(h, panes, true));
|
||||
|
||||
@@ -1190,7 +1190,7 @@ class FleetMcpTest {
|
||||
FakeHerdr h = new FakeHerdr().workspaceListFailsWith("unavailable");
|
||||
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
|
||||
() -> new PaneLocator(h).tabLabelsByTabId(),
|
||||
Fleetd.deliverableTo(new MemberPresence(), Map::of, Map::of));
|
||||
Fleetd.deliverableTo(new MemberPresence(), Map::of, Map::of), _ -> false, _ -> false);
|
||||
|
||||
McpSchema.CallToolResult res = listFleetWithPanes(h, panes, true);
|
||||
|
||||
@@ -1203,6 +1203,123 @@ class FleetMcpTest {
|
||||
assertTrue(out.contains("\"members\":[]"), "a label-scan failure must not cost the members array: " + out);
|
||||
}
|
||||
|
||||
/**
|
||||
* fleetd #756: a pane bound to a configured architect slot with no live member session must
|
||||
* report {@code role: "architect"}, read from {@link CallerResolver#boundToArchitectSlot} —
|
||||
* the same classifier {@link CallerResolver#sendableObserverTarget} refuses as a {@code SEND}
|
||||
* target — rather than falling through to {@code "observer"}.
|
||||
*/
|
||||
@Test
|
||||
void listReportsArchitectForASlotBoundPaneWithNoLiveMember() {
|
||||
FakeHerdr h = new FakeHerdr()
|
||||
.withAgent("claude-arch", "term_unoccupied_architect", "w2:pArch", "w2:tArch");
|
||||
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
|
||||
Map::of, _ -> false, "term_unoccupied_architect"::equals, _ -> false);
|
||||
|
||||
String out = textOf(listFleetWithPanes(h, panes, true));
|
||||
|
||||
assertTrue(out.contains("\"sessionId\":\"term_unoccupied_architect\""), out);
|
||||
assertTrue(out.contains("\"role\":\"architect\""),
|
||||
"a slot-bound pane with no live session must read \"architect\", not the generic "
|
||||
+ "\"observer\" fallback: " + out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the canonical {@code listFleet} overload directly with an explicit {@code leads}/
|
||||
* {@code collaborators} payload and {@code callerIsObserver}, mirroring exactly what the real
|
||||
* {@code fleet_list} handler computes for an observer caller: {@code panesVisible} true,
|
||||
* {@code leadsVisible}/{@code membersVisible}/{@code collaboratorsVisible} false.
|
||||
*/
|
||||
private static McpSchema.CallToolResult listFleetAsObserver(FakeHerdr h, Map<String, String> leads,
|
||||
Map<String, String> collaborators, FleetMcp.PaneSource panes) {
|
||||
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
||||
return FleetMcp.listFleet(
|
||||
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||
FleetMcp.LoopHealthSource.none(), FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(),
|
||||
FleetMcp.LeadSeatSource.none(), new LeadContextGauge(), FleetMcp.LeadConfigDirSource.none(),
|
||||
leads, "", collaborators, false,
|
||||
FleetMcp.CoordinationSource.none(), false, false, false, panes, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* fleetd #758: an observer's {@code fleet_list} now carries a {@code panes} key, filtered to
|
||||
* {@link CallerResolver#sendableObserverTarget} (so a lead's pane, a spawned member's pane, a
|
||||
* collaborator's pane, and an unoccupied architect-slot pane are all absent) and every
|
||||
* surviving row reduced to exactly {@code sessionId}, {@code label}, {@code status},
|
||||
* {@code role}, {@code deliverable} — never {@code paneId}, {@code workspaceId}, {@code tabId},
|
||||
* {@code agentType}, or {@code cwd}.
|
||||
*/
|
||||
@Test
|
||||
void listFiltersAndReducesThePanesArrayForAnObserver() {
|
||||
MemberRegistry members = new MemberRegistry(new FleetConfig.Fleet(Map.of(),
|
||||
Map.of("lead-designer", new FleetConfig.Slot("sonnet")), Map.of(), Map.of(), null));
|
||||
assertTrue(members.bind("architect:lead-designer", "term_architect_pane"));
|
||||
CallerResolver callers = CallerResolver.withLeadsAndMembers(null, false, null,
|
||||
() -> Map.of("term_lead_pane", "fleet01-lead"), members,
|
||||
t -> "term_member_pane".equals(t) ? MemberRole.DEV : null,
|
||||
() -> Map.of("term_collab_pane", "ops"));
|
||||
|
||||
FakeHerdr h = new FakeHerdr()
|
||||
.withAgent("claude-sendable", "term_sendable", "w2:pS", "w2:tS")
|
||||
.withAgent("claude-lead", "term_lead_pane", "w2:pL", "w2:tL")
|
||||
.withAgent("claude-member", "term_member_pane", "w2:pM", "w2:tM")
|
||||
.withAgent("claude-collab", "term_collab_pane", "w2:pC", "w2:tC")
|
||||
.withAgent("claude-arch", "term_architect_pane", "w2:pA", "w2:tA")
|
||||
.withTab("w2", "w2:tS", "trinotes");
|
||||
|
||||
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
|
||||
() -> new PaneLocator(h).tabLabelsByTabId(), _ -> true,
|
||||
callers::boundToArchitectSlot, callers.sendableObserverTarget());
|
||||
|
||||
String out = textOf(listFleetAsObserver(h, callers.leads(),
|
||||
Map.of("term_collab_pane", "ops"), panes));
|
||||
|
||||
assertTrue(out.contains("\"panes\":["), out);
|
||||
assertTrue(out.contains("\"sessionId\":\"term_sendable\""),
|
||||
"an ordinary unclassified pane must still be sendable and visible: " + out);
|
||||
assertFalse(out.contains("term_lead_pane"), "a lead's pane must not be enumerated: " + out);
|
||||
assertFalse(out.contains("term_member_pane"), "a spawned member's pane must not be enumerated: " + out);
|
||||
assertFalse(out.contains("term_collab_pane"), "a collaborator's pane must not be enumerated: " + out);
|
||||
assertFalse(out.contains("term_architect_pane"),
|
||||
"an unoccupied architect-slot pane must not be enumerated: " + out);
|
||||
assertFalse(out.contains("\"paneId\""), "an observer's row must never carry paneId: " + out);
|
||||
assertFalse(out.contains("\"workspaceId\""), "an observer's row must never carry workspaceId: " + out);
|
||||
assertFalse(out.contains("\"tabId\""), "an observer's row must never carry tabId: " + out);
|
||||
assertFalse(out.contains("\"agentType\""), "an observer's row must never carry agentType: " + out);
|
||||
assertFalse(out.contains("\"cwd\""), "an observer's row must never carry cwd: " + out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Control for the test above: a primary's {@code panes} row is unchanged by fleetd #758 —
|
||||
* {@code callerIsObserver} false keeps every field, including {@code paneId} and a spawned
|
||||
* member's {@code cwd}.
|
||||
*/
|
||||
@Test
|
||||
void listKeepsTheFullPaneRowForAPrimaryIncludingCwdAndPaneId() {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
||||
MemberSession spawned = sessions.acquire("ltms-local", "/worktree/member-1", null, null);
|
||||
h.withAgent("claude-member", spawned.terminalId(), "w9:pMember", "w9:tMember");
|
||||
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(Map::of, _ -> true, _ -> false, _ -> false);
|
||||
|
||||
McpSchema.CallToolResult res = FleetMcp.listFleet(
|
||||
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null,
|
||||
FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
|
||||
FleetMcp.LoopHealthSource.none(), FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(),
|
||||
FleetMcp.LeadSeatSource.none(), new LeadContextGauge(), FleetMcp.LeadConfigDirSource.none(),
|
||||
Map.of(), "", Map.of(), false,
|
||||
FleetMcp.CoordinationSource.none(), true, true, true, panes, true, false);
|
||||
|
||||
String out = textOf(res);
|
||||
assertTrue(out.contains("\"sessionId\":\"" + spawned.terminalId() + "\""), out);
|
||||
assertTrue(out.contains("\"paneId\":\"w9:pMember\""),
|
||||
"a primary must still see the fleet_stop handle: " + out);
|
||||
assertTrue(out.contains("\"cwd\":\"/worktree/member-1\""),
|
||||
"a primary must still see a spawned member's worktree path: " + out);
|
||||
assertTrue(out.contains("\"role\":\"dev\""), out);
|
||||
}
|
||||
|
||||
/**
|
||||
* fleetd #421: {@code mailbox.pending} counts only broker-ready messages, so a blocked lead's
|
||||
* normal, healthy state is {@code "pending": 0} next to a non-empty {@code held[]} — which
|
||||
|
||||
Reference in New Issue
Block a user