Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2289e94223 | |||
| 92adfcfae5 | |||
| 5f7f388e69 | |||
| 39accf73e6 | |||
| 5c2f296bc3 | |||
| 0f2ec7a6b5 | |||
| 02eff4c532 | |||
| 92b6d9406b | |||
| c4498607e1 | |||
| e42eab5b4c | |||
| b1d2cb48ac | |||
| 459a523e2c | |||
| 291dc02c77 |
@@ -33,8 +33,11 @@ gate uses. A worker also carries its `sessionId`, `profile`, `worktree` and `bra
|
||||
carries the slot name it was bound to; a collaborator carries its registry name and its own
|
||||
`sessionId`, and **no `leader` key** — a collaborator is a named peer, not a primary. An **observer**
|
||||
carries only its own `sessionId`: a pane the daemon could not place as any of the above, authorized
|
||||
to `READ`/`METRICS` and to `REPLY`/`ASK` on its own pane and nothing more — never `SEND`, never a
|
||||
ticket. Don't infer what you can ask.
|
||||
to `READ`/`METRICS`, to `REPLY`/`ASK` on its own pane, and to `SEND` only to a target that resolves
|
||||
as an observer too — never to a lead, a collaborator, or a spawned member, and never a ticket. It
|
||||
finds such a target in `fleet_list`'s `panes` array, which for an observer is filtered to exactly
|
||||
what it may send to and reduced to `sessionId`, `label`, `status`, `role` and `deliverable`.
|
||||
Don't infer what you can ask.
|
||||
|
||||
Only if that call is unavailable, fall back to these — each is one-way, so keep reading until one
|
||||
fires: the reply charter in your system prompt (*"You are a spawned member in the
|
||||
@@ -62,9 +65,10 @@ and the sender silently receives nothing. Fail toward the recoverable error.
|
||||
2. **The bridge is the only channel.** Text you print in your terminal reaches nobody — the other
|
||||
side cannot see your screen. An answer that isn't in a `fleet_*` call is silently discarded.
|
||||
3. **Identity comes from the connection, never an argument.** Workers never pass a target; you
|
||||
cannot act as another session. Spawn/stop/drain are lead-only; **send is lead, architect, or
|
||||
collaborator** — and a collaborator may send only to a lead or another collaborator, never to a
|
||||
spawned member's terminal; reply/ask are only-as-itself — any peer may answer for its own pane,
|
||||
cannot act as another session. Spawn/stop/drain are lead-only; **send is lead, architect,
|
||||
collaborator, or observer** — and a collaborator may send only to a lead or another collaborator,
|
||||
never to a spawned member's terminal, while an observer may send only to another observer pane;
|
||||
reply/ask are only-as-itself — any peer may answer for its own pane,
|
||||
and for no other. A call outside your role is refused, not queued.
|
||||
4. **Delivery is status-gated: one message per turn.** Don't busy-poll a peer's terminal and don't
|
||||
re-send because a call looks slow — the bridge delivers when the peer is `idle`, `blocked` or
|
||||
@@ -178,6 +182,7 @@ you decide.
|
||||
| Message a **peer lead** on this host | `fleet_send{sessionId: <their terminal>, content}` — `fleet_list` → `leads` reports it. Coordination only, **never** a task |
|
||||
| Message a **peer lead** on another daemon or host | `fleet_send{coordId: <their coord-id>, content}` — needs a `coordinator:` block; your own coord-id is in `fleet_list`. Coordination only, **never** a task |
|
||||
| Message a **collaborator** on this host | `fleet_send{sessionId: <their terminal>, content}` — `fleet_list` reports a `collaborators` array, and each row carries that peer's `name` and the `sessionId` you send to. It is visible to you, to an architect and to another collaborator, never to a worker. Coordination only, **never** a task |
|
||||
| Message an **unconfigured pane** — a tab a person opened by hand | `fleet_send{sessionId: <their terminal>, content}` — it needs **no** `fleet.collaborators` entry and no restart, because a pane becomes deliverable the moment its agent connects the bridge MCP. `fleet_list`'s `panes` array reports every such pane with its label and the terminal id to send to — the full row for you, an architect or a collaborator; filtered and reduced for an observer. **`ListAgents` still never lists these**, and joining `herdr tab list` to `GET /agents` on `tab_id` stays the read-only fallback if the array is missing. Such a pane resolves as an `observer`: it can answer you with `fleet_reply`, and it can `fleet_send` to another observer pane, but never to you. Coordination only, **never** a task |
|
||||
| Answer a peer lead that messaged you | `fleet_send{coordId}` — or `{sessionId}` if they are on this host. **Not** `fleet_reply`: it has no peer route and the publish is refused |
|
||||
| Read your own held lead-to-lead mail (no ack) | `fleet_poll{coordId: <your own coord-id, from fleet_list's coordinator.selfId>}` — primary-only; never acks, so `fleet_list`'s `held[]` still shows it after. `fleet_list`'s `held[]` gives only a truncated preview — this is the only way to read the full body |
|
||||
| Collect a held reply | `fleet_poll{target}` · then `fleet_ack{target, msgId}` |
|
||||
|
||||
@@ -182,6 +182,12 @@ Two consequences a lead feels directly:
|
||||
is fine; the message simply waits, and then restarts the member when it next goes idle.
|
||||
- **A spawned member is not deliverable until it has mounted the MCP.** Until then a send waits on
|
||||
that gate for about 60 seconds and then fails without ever reaching the pane.
|
||||
- **The same gate is what makes an unconfigured pane deliverable.** `contextExtractor` runs on
|
||||
every MCP request, `initialize` included, and `markTrackedCallerPresent` enrols a spawned member
|
||||
*or* an observer into `MemberPresence`; `deliverableTo` then tests presence before the lead and
|
||||
collaborator maps. So mounting the server is the enrolment, and a tab a person opened by hand can
|
||||
be sent to with no config and no restart. It answers with `fleet_reply` — it cannot `fleet_send`,
|
||||
because `Authz` keeps `SEND` to a primary, an architect or a collaborator.
|
||||
|
||||
`UNKNOWN` is deliberately neither injectable nor a pickup. A pane whose status cannot be read is
|
||||
not a pane that is safe to write to — see fleetd #176 for what happens when a gate treats an
|
||||
|
||||
@@ -234,7 +234,7 @@ public final class Fleetd {
|
||||
* <p>Both sets are read through their supplier on each call rather than snapshotted, so a lead or
|
||||
* collaborator discovered by {@code leadScan} after startup becomes deliverable without a restart.
|
||||
*/
|
||||
static Predicate<String> deliverableTo(MemberPresence presence, Supplier<Map<String, String>> leads,
|
||||
public static Predicate<String> deliverableTo(MemberPresence presence, Supplier<Map<String, String>> leads,
|
||||
Supplier<Map<String, String>> collaborators) {
|
||||
return target -> presence.isPresent(target) || leads.get().containsKey(target)
|
||||
|| collaborators.get().containsKey(target);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ package dev.ltms.fleet.auth;
|
||||
public enum Role {
|
||||
|
||||
/**
|
||||
* The orchestrating session. Established either by being a loopback caller that is not a
|
||||
* worker pane (under {@code loopback-trust}) or by presenting a valid bearer token (under
|
||||
* {@code token} mode).
|
||||
* The orchestrating session. Established either by being a loopback caller that resolves to
|
||||
* no herdr pane at all (under {@code loopback-trust}) or by presenting a valid bearer token
|
||||
* (under {@code token} mode). A loopback caller that does own a pane, but matches none of the
|
||||
* roles below, resolves to {@link #OBSERVER} instead.
|
||||
*/
|
||||
PRIMARY,
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -145,6 +146,32 @@ public final class PaneLocator {
|
||||
return ancestry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tab herdr tracks across every searched daemon, keyed by tab id, to its display label —
|
||||
* the pane-discovery surface behind {@code GET /agents} and {@code fleet_list}'s {@code panes}
|
||||
* row. Collapses to one scan in the single-daemon deployment, the same as
|
||||
* {@link #terminalForPid}. A tab herdr reports with no label maps to a {@code null} value here;
|
||||
* a tab with no {@code tab_id} is skipped.
|
||||
*/
|
||||
public Map<String, String> tabLabelsByTabId() {
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
for (HerdrClient herdr : herdrs) {
|
||||
for (JsonNode w : herdr.call("workspace.list").path("workspaces")) {
|
||||
String workspaceId = w.path("workspace_id").asText(null);
|
||||
if (workspaceId == null) {
|
||||
continue;
|
||||
}
|
||||
for (JsonNode t : herdr.call("tab.list", Map.of("workspace_id", workspaceId)).path("tabs")) {
|
||||
Tab tab = Tab.from(t);
|
||||
if (tab.tabId() != null) {
|
||||
out.put(tab.tabId(), tab.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a pane owns one of the scanned pid's ancestors, or the check of it failed outright. */
|
||||
private enum Ownership { OWNS, DOES_NOT_OWN, UNKNOWN }
|
||||
|
||||
|
||||
@@ -4,16 +4,17 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Tracks which workers are <em>available</em> — their Claude has booted and connected its MCP client
|
||||
* to the bridge (CB-113). This is the reliable readiness signal, unlike herdr's {@code agent_status},
|
||||
* which reports {@code idle} for a worker whose Claude is still booting. Delivering into that boot
|
||||
* window pastes into a not-yet-ready TUI (the text is lost) and wedges the worker's delivery state,
|
||||
* so the {@link Injector} holds the first delivery until the worker is present here.
|
||||
* Tracks which peers are <em>available</em> — their Claude has booted and connected its MCP
|
||||
* client to the bridge. For a spawned member this is the reliable readiness signal,
|
||||
* unlike herdr's {@code agent_status}, which reports {@code idle} while its Claude is still
|
||||
* booting. Delivering into that boot window pastes into a not-yet-ready TUI (the text is lost)
|
||||
* and wedges that member's delivery state, so the {@link Injector} holds a spawned member's
|
||||
* first delivery until it is present here.
|
||||
*
|
||||
* <p>Populated from the MCP transport: any MCP request whose connection resolves to a worker terminal
|
||||
* marks that worker present (its {@code initialize} is the first such contact). A worker that never
|
||||
* mounts the bridge MCP is never marked present — its sends stay queued until they time out, which is
|
||||
* correct (it could not have replied anyway).
|
||||
* <p>Populated from the MCP transport, for the peers whose deliverability rests on proving a live
|
||||
* MCP contact rather than on a configured registry entry. A peer that never mounts the bridge MCP
|
||||
* is never marked present — its sends stay queued until they time out, which is correct (it could
|
||||
* not have replied anyway).
|
||||
*/
|
||||
public class MemberPresence {
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ import dev.ltms.fleet.herdr.PaneLocator;
|
||||
/**
|
||||
* Resolves <em>who is calling</em> an MCP tool from the connection alone — the anti-spoofing
|
||||
* identity model of the MCP contract. It ties the connection's loopback peer PID (from the OS)
|
||||
* to a herdr agent pane (from herdr), yielding the caller's worker {@code terminal_id}. A caller
|
||||
* that maps to no worker pane — the primary, or an off-host client — resolves to {@code null}.
|
||||
* to a herdr agent pane (from herdr), yielding that pane's {@code terminal_id}. A connection that
|
||||
* maps to no pane resolves to {@code null}; this class assigns no role to either outcome — {@link
|
||||
* dev.ltms.fleet.auth.CallerResolver} does that.
|
||||
*
|
||||
* <p>Both sources are authoritative and unforgeable: the OS reports the real connecting PID, and
|
||||
* herdr owns the PID→pane mapping. A worker cannot claim to be another worker, nor the primary.
|
||||
* Single-host only (the herd shares the {@code fleetd} host); the token path is the split-host
|
||||
* fallback.
|
||||
* herdr owns the PID→pane mapping, so a caller cannot claim to be at another pane. Single-host
|
||||
* only (the herd shares the {@code fleetd} host); the token path is the split-host fallback.
|
||||
*/
|
||||
public final class ConnectionIdentity {
|
||||
|
||||
@@ -46,9 +46,10 @@ public final class ConnectionIdentity {
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller resolved from the connection: its worker {@code terminal} (or {@code null} for the
|
||||
* primary / an off-host client), its {@code pid} (or {@code -1} if not resolvable), and whether
|
||||
* the pane scan behind {@code terminal} ran to completion ({@link #scanComplete}).
|
||||
* The caller resolved from the connection: the {@code terminal} of the pane it connects from
|
||||
* (or {@code null} when the connection maps to no pane), its {@code pid} (or {@code -1} if not
|
||||
* resolvable), and whether the pane scan behind {@code terminal} ran to completion
|
||||
* ({@link #scanComplete}).
|
||||
*/
|
||||
public record Caller(String terminal, long pid, boolean scanComplete) {
|
||||
|
||||
@@ -87,8 +88,8 @@ public final class ConnectionIdentity {
|
||||
}
|
||||
|
||||
/**
|
||||
* The calling worker's {@code terminal_id}, or {@code null} if the caller is not a known
|
||||
* on-host worker (treat as the primary).
|
||||
* The terminal id of the pane the caller connects from, or {@code null} if the connection
|
||||
* maps to no pane.
|
||||
*/
|
||||
public String callerTerminal(String remoteAddr, int remotePort) {
|
||||
return resolve(remoteAddr, remotePort).terminal();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.ltms.fleet.mcp;
|
||||
|
||||
import dev.ltms.fleet.Fleetd;
|
||||
import dev.ltms.fleet.auth.AuditLog;
|
||||
import dev.ltms.fleet.auth.Authz;
|
||||
import dev.ltms.fleet.auth.CallerResolver;
|
||||
@@ -41,6 +42,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServlet;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -52,6 +54,7 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -303,6 +306,29 @@ public final class FleetMcp {
|
||||
public static LeadConfigDirSource none() { return new LeadConfigDirSource(_ -> null, _ -> null); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pane-discovery facts for {@code fleet_list}'s {@code panes} row — every herdr tab's display
|
||||
* 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,
|
||||
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); }
|
||||
}
|
||||
|
||||
/**
|
||||
* fleetd #361: peer-visibility facts for {@code fleet_list}'s {@code coordinator} row — this
|
||||
* daemon's own {@link LeadChannel} (for its self mailbox state and held messages) plus the
|
||||
@@ -503,8 +529,9 @@ public final class FleetMcp {
|
||||
? sendAsync(messages, target, content, onAccepted, workers.profiles(), caller)
|
||||
: send(messages, target, content, timeoutMs(a), onAccepted, workers.profiles(), callerOwner);
|
||||
};
|
||||
// fleet_reply's identity is the CONNECTION, never an argument — so the authz check
|
||||
// is "is this caller a worker at all", and it can only ever reply as itself.
|
||||
// fleet_reply's identity is the CONNECTION, never an argument. The authz check is
|
||||
// terminal ownership, not a role test: the caller may reply only for its own pane,
|
||||
// which is why no role appears in the check at all.
|
||||
BiFunction<McpSyncServerExchange, McpSchema.CallToolRequest, McpSchema.CallToolResult> replyHandler =
|
||||
(exchange, req) -> {
|
||||
String self = callerTerminal(exchange);
|
||||
@@ -568,13 +595,19 @@ public final class FleetMcp {
|
||||
(exchange, _) -> {
|
||||
McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_list", Map.of()), null);
|
||||
if (denied != null) return denied;
|
||||
// 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),
|
||||
callers::boundToArchitectSlot, callers.sendableObserverTarget());
|
||||
return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, outage,
|
||||
leadSeats, leadContextGauge, leadConfigDirs, callers.leads(),
|
||||
callerTerminal(exchange),
|
||||
callers.collaborators(), collaboratorsVisibleTo(principal(exchange)),
|
||||
new CoordinationSource(leadChannel, peers),
|
||||
coordinatorVisibleTo(principal(exchange)),
|
||||
leadsVisibleTo(principal(exchange)), membersVisibleTo(principal(exchange)));
|
||||
leadsVisibleTo(principal(exchange)), membersVisibleTo(principal(exchange)),
|
||||
panes, panesVisibleTo(principal(exchange)), principal(exchange).isObserver());
|
||||
};
|
||||
BiFunction<McpSyncServerExchange, McpSchema.CallToolRequest, McpSchema.CallToolResult> stopHandler =
|
||||
(exchange, req) -> {
|
||||
@@ -800,6 +833,17 @@ public final class FleetMcp {
|
||||
return caller.isPrimary() || caller.isArchitect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() || caller.isObserver();
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminal of the caller on this call's connection, or {@code null} when that caller carries
|
||||
* no terminal, which is only the unnamed primary. A named lead, an architect and a worker each
|
||||
@@ -1982,6 +2026,25 @@ public final class FleetMcp {
|
||||
Map.of(), false, coordination, callerIsPrimary, leadsVisible, membersVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* As below, with no pane discovery — {@code panes} is {@link PaneSource#none()} and
|
||||
* {@code panesVisible} is {@code false}.
|
||||
*/
|
||||
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) {
|
||||
return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, outage,
|
||||
leadSeats, contextGauge, leadConfigDirs, leads, selfTerm, collaborators, collaboratorsVisible,
|
||||
coordination, callerIsPrimary, leadsVisible, membersVisible, PaneSource.none(), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical implementation. {@code contextGauge} is the "lead context gauge" (see
|
||||
* {@link LeadContextGauge}) — every wrapper overload above passes a freshly constructed one,
|
||||
@@ -2000,6 +2063,11 @@ public final class FleetMcp {
|
||||
* {@link #leadsVisibleTo})
|
||||
* @param membersVisible whether this caller may see the {@code members} array (see
|
||||
* {@link #membersVisibleTo})
|
||||
* @param panes pane-discovery facts — labels and the deliverable gate for the
|
||||
* {@code panes} row; {@link PaneSource#none()} for a caller that
|
||||
* does not want the row
|
||||
* @param panesVisible whether this caller may see the {@code panes} array (see
|
||||
* {@link #panesVisibleTo})
|
||||
*/
|
||||
static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions, MessageService messages,
|
||||
CapacitySource capacity, HealthCoverageSource healthCoverage,
|
||||
@@ -2010,11 +2078,38 @@ public final class FleetMcp {
|
||||
Map<String, String> leads, String selfTerm,
|
||||
Map<String, String> collaborators, boolean collaboratorsVisible,
|
||||
CoordinationSource coordination, boolean callerIsPrimary,
|
||||
boolean leadsVisible, boolean membersVisible) {
|
||||
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.
|
||||
Map<String, Agent> live = (leadsVisible || membersVisible)
|
||||
Map<String, Agent> live = (leadsVisible || membersVisible || panesVisible)
|
||||
? workers.list().stream()
|
||||
.map(Agent.class::cast)
|
||||
.filter(a -> a.terminalId() != null)
|
||||
@@ -2058,6 +2153,10 @@ public final class FleetMcp {
|
||||
.map(e -> collaboratorRow(e.getKey(), e.getValue()))
|
||||
.toList());
|
||||
}
|
||||
// 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, 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
|
||||
// key is absent rather than present-and-empty.
|
||||
@@ -2383,6 +2482,102 @@ public final class FleetMcp {
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code panes.tabLabels()}'s herdr scan, or an empty map on a {@code HerdrException} — a
|
||||
* missing label must not cost the {@code leads}/{@code members}/{@code capacity}/
|
||||
* {@code coordinator} rows that share {@code listFleet}'s own {@code catch}.
|
||||
*/
|
||||
private static Map<String, String> tabLabelsOrEmpty(PaneSource panes) {
|
||||
try {
|
||||
return panes.tabLabels().get();
|
||||
} catch (HerdrException e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per herdr-tracked agent pane, sorted by terminal id for a stable order. {@code live}
|
||||
* 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,
|
||||
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,
|
||||
observerView))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param session the roster entry for this pane's terminal, or {@code null} for a pane the
|
||||
* 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,
|
||||
boolean observerView) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("sessionId", a.terminalId());
|
||||
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()));
|
||||
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, panes));
|
||||
m.put("deliverable", panes.deliverable().test(a.terminalId()));
|
||||
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" 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, 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";
|
||||
}
|
||||
return "observer";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the lead context gauge for one lead. {@code configDir} is this lead's configured
|
||||
* {@code CLAUDE_CONFIG_DIR} override (see {@link LeadConfigDirSource}), derived from
|
||||
@@ -2588,8 +2783,8 @@ public final class FleetMcp {
|
||||
+ "orchestrators, each with its sessionId (the address to fleet_send to), "
|
||||
+ "name, live status, and 'self': true on your own row; this is how you "
|
||||
+ "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 "
|
||||
+ "sessions delegated to — each with sessionId, paneId, role (" + MemberRole.wireNames()
|
||||
+ "), profile (the backend it runs on), state, optional "
|
||||
+ "worktree/branch/owner/agentSessionId, and live herdr status. agentSessionId, "
|
||||
+ "when present, is the id to pass as fleet_spawn's resumeSessionId to relaunch "
|
||||
+ "onto that same conversation. It is ABSENT — not a guess — for a member fleetd "
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dev.ltms.fleet.peer;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* What a member is <em>for</em> — the contract it runs under.
|
||||
@@ -100,6 +102,11 @@ public enum MemberRole {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The wire name of every role, joined with {@code ", "} in declaration order. */
|
||||
public static String wireNames() {
|
||||
return Stream.of(values()).map(MemberRole::wireName).collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a config/wire spelling, case-insensitively.
|
||||
*
|
||||
@@ -118,14 +125,7 @@ public enum MemberRole {
|
||||
}
|
||||
}
|
||||
}
|
||||
StringBuilder valid = new StringBuilder();
|
||||
for (MemberRole r : values()) {
|
||||
if (!valid.isEmpty()) {
|
||||
valid.append(", ");
|
||||
}
|
||||
valid.append(r.wireName());
|
||||
}
|
||||
throw new IllegalArgumentException(
|
||||
"unknown member role '" + s + "'; valid roles are: " + valid);
|
||||
"unknown member role '" + s + "'; valid roles are: " + wireNames());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import dev.ltms.fleet.mcp.FleetMcp;
|
||||
import dev.ltms.fleet.herdr.Agent;
|
||||
import dev.ltms.fleet.herdr.HerdrClient;
|
||||
import dev.ltms.fleet.herdr.HerdrException;
|
||||
import dev.ltms.fleet.herdr.PaneLocator;
|
||||
import dev.ltms.fleet.inject.MemberPresence;
|
||||
import dev.ltms.fleet.member.MemberCredentialPolicyView;
|
||||
import dev.ltms.fleet.peer.PeerUnreachableException;
|
||||
@@ -448,14 +449,27 @@ public final class FleetApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab id → its herdr display label, or an empty map on a {@code workspace.list}/{@code
|
||||
* tab.list} failure — a missing label must not cost the agent roster.
|
||||
*/
|
||||
private Map<String, String> tabLabelsOrEmpty() {
|
||||
try {
|
||||
return new PaneLocator(herdr, memberHerdr).tabLabelsByTabId();
|
||||
} catch (HerdrException e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** Discovery: every agent herdr tracks, keyed by its Claude session UUID. */
|
||||
private void agents(Context ctx) {
|
||||
if (!allow(ctx, routeAction("GET /agents"), null)) {
|
||||
return;
|
||||
}
|
||||
final Map<String, String> tabLabels = tabLabelsOrEmpty();
|
||||
try {
|
||||
ctx.status(200).json(Map.of("agents",
|
||||
workers.list().stream().map(Agent.class::cast).map(FleetApp::view).toList()));
|
||||
workers.list().stream().map(Agent.class::cast).map(a -> view(a, tabLabels)).toList()));
|
||||
} catch (HerdrException e) {
|
||||
// fleetd #297: workers.list() reaches herdr — a transport failure must land in the same
|
||||
// {error, detail} envelope every other failure path here uses, not escape as a bare
|
||||
@@ -951,13 +965,19 @@ public final class FleetApp {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable JSON projection of an agent (null-safe for the start-time shape). */
|
||||
private static Map<String, Object> view(Agent a) {
|
||||
/**
|
||||
* Stable JSON projection of an agent (null-safe for the start-time shape).
|
||||
*
|
||||
* @param tabLabels tab id → its herdr display label; a tab absent from this map, or carrying
|
||||
* a {@code null} label itself, projects as a {@code null} "label"
|
||||
*/
|
||||
private static Map<String, Object> view(Agent a, Map<String, String> tabLabels) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("terminalId", a.terminalId());
|
||||
m.put("paneId", a.paneId());
|
||||
m.put("workspaceId", a.workspaceId());
|
||||
m.put("tabId", a.tabId());
|
||||
m.put("label", tabLabels.get(a.tabId()));
|
||||
m.put("sessionId", a.sessionId());
|
||||
m.put("agentType", a.agentType());
|
||||
m.put("status", a.status().name().toLowerCase());
|
||||
|
||||
@@ -48,6 +48,7 @@ public final class FakeHerdr implements HerdrClient {
|
||||
private final Map<String, String> processInfoErrorCodeFor = new ConcurrentHashMap<>();
|
||||
private String tabCloseErrorCode = null;
|
||||
private final Map<String, String> tabCloseErrorCodeFor = new ConcurrentHashMap<>();
|
||||
private String workspaceListErrorCode = null;
|
||||
private String agentSendErrorCode = null;
|
||||
private boolean noPanes = false;
|
||||
private volatile String agentStatus = "idle"; // steady-state agent.get status
|
||||
@@ -142,6 +143,12 @@ public final class FakeHerdr implements HerdrClient {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Make {@code workspace.list} fail with this herdr error code; every other method still succeeds. */
|
||||
public FakeHerdr workspaceListFailsWith(String code) {
|
||||
this.workspaceListErrorCode = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make {@code pane.list} report no panes at all — models a second herdr daemon (CB-185) that
|
||||
* simply does not host the pane a {@link PaneLocator} is searching for.
|
||||
@@ -302,11 +309,17 @@ public final class FakeHerdr implements HerdrClient {
|
||||
case "ping" -> mapper.readTree(
|
||||
("{\"type\":\"pong\",\"version\":\"%s\",\"protocol\":%d}")
|
||||
.formatted(pingVersion, pingProtocol));
|
||||
case "workspace.list" -> mapper.readTree(("""
|
||||
case "workspace.list" -> {
|
||||
if (workspaceListErrorCode != null) {
|
||||
throw new HerdrException("herdr error [" + workspaceListErrorCode + "]: workspace.list failed",
|
||||
workspaceListErrorCode, null);
|
||||
}
|
||||
yield mapper.readTree(("""
|
||||
{"type":"workspace_list","workspaces":[
|
||||
{"workspace_id":"w1","label":"dev-mgnl","focused":true,"pane_count":7,"agent_status":"unknown"},
|
||||
{"workspace_id":"w2","label":"ltms","focused":false,"pane_count":5,"agent_status":"done"}%s]}""")
|
||||
.formatted(extraWorkspaces.isEmpty() ? "" : "," + String.join(",", extraWorkspaces)));
|
||||
}
|
||||
case "agent.list" -> mapper.readTree(("""
|
||||
{"type":"agent_list","agents":[
|
||||
{"terminal_id":"term_a","agent":"claude","agent_status":"idle",
|
||||
|
||||
@@ -508,6 +508,28 @@ class FleetMcpAuthzTest {
|
||||
assertFalse(FleetMcp.membersVisibleTo(ANON), "authenticated as nothing must not see it either");
|
||||
}
|
||||
|
||||
// --- who may see fleet_list's panes array ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* {@link FleetMcp#panesVisibleTo} is the whole policy decision for {@code fleet_list}'s
|
||||
* {@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 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");
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Same reasoning as {@link #theFleetListHandlerActuallyConsultsCoordinatorVisibleTo}: the
|
||||
* predicate above can be perfectly correct while the one production call site never asks it.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.ltms.fleet.mcp;
|
||||
|
||||
import dev.ltms.fleet.Fleetd;
|
||||
import dev.ltms.fleet.auth.CallerResolver;
|
||||
import dev.ltms.fleet.auth.MemberRegistry;
|
||||
import dev.ltms.fleet.auth.Principal;
|
||||
@@ -1110,6 +1111,215 @@ class FleetMcpTest {
|
||||
assertTrue(asArchitect.contains("\"sessionId\":\"term_collab\""), asArchitect);
|
||||
}
|
||||
|
||||
// --- fleet_list's panes array -----------------------------------------------------------------
|
||||
|
||||
/** Calls the canonical {@code listFleet} overload directly, so a test can set the pane-discovery
|
||||
* payload and its visibility independently of a real {@code Principal} / MCP exchange. */
|
||||
private static McpSchema.CallToolResult listFleetWithPanes(FakeHerdr h, FleetMcp.PaneSource panes,
|
||||
boolean panesVisible) {
|
||||
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(),
|
||||
Map.of(), "", Map.of(), false,
|
||||
FleetMcp.CoordinationSource.none(), false, true, true, panes, panesVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane whose tab herdr reports with a label gets that label and the exact terminal id
|
||||
* {@code fleet_send} takes as a target, carried as {@code sessionId}.
|
||||
*/
|
||||
@Test
|
||||
void listReportsAPaneRowWithItsTabLabelAndSendableSessionId() {
|
||||
FakeHerdr h = new FakeHerdr().withTab("w2", "w2:t7", "trinotes");
|
||||
MemberPresence presence = new MemberPresence();
|
||||
presence.markPresent("term_a");
|
||||
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
|
||||
() -> new PaneLocator(h).tabLabelsByTabId(),
|
||||
Fleetd.deliverableTo(presence, Map::of, Map::of), _ -> false, _ -> false);
|
||||
|
||||
String out = textOf(listFleetWithPanes(h, panes, true));
|
||||
|
||||
assertTrue(out.contains("\"panes\":["), out);
|
||||
assertTrue(out.contains("\"sessionId\":\"term_a\""), out);
|
||||
assertTrue(out.contains("\"label\":\"trinotes\""), out);
|
||||
assertTrue(out.contains("\"deliverable\":true"), out);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane whose tab carries no label known to herdr still gets a row -- a missing label must
|
||||
* never throw, and must never drop the pane from the array, only report a {@code null} label.
|
||||
* Pairs with a {@code deliverable} false reading when the target is neither present, a lead,
|
||||
* nor a collaborator.
|
||||
*/
|
||||
@Test
|
||||
void listReportsAPaneRowWithANullLabelWhenHerdrHasNoneAndNotDeliverable() {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
|
||||
() -> new PaneLocator(h).tabLabelsByTabId(),
|
||||
Fleetd.deliverableTo(new MemberPresence(), Map::of, Map::of), _ -> false, _ -> false);
|
||||
|
||||
String out = textOf(listFleetWithPanes(h, panes, true));
|
||||
|
||||
assertTrue(out.contains("\"panes\":["), out);
|
||||
assertTrue(out.contains("\"sessionId\":\"term_a\""), out);
|
||||
assertTrue(out.contains("\"label\":null"), out);
|
||||
assertTrue(out.contains("\"deliverable\":false"), out);
|
||||
}
|
||||
|
||||
/** A caller this role may not show the array to gets no {@code panes} key at all. */
|
||||
@Test
|
||||
void listOmitsThePanesArrayWhenTheCallerMayNotSeeIt() {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
|
||||
String out = textOf(listFleetWithPanes(h, FleetMcp.PaneSource.none(), false));
|
||||
|
||||
assertFalse(out.contains("\"panes\""), out);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab-label scan behind {@code panes} shares no failure path with the rest of
|
||||
* {@code listFleet} -- a {@code workspace.list}/{@code tab.list} failure costs only the
|
||||
* labels in the {@code panes} row (each renders {@code null}), never the {@code leads}/
|
||||
* {@code members} arrays, which never needed that scan at all.
|
||||
*/
|
||||
@Test
|
||||
void listStillReportsEveryOtherArrayWhenTheLabelScanFails() {
|
||||
FakeHerdr h = new FakeHerdr().workspaceListFailsWith("unavailable");
|
||||
FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
|
||||
() -> new PaneLocator(h).tabLabelsByTabId(),
|
||||
Fleetd.deliverableTo(new MemberPresence(), Map::of, Map::of), _ -> false, _ -> false);
|
||||
|
||||
McpSchema.CallToolResult res = listFleetWithPanes(h, panes, true);
|
||||
|
||||
assertNotEquals(Boolean.TRUE, res.isError(), textOf(res));
|
||||
String out = textOf(res);
|
||||
assertTrue(out.contains("\"panes\":["), out);
|
||||
assertTrue(out.contains("\"sessionId\":\"term_a\""), out);
|
||||
assertTrue(out.contains("\"label\":null"), out);
|
||||
assertTrue(out.contains("\"leads\":[]"), "a label-scan failure must not cost the leads array: " + out);
|
||||
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
|
||||
|
||||
@@ -223,6 +223,37 @@ class FleetAppTest {
|
||||
assertTrue(body.has("detail"), res.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentsReportsTheAgentsTabLabel() throws Exception {
|
||||
FakeHerdr herdr = new FakeHerdr().withTab("w2", "w2:t7", "trinotes");
|
||||
int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw"));
|
||||
|
||||
HttpResponse<String> res = req(port, "GET", "/agents");
|
||||
assertEquals(200, res.statusCode(), res.body());
|
||||
JsonNode agents = mapper.readTree(res.body()).get("agents");
|
||||
assertEquals(1, agents.size());
|
||||
assertEquals("sess-1111", agents.get(0).get("sessionId").asText());
|
||||
assertEquals("trinotes", agents.get(0).get("label").asText());
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab-label scan ({@code workspace.list}/{@code tab.list}) is decoration on top of
|
||||
* {@code workers.list()}'s own agent roster, so its failure must not cost that roster: a row
|
||||
* reports a {@code null} label instead, never the {@code herdr_error} envelope.
|
||||
*/
|
||||
@Test
|
||||
void agentsStillReportsTheRosterWhenTheLabelScanFails() throws Exception {
|
||||
FakeHerdr herdr = new FakeHerdr().workspaceListFailsWith("unavailable");
|
||||
int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw"));
|
||||
|
||||
HttpResponse<String> res = req(port, "GET", "/agents");
|
||||
assertEquals(200, res.statusCode(), res.body());
|
||||
JsonNode agents = mapper.readTree(res.body()).get("agents");
|
||||
assertEquals(1, agents.size());
|
||||
assertEquals("sess-1111", agents.get(0).get("sessionId").asText());
|
||||
assertTrue(agents.get(0).get("label").isNull(), "a failed label scan must report a null label, not fail the roster: " + res.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void spawnWorkerLandsInOwnTabInWorkerSpaceAndInjectsBaseUrl() throws Exception {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
|
||||
Reference in New Issue
Block a user