diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml index 240eacc..1103313 100644 --- a/fleetd/fleetd.example.yaml +++ b/fleetd/fleetd.example.yaml @@ -62,11 +62,12 @@ bind: # # Leads are configured under `fleet.leaders:` — see THE FLEET further down. # -# Two things stop the tab-name convention from becoming a way to claim leadership: the configured -# member spaces are excluded from the scan, so nothing fleetd places can land in a matching tab; -# and startup REFUSES a `tabPrefix` that the fleet tabLabel template, or any per-profile `tabLabel` -# override, also matches — so the two namespaces cannot overlap by accident. The label is a NAME, -# never a capability: what a pane may do is decided by the role the daemon resolves for it. +# Two things stop the tab-name convention from becoming a way to claim leadership: startup REFUSES +# a `tabPrefix` that the fleet tabLabel template, or any per-profile `tabLabel` override, also +# matches, so the two namespaces cannot overlap by accident; and the CallerResolver asks the live +# spawned-member roster BEFORE any tab map, so a live member is never mistaken for a lead no matter +# what its tab says. The label is a NAME, never a capability: what a pane may do is decided by the +# role the daemon resolves for it. # CB-551: IDLE-LEAD HEARTBEAT — nudge the single lead back to work when it has been continuously # idle (no open fleet_send driving it) past the quiet period. The fleet is one lead + architects + @@ -659,9 +660,10 @@ fleet: # tabPrefix: "lead:" # only used to guard against a worker tabLabel colliding with # # this convention at startup; plays no part in matching a lead # scanIntervalSeconds: 10 # rescan cadence, and the worst case before a new tab is seen - # workspace: leads # where a launched lead's tab is created (default "leads"). - # # MUST NOT be a member workspace — those are excluded from the - # # scan, so a lead placed in one is never found again. + # workspace: leads # where a launched lead's tab is created (default "fleet", + # # the same shared space the members use). Sharing that space + # # with members is the normal shipped shape: the scanner tells + # # a lead from a member by the exact tab label, not by workspace. # cwd: /path/to/repo # the launched lead's working directory (default: fleetd's own) # kind: claude # descriptive; reported by fleet_whoami # gpt-sol-5.6: diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLeadTabScannerExclusionTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLeadTabScannerExclusionTest.java index 3a97a5e..ac7a7d6 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLeadTabScannerExclusionTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLeadTabScannerExclusionTest.java @@ -31,8 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** * fleetd #670 — pins the {@code excludedWorkspaceLabels} argument {@link FleetdAssembly}'s - * production boot path passes to {@link LeadTabScanner} at {@code FleetdAssembly.java:265} - * ({@code Set.of()}). + * production boot path passes to {@link LeadTabScanner} ({@code Set.of()}). * *

{@code LeadTabScannerTest} already covers this constructor parameter, but it builds its own * {@link LeadTabScanner} with its own set, so it tests the seam and proves nothing about the @@ -191,7 +190,7 @@ class FleetdAssemblyLeadTabScannerExclusionTest { Set excluded = (Set) excludedField.get(leads); assertTrue(excluded.isEmpty(), - "FleetdAssembly.java:265 must pass an empty excludedWorkspaceLabels to " + "FleetdAssembly must pass an empty excludedWorkspaceLabels to " + "LeadTabScanner — scanning member tabs would demote the lead to a worker"); } finally { assertNotNull(ports.shutdownHook, "control: assembly must capture its shutdown hook"); diff --git a/fleetd/src/test/java/dev/ltms/fleet/herdr/LeadTabScannerTest.java b/fleetd/src/test/java/dev/ltms/fleet/herdr/LeadTabScannerTest.java index 86d90c6..e56b9b2 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/herdr/LeadTabScannerTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/herdr/LeadTabScannerTest.java @@ -205,9 +205,12 @@ class LeadTabScannerTest { } /** - * The guard that matters: fleetd labels its own worker tabs, so if a worker space were scanned - * a naming accident would promote the fleet. The exclusion is by workspace, not by hoping the - * worker template never collides. + * Covers the {@code excludedWorkspaceLabels} parameter: a tab in an excluded workspace is never + * matched, whatever its label. Production always constructs this class with an empty set (CB-558, + * {@code FleetdAssembly}), so this parameter plays no part in the live guard against a worker + * tab being mistaken for a lead — that guard is {@code CallerResolver} asking the live + * spawned-member roster before any tab map. This test exists because the parameter still exists + * and is worth covering on its own terms. */ @Test void aTabInAWorkerSpaceIsNeverALeadEvenWhenItsLabelMatches() { @@ -219,6 +222,29 @@ class LeadTabScannerTest { assertFalse(scanner(herdr, tabToName, new AtomicLong()).get().containsKey("term_impostor")); } + /** + * The shipped shape (CB-558, {@code FleetdAssembly}): production always constructs this class + * with an empty {@code excludedWorkspaceLabels}, and a lead's {@code workspace:} default is the + * same shared {@code "fleet"} space the members use. A lead tab is still found when it sits in + * the exact same workspace as a member-labelled tab — the scanner tells them apart by the exact + * tab label, not by which workspace either one is in. + */ + @Test + void aLeadIsDiscoveredWhenItsWorkspaceIsTheSameAsTheMemberWorkspace() { + TopologyHerdr herdr = new TopologyHerdr() + .workspace("w1", "fleet") + .tab("w1:t1", "w1", "lead: opus-5.0") + .tab("w1:t2", "w1", "worker: gx10 #1") + .pane("w1:p1", "w1:t1", "term_opus") + .pane("w1:p2", "w1:t2", "term_worker"); + LeadTabScanner s = new LeadTabScanner(herdr, Map.of("lead: opus-5.0", "opus-5.0"), + Set.of(), TTL, new AtomicLong()::get); + + assertEquals("opus-5.0", s.get().get("term_opus"), + "a lead sharing the members' workspace is still discovered — the label, not the " + + "workspace, is what matches it"); + } + @Test void aLabelWithNoConfiguredEntryIsIgnored() { TopologyHerdr herdr = new TopologyHerdr().workspace("w1", "main")