diff --git a/CHANGELOG.md b/CHANGELOG.md index 89cfa00c..774282da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Every sister agent gets the fleet surfaces, not just the host console (ADR 0048 §8).** + "New agent" and "Fleet settings" in the header switcher, the ⌘K **Fleet Room**, and + **Settings ▸ Box ▸ Fleet** all render in a fleet member's window now. They were gated to + the host to stop *nested* agents, but that fear never applied here: `/api/fleet` and + `/api/archetypes` are hub paths (never slug-scoped), so from `/app/agent//` those + surfaces were already driving the **hub's** fleet — the same roster, the same members to + start and stop, the same fleet to create into. Open a sister agent, and the fleet is + reachable without a trip back to the host window. + + The gate now asks the one question that actually distinguishes the nesting case: is this + a spawned member reached **directly on its own port**? There its `/api/fleet` really is a + fleet-of-one and creating would spawn a grandchild the hub never sees, so the items stay + disabled with a tooltip pointing at the host. + + Two guards came along with it, since they only became reachable once member windows had + these panels: a window can no longer **Stop** or **Remove** the agent that serves it (the + fleet panel row and the Fleet Room roster both keyed on "is the host" — correct only on + the host console, and a Stop on your own row killed the console you were standing in). + The Box group narrows rather than disappears off the host — Overview and Telemetry read + the focused agent's own endpoints and stay host-only — which is also what makes the + "Fleet settings" deep-link resolve instead of falling back to an unrelated section. + ## [0.115.0] - 2026-07-25 ### Added diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index 0ffea9e5..1d16aa48 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -394,3 +394,56 @@ test("⌘K → Fleet Room: a TYPED @name addresses that member without using the await expect(page.getByPlaceholder(/Message ava/i)).toBeVisible(); await expect(page.locator(".pl-toast", { hasText: /Broadcast to/ })).toHaveCount(0); }); + +// ── Sister agents get the fleet surfaces too (#1708/#1999 revisited) ─────────────────── +// The three affordances used to be host-console-only, on the theory that a member window +// would be managing a fleet-of-one and could only nest. That's false for a slug window: +// /api/fleet + /api/archetypes are HUB paths (lib/api.ts `isHubPath`), so a sister agent's +// console drives the SAME fleet the host does. These pin that it stays reachable there — +// and that the window can't act on the agent serving it. + +// Assert by ACTING, not by reading an aria-disabled attribute: a disabled DS MenuItem is +// pointer-events:none, so a click that lands is proof the item is live — and it also proves +// the deep-link RESOLVES, which is the half that was actually broken (the Box group was +// dropped wholesale off the host, so "Fleet settings" fell back to some other section). +test("a sister agent's window: Fleet settings opens the fleet panel from the switcher", async ({ page }) => { + await page.goto("/app/agent/ava/", { waitUntil: "load" }); + await page.getByTestId("fleet-switcher").click(); + await page.getByRole("menuitem", { name: "Fleet settings" }).click(); + await expect(page.getByRole("heading", { name: "Agents" })).toBeVisible(); +}); + +test("a sister agent's window: New agent opens the archetype picker from the switcher", async ({ page }) => { + await page.goto("/app/agent/ava/", { waitUntil: "load" }); + await page.getByTestId("fleet-switcher").click(); + await page.getByRole("menuitem", { name: "New agent" }).click(); + await expect(page.getByRole("heading", { name: "New agent" })).toBeVisible(); +}); + +test("a sister agent's window: the ⌘K Fleet Room opens on the hub's roster", async ({ page }) => { + await page.goto("/app/agent/ava/", { waitUntil: "load" }); + await openFleetRoom(page); + const room = page.locator(".flr"); + // The hub's real roster — its siblings are here, not an empty fleet-of-one. + await expect(room.locator(".flr__member", { hasText: "main" })).toBeVisible(); + await expect(room.locator(".flr__member", { hasText: "roxy" })).toBeVisible(); + // But no Stop on its OWN row: that button would kill the agent serving this window. + await expect( + room.locator(".flr__member", { hasText: "ava" }).getByRole("button", { name: /^(Stop|Start) ava$/ }), + ).toHaveCount(0); + // A sibling still toggles normally. + await expect(room.locator(".flr__member", { hasText: "roxy" }).getByRole("button", { name: "Start roxy" })).toBeVisible(); +}); + +test("a sister agent's window: the fleet panel won't stop or remove the agent serving it", async ({ page }) => { + await page.goto("/app/agent/ava/", { waitUntil: "load" }); + await page.getByTestId("header-menu").click(); + await page.getByTestId("app-drawer").getByRole("button", { name: "Settings", exact: true }).click(); + await page.locator(".settings-overlay .pl-sidenav").getByRole("tab", { name: "Fleet", exact: true }).click(); + const self = page.locator(".fleet-row", { hasText: "ava" }); + await expect(self).toBeVisible(); + await expect(self.getByRole("button", { name: "Stop" })).toHaveCount(0); + await expect(self.getByRole("button", { name: "Remove" })).toHaveCount(0); + // A sibling keeps its controls — the guard is about SELF, not about being a member window. + await expect(page.locator(".fleet-row", { hasText: "roxy" }).getByRole("button", { name: "Start" })).toBeVisible(); +}); diff --git a/apps/web/e2e/settings.spec.ts b/apps/web/e2e/settings.spec.ts index a70245a3..d7cd7271 100644 --- a/apps/web/e2e/settings.spec.ts +++ b/apps/web/e2e/settings.spec.ts @@ -174,12 +174,10 @@ test("a host-scoped edit on the host console saves to the host layer", async ({ }); // On a FLEET MEMBER console (/agent//) the same fields show the ADR 0047 inheritance -// view instead — inherited-from / overridden-here badges + reset-to-inherited. There is no -// Box group on a member. +// view instead — inherited-from / overridden-here badges + reset-to-inherited. The Box group +// narrows to Fleet there (see the next test). test("per-agent (fleet member) settings show ADR 0047 inheritance badges + reset", async ({ page }) => { await openSettings(page, "/app/agent/ava/"); - // No Box group on a fleet member. - await expect(page.locator(".settings-overlay .pl-sidenav").getByRole("tab", { name: "Fleet", exact: true })).toHaveCount(0); await section(page, "Model"); await expandAllGroups(page); await expect(page.locator('.setting-row[data-key="model.name"] .setting-inheritance')).toContainText( @@ -195,6 +193,21 @@ test("per-agent (fleet member) settings show ADR 0047 inheritance badges + reset await expect(page.locator('.setting-row[data-key="routing.fallback_models"] .setting-inheritance')).toHaveCount(0); }); +// The Box group NARROWS on a sister agent's console, it doesn't vanish: Fleet stays (it names +// the hub's fleet from any window — /api/fleet is a hub path), while Overview + Telemetry read +// the focused agent's own endpoints and remain host-console-only. +test("a sister agent's console keeps Box ▸ Fleet, without the agent-scoped Box sections", async ({ page }) => { + await openSettings(page, "/app/agent/ava/"); + const sidenav = page.locator(".settings-overlay .pl-sidenav"); + await expect(sidenav.getByRole("tab", { name: "Fleet", exact: true })).toBeVisible(); + await expect(sidenav.getByRole("tab", { name: "Overview", exact: true })).toHaveCount(0); + await expect(sidenav.getByRole("tab", { name: "Telemetry", exact: true })).toHaveCount(0); + // And it's the real panel — the hub's roster, reachable from here. + await sidenav.getByRole("tab", { name: "Fleet", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Agents" })).toBeVisible(); + await expect(page.locator(".fleet-row", { hasText: "roxy" })).toBeVisible(); +}); + test("Identity: a name change saves via /api/settings, not /api/config (no operator clobber)", async ({ page }) => { // The Identity panel routes the name through the canonical settings cascade; SOUL (unchanged // here) is what goes via /api/config. The old code ALWAYS POSTed /api/config echoing a cached diff --git a/apps/web/src/app/FleetRoom.tsx b/apps/web/src/app/FleetRoom.tsx index 2e0e7670..eb5383c3 100644 --- a/apps/web/src/app/FleetRoom.tsx +++ b/apps/web/src/app/FleetRoom.tsx @@ -6,9 +6,11 @@ // The bottom bar broadcasts to everyone online (the @everyone announce — the only // fire-and-forget path, since you can't stream N replies into one pane). // -// Entered from the palette's "Agents" group; the DS CommandPalette supplies the -// back/close chrome + footer. Ungated for now — it reflects whatever api.fleet() returns -// for this window; host-scoping (cf. fleetSettingsGate) can follow. +// Entered from the palette's "Agents" group; the DS CommandPalette supplies the back/close +// chrome + footer. Open in every sister agent's window as well as the host's: `/api/fleet` is +// a hub path (never slug-scoped), so the roster it reflects is the same one from anywhere. +// The one window it's withheld from is a member reached DIRECTLY on its own port, where the +// fleet really is a fleet-of-one — see fleetSettingsGate. import { useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { ExternalLink, Play, Radio, Send, Square } from "lucide-react"; @@ -226,7 +228,11 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl {roster.map((a) => { const slug = slugOf(a); const p = presenceOf(a); - const local = !a.host && !a.remote; // only a local process can be started/stopped here + // Only a local process can be started/stopped here — and never the agent serving + // THIS window (`here`): in a sister agent's room its own row would otherwise offer + // a Stop that kills the console you're standing in. The host row is already + // excluded on the host console; `here` is what covers a member's window. + const local = !a.host && !a.remote && slug !== here; return (
diff --git a/apps/web/src/app/FleetSwitcher.tsx b/apps/web/src/app/FleetSwitcher.tsx index 4f9024b6..6c273012 100644 --- a/apps/web/src/app/FleetSwitcher.tsx +++ b/apps/web/src/app/FleetSwitcher.tsx @@ -35,9 +35,10 @@ export function FleetSwitcher({ const agents = fleet.data?.agents ?? []; const slug = currentSlug(); // the agent THIS window is on const current = agents.find((a) => slugOf(a) === slug); - // Fleet settings are hub-only (#1708): non-null in a member window (a hub slug window, - // or a spawned workspace member reached directly) — the item disables with this tooltip. - const fleetSettingsBlocked = fleetSettingsDisabledReason(agents, slug); + // Non-null only on a spawned member reached DIRECTLY on its own port (#1708/#1999) — the + // one window where managing "the fleet" would spawn a nested one. A sister agent's slug + // window drives the hub's fleet and keeps both items live. + const fleetSettingsBlocked = fleetSettingsDisabledReason(agents); // Only a hard fleet-API error hides the switcher; otherwise it's always available. if (fleet.isError) return <>{fallbackName}; @@ -71,9 +72,9 @@ export function FleetSwitcher({ })} {agents.length > 0 ? : null} {fleetSettingsBlocked ? ( - // Adding a member is a host-only op, same as Fleet settings (a member window's - // /api/fleet is a fleet-of-one — "New agent" there would spawn a nested fleet by - // accident, #1999). Disabled + tooltip, not hidden, so it stays discoverable. + // Adding a member follows the same gate as Fleet settings: on a directly-reached + // member /api/fleet is a fleet-of-one, so "New agent" there would spawn a nested + // fleet by accident (#1999). Disabled + tooltip, not hidden, so it stays discoverable. } disabled> New agent diff --git a/apps/web/src/app/fleetSettingsGate.test.ts b/apps/web/src/app/fleetSettingsGate.test.ts index 43856272..2cd20160 100644 --- a/apps/web/src/app/fleetSettingsGate.test.ts +++ b/apps/web/src/app/fleetSettingsGate.test.ts @@ -1,6 +1,8 @@ -// "Fleet settings" gate (#1708): the header dropdown item is hub-only — disabled with -// a point-at-the-host tooltip in member windows, enabled on the host AND on a -// standalone instance (standalone is where you create your first member). +// The fleet-surface gate (#1708/#1999 → sister agents). "New agent", "Fleet settings" and the +// ⌘K Fleet Room are live in every window that drives a REAL fleet — the host, a standalone +// instance, and a sister agent's slug window (its fleet calls are hub paths, so they manage the +// hub's fleet). Only a spawned member reached DIRECTLY on its own port is held back, since +// there the fleet genuinely is a fleet-of-one and creating would nest. import { describe, expect, it } from "vitest"; import type { FleetAgent } from "../lib/types"; @@ -19,32 +21,39 @@ const agent = (over: Partial): FleetAgent => ({ describe("fleetSettingsDisabledReason", () => { it("host instance with a fleet → enabled", () => { const agents = [agent({ name: "main", id: "main", host: true }), agent({ name: "ava", id: "ava-7f3a" })]; - expect(fleetSettingsDisabledReason(agents, "host")).toBeNull(); + expect(fleetSettingsDisabledReason(agents)).toBeNull(); }); it("standalone instance (fleet of one, no member flag) → enabled", () => { // A standalone /api/fleet still returns its own host entry — this is the window // where the first fleet member gets CREATED, so it must never be locked out. const agents = [agent({ name: "main", id: "main", host: true })]; - expect(fleetSettingsDisabledReason(agents, "host")).toBeNull(); + expect(fleetSettingsDisabledReason(agents)).toBeNull(); }); it("spawned workspace member reached directly (host entry self-reports member) → disabled", () => { const agents = [agent({ name: "ava", id: "ava-7f3a", host: true, member: true })]; - expect(fleetSettingsDisabledReason(agents, "host")).toBe(FLEET_SETTINGS_MEMBER_TOOLTIP); + expect(fleetSettingsDisabledReason(agents)).toBe(FLEET_SETTINGS_MEMBER_TOOLTIP); }); - it("member via the hub's slug window → disabled (slug alone decides)", () => { + it("sister agent via the hub's slug window → ENABLED (the roster it sees is the hub's)", () => { + // The window's slug is deliberately not an input: /api/fleet + /api/archetypes are hub + // paths (never slug-scoped), so this window creates into — and manages — the hub's fleet. + // The entry carrying `host` is the HUB's, and a hub never self-reports `member`. const agents = [agent({ name: "main", id: "main", host: true }), agent({ name: "ava", id: "ava-7f3a" })]; - expect(fleetSettingsDisabledReason(agents, "ava-7f3a")).toBe(FLEET_SETTINGS_MEMBER_TOOLTIP); - // Even before the fleet poll lands, the slug already marks a member window. - expect(fleetSettingsDisabledReason([], "ava-7f3a")).toBe(FLEET_SETTINGS_MEMBER_TOOLTIP); + expect(fleetSettingsDisabledReason(agents)).toBeNull(); }); it("remote member reached directly at its own URL → enabled (independent instance)", () => { // A remote's own /api/fleet host entry never carries `member` — registration is // one-sided on the hub, and the remote may legitimately run its OWN fleet. const agents = [agent({ name: "peer", id: "peer", host: true })]; - expect(fleetSettingsDisabledReason(agents, "host")).toBeNull(); + expect(fleetSettingsDisabledReason(agents)).toBeNull(); + }); + + it("an empty roster (the poll hasn't landed) → enabled, so nothing flickers disabled", () => { + // Nothing here proves a fleet-of-one; disabled must never be the cold-open default or the + // menu would flash a point-at-the-host tooltip on every load. + expect(fleetSettingsDisabledReason([])).toBeNull(); }); }); diff --git a/apps/web/src/app/fleetSettingsGate.ts b/apps/web/src/app/fleetSettingsGate.ts index b71aa6a8..6607275b 100644 --- a/apps/web/src/app/fleetSettingsGate.ts +++ b/apps/web/src/app/fleetSettingsGate.ts @@ -1,6 +1,9 @@ -// Gate for the header dropdown's "Fleet settings" item (#1708). The fleet is managed -// from its HOST instance only (ADR 0042) — a member window gets the item DISABLED with -// a tooltip pointing at the host instead of a broken/irrelevant panel. +// Gate for the fleet affordances a console window offers: the header dropdown's "New agent" +// + "Fleet settings" items, and the ⌘K Fleet Room. Originally hub-only (#1708/#1999) on the +// theory that any member window manages a fleet-of-one and so could only spawn NESTED agents +// by accident. That holds for exactly one of the four windows below — a spawned member +// reached DIRECTLY on its own port — so the gate asks that question alone, and every sister +// agent gets the same fleet surfaces its hub has. // // Host / member / standalone matrix (window = how the console reaches an instance): // @@ -8,12 +11,16 @@ // → ENABLED. Covers both a fleet host and a STANDALONE instance — standalone is // where you create your first fleet member, so it must never be locked out. // member via the hub's slug window (/app/agent//) -// → DISABLED. The Box ▸ Fleet settings group only exists on the host window -// (ADR 0047 §7.7): opening it here lands on an unrelated fallback section. -// Signal: the URL slug (client-side, no fetch needed). +// → ENABLED. `/api/fleet` and `/api/archetypes` are HUB paths in the console router +// (`isHubPath`, lib/api.ts) — never slug-scoped — so every fleet read and write from +// a sister agent's window already lands on the hub's supervisor: the hub's real +// roster, the hub's fleet to create into, the hub's members to start/stop. Nothing +// nests; the sister manages the same fleet its hub does. // spawned workspace member reached DIRECTLY (its own port) -// → DISABLED. Its own /api/fleet is a fleet-of-one (its workspaces root is empty -// by construction) — "managing" it there creates a nested fleet by accident. +// → DISABLED. The one true nesting path: its own /api/fleet is a fleet-of-one (its +// workspaces root is empty by construction), so creating there spawns a GRANDCHILD +// the hub's roster never shows, and breaks the invariant that a member's empty +// workspaces root keeps `shutdown_all` hub-only (graph/workspaces/manager.py). // Signal: the member self-reports `member: true` on its /api/fleet host entry. // remote member (ADR 0042 §I) reached directly at its own URL // → ENABLED, deliberately. Registration is one-sided on the hub — the remote has @@ -26,13 +33,12 @@ import type { FleetAgent } from "../lib/types"; export const FLEET_SETTINGS_MEMBER_TOOLTIP = "Fleet settings are managed from the host instance"; /** - * Why "Fleet settings" is unavailable in this window — or `null` when it's allowed. - * `agents` is the polled /api/fleet list; `slug` is `currentSlug()` (the window's URL slug, - * `"host"` when the console talks to its instance directly). + * Why the fleet surfaces are unavailable in this window — or `null` when they're allowed. + * `agents` is the polled /api/fleet list. The window's URL slug deliberately does NOT enter + * into it: a sister agent's slug window drives the hub's fleet, exactly as the host console does. */ -export function fleetSettingsDisabledReason(agents: FleetAgent[], slug: string): string | null { - if (slug !== "host") return FLEET_SETTINGS_MEMBER_TOOLTIP; // hub slug window on a member +export function fleetSettingsDisabledReason(agents: FleetAgent[]): string | null { const self = agents.find((a) => a.host); if (self?.member) return FLEET_SETTINGS_MEMBER_TOOLTIP; // spawned member, reached directly - return null; // fleet host, or a standalone instance (no fleet configured yet) + return null; // host, sister-agent slug window, standalone, or a remote's own console } diff --git a/apps/web/src/app/usePaletteRegistry.ts b/apps/web/src/app/usePaletteRegistry.ts index fa56f903..6720ce9d 100644 --- a/apps/web/src/app/usePaletteRegistry.ts +++ b/apps/web/src/app/usePaletteRegistry.ts @@ -17,7 +17,7 @@ import type { View } from "../lib/viewRegistry"; import { registerPaletteCommand, registeredPaletteCommands } from "../ext/paletteRegistry"; import type { PaletteCommand } from "../ext/paletteRegistry"; import { useQuery } from "@tanstack/react-query"; -import { agentHref, currentSlug } from "../lib/api"; +import { agentHref } from "../lib/api"; import { fleetQuery } from "../lib/queries"; import { markAgentOpened } from "./fleetPalette"; import { fleetRoomView } from "./FleetRoom"; @@ -275,12 +275,12 @@ export function usePaletteRegistry( // roster row carries DM / open-console / start / stop, and every member's name rides // this command's keywords — so typing "ava" still lands you one step from her. // (Re-registered on fleetSig, so the keyword list tracks the live roster.) - // Host-scoped (ADR 0042), mirroring the Fleet settings gate: the fleet is managed from - // its HOST instance. On a member window `/api/fleet` is a fleet-of-one by construction, - // so the room would be an empty, misleading surface (and its DM/broadcast targets would - // be nothing). Disable it there with a pointer at the host rather than hiding it, so the - // command stays discoverable and explains itself. - const fleetGate = fleetSettingsDisabledReason(agents, currentSlug()); + // Open in every sister agent's window, mirroring the Fleet settings gate: `/api/fleet` is + // a HUB path (never slug-scoped), so a member window's room shows the hub's real roster + // and its DM/broadcast targets are the real siblings. Only a member reached DIRECTLY on + // its own port sees a fleet-of-one — there the command disables with a pointer at the + // host rather than hiding, so it stays discoverable and explains itself. + const fleetGate = fleetSettingsDisabledReason(agents); const offFleetRoom = registry.registerCommands([ { id: "fleet-room", diff --git a/apps/web/src/settings/FleetManagerPanel.tsx b/apps/web/src/settings/FleetManagerPanel.tsx index 46b15b34..cf6f4802 100644 --- a/apps/web/src/settings/FleetManagerPanel.tsx +++ b/apps/web/src/settings/FleetManagerPanel.tsx @@ -408,9 +408,13 @@ export function FleetManagerPanel({ onNew }: { onNew?: () => void }) { ) : null} - {/* The host serves this console — it can't stop or remove itself; its - display name is edited in Settings → Identity instead. */} - {a.host || a.remote ? null : ( + {/* The agent serving THIS console can't act on itself — its display name + is edited in Settings → Identity instead. On the host console that's + the host row; in a sister agent's window (where this panel now also + renders) it's that member's row, so the guard keys on the focused slug + as well as `host` — otherwise "Stop"/"Remove" would kill or delete the + very agent serving the window you're in. */} + {a.host || a.remote || isActive ? null : ( <>