fleetd #743: make the pane-label scan best-effort, trim justification comments
A workspace.list/tab.list failure in the label scan no longer costs the caller the agent roster (GET /agents) or the leads/members/capacity/ coordinator rows (fleet_list) that never needed it. Both call sites now fall back to an empty label map on HerdrException, so a pane row still renders with label:null instead of the whole response failing. Also cuts four comments down to the current contract, per the project's comment rule: dropped the reviewer-facing justification from Fleetd's deliverableTo javadoc, panesVisibleTo's javadoc, the fleet_list handler's inline comment, and the panesVisible assembly-gate comment, and removed the two fragments describing what a test must do.
This commit is contained in:
@@ -233,9 +233,6 @@ 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.
|
||||
*
|
||||
* <p>Public so {@code fleet_list}'s {@code panes} row can report the exact same gate the
|
||||
* injector enforces, rather than a second, separately-derived guess at reachability.
|
||||
*/
|
||||
public static Predicate<String> deliverableTo(MemberPresence presence, Supplier<Map<String, String>> leads,
|
||||
Supplier<Map<String, String>> collaborators) {
|
||||
|
||||
@@ -582,11 +582,8 @@ public final class FleetMcp {
|
||||
(exchange, _) -> {
|
||||
McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_list", Map.of()), null);
|
||||
if (denied != null) return denied;
|
||||
// The label lookup costs a herdr scan, so it is only a Supplier here —
|
||||
// listFleet reads it (inside its own HerdrException handling) only once
|
||||
// panesVisibleTo has already said this caller receives the row at all. The
|
||||
// deliverable predicate is the exact gate the injector enforces, never a second,
|
||||
// separately-derived guess (see Fleetd#deliverableTo's own javadoc).
|
||||
// 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));
|
||||
return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, outage,
|
||||
@@ -823,15 +820,9 @@ public final class FleetMcp {
|
||||
|
||||
/**
|
||||
* Who may see {@code fleet_list}'s {@code panes} array — every herdr-tracked agent pane on the
|
||||
* host, labelled and addressable by {@code sessionId}, including a hand-opened tab this daemon
|
||||
* never spawned and never configured as a lead or collaborator. {@code READ}'s own grant rests
|
||||
* on "the roster carries no secrets" ({@code Authz.java}'s comment on its {@code READ} case) —
|
||||
* a tab label and a member's cwd are not that, so this array gets the same narrower gate as
|
||||
* {@link #leadsVisibleTo}/{@link #collaboratorsVisibleTo} rather than riding bare {@code READ}:
|
||||
* exactly the roles that may {@link Authz.Action#SEND} to a named peer. A plain worker or an
|
||||
* unconfigured observer pane can never {@code SEND} at all, so listing every other pane's label
|
||||
* and working directory to one would expose host shape with no use to that caller — the same
|
||||
* reasoning already applied to {@code collaborators}.
|
||||
* 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.
|
||||
*/
|
||||
static boolean panesVisibleTo(Principal caller) {
|
||||
return caller.isPrimary() || caller.isArchitect() || caller.isCollaborator();
|
||||
@@ -2009,10 +2000,8 @@ public final class FleetMcp {
|
||||
}
|
||||
|
||||
/**
|
||||
* As below, with no pane discovery — every wrapper overload above delegates here, so
|
||||
* {@code panes} is omitted and {@code panesVisible} is {@code false}. A test that wants the
|
||||
* {@code panes} row must call the canonical overload below with an explicit {@link PaneSource}
|
||||
* and {@code panesVisible}.
|
||||
* 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,
|
||||
@@ -2051,9 +2040,7 @@ public final class FleetMcp {
|
||||
* {@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}); every wrapper overload above passes
|
||||
* {@code false}, so a test that wants the row must call this overload
|
||||
* with an explicit {@code true}
|
||||
* {@link #panesVisibleTo})
|
||||
*/
|
||||
static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions, MessageService messages,
|
||||
CapacitySource capacity, HealthCoverageSource healthCoverage,
|
||||
@@ -2113,9 +2100,7 @@ public final class FleetMcp {
|
||||
.map(e -> collaboratorRow(e.getKey(), e.getValue()))
|
||||
.toList());
|
||||
}
|
||||
// A pane's label and a member's cwd are not roster facts every READ-gated caller may
|
||||
// see (see panesVisibleTo) -- gate BEFORE assembling the row, same reason as every
|
||||
// other array above.
|
||||
// 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));
|
||||
}
|
||||
@@ -2444,6 +2429,19 @@ 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}
|
||||
@@ -2452,7 +2450,7 @@ public final class FleetMcp {
|
||||
*/
|
||||
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> tabLabels = panes.tabLabels().get();
|
||||
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));
|
||||
|
||||
@@ -437,16 +437,25 @@ 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 {
|
||||
// A tab's label is the only human-usable address for a pane this daemon never spawned
|
||||
// (a hand-opened observer tab); agent.list carries no label of its own, so it is merged
|
||||
// in from the same herdr daemon(s) the agent roster itself is drawn from.
|
||||
Map<String, String> tabLabels = new PaneLocator(herdr, memberHerdr).tabLabelsByTabId();
|
||||
ctx.status(200).json(Map.of("agents",
|
||||
workers.list().stream().map(Agent.class::cast).map(a -> view(a, tabLabels)).toList()));
|
||||
} catch (HerdrException e) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1179,6 +1179,30 @@ class FleetMcpTest {
|
||||
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));
|
||||
|
||||
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 #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,24 @@ class FleetAppTest {
|
||||
assertTrue(body.has("detail"), res.body());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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