From 5c2f296bc35bcaf94ee0c19723d74589eca43d88 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Mon, 5 Oct 2026 10:24:00 +0200 Subject: [PATCH] fleetd #756/#758: panes array reads the architect-slot gate and widens to observer paneRole now reads CallerResolver#boundToArchitectSlot (made public, no second definition) so a slot-bound pane with no live member reports "architect", matching what sendableObserverTarget already allowed as a SEND target. panesVisibleTo now admits an observer, since an observer holds SEND to another observer pane. Its panes rows are filtered to sendableObserverTarget and reduced to sessionId/label/status/role/ deliverable; every other caller's rows are unchanged. --- .../dev/ltms/fleet/auth/CallerResolver.java | 9 +- .../java/dev/ltms/fleet/mcp/FleetMcp.java | 108 +++++++++++---- .../dev/ltms/fleet/mcp/FleetMcpAuthzTest.java | 14 +- .../java/dev/ltms/fleet/mcp/FleetMcpTest.java | 123 +++++++++++++++++- 4 files changed, 218 insertions(+), 36 deletions(-) diff --git a/fleetd/src/main/java/dev/ltms/fleet/auth/CallerResolver.java b/fleetd/src/main/java/dev/ltms/fleet/auth/CallerResolver.java index efd5b030..c8eab80d 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/auth/CallerResolver.java +++ b/fleetd/src/main/java/dev/ltms/fleet/auth/CallerResolver.java @@ -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; } diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java index 006ae8e2..bbcb2765 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -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> tabLabels, Predicate 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> tabLabels, Predicate deliverable, + Predicate architectSlot, Predicate 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 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 leads, String selfTerm, + Map 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. + * + *

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> paneRows(Map live, List roster, - Map leads, Map collaborators, PaneSource panes) { + Map leads, Map collaborators, PaneSource panes, + boolean observerView) { final Map tabLabels = tabLabelsOrEmpty(panes); Map 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 paneRow(Agent a, MemberSession session, Map leads, - Map collaborators, Map tabLabels, PaneSource panes) { + Map collaborators, Map tabLabels, PaneSource panes, + boolean observerView) { Map 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 leads, - Map collaborators) { + Map 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"; } diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java index 97ef7da2..d1a8be45 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java @@ -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"); } diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java index 30baf30f..63ab9249 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java @@ -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 leads, + Map 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