fleetd #770: lead identity keys on the space, not the tab label
CI / shell-tests (pull_request) Failing after 9s
CI / contract (pull_request) Successful in 54s
CI / build (pull_request) Failing after 1m57s
CI / shell-tests (push) Failing after 9s
CI / contract (push) Successful in 53s
CI / build (push) Failing after 1m52s
CI / shell-tests (pull_request) Failing after 9s
CI / contract (pull_request) Successful in 54s
CI / build (pull_request) Failing after 1m57s
CI / shell-tests (push) Failing after 9s
CI / contract (push) Successful in 53s
CI / build (push) Failing after 1m52s
The lead tab label becomes a fixed constant (Leader.LEAD_TAB_LABEL = "lead"); fleet.leaders.<name>.tab is now optional legacy, matched case-insensitively alongside the constant via Leader.acceptedLabels(). The uniqueness boundary between leads moves from the exact tab text to the workspace: FleetConfig refuses two leaders that share a workspace, LeadTabScanner indexes lead labels per space (collaborators stay space-agnostic), and LeadLauncher.leadNameOf/countLeads require both the accepted label and the lead's own space to match, so a legacy-labelled tab in the wrong space never counts and a daemon restart never double-spawns a second lead next to a live one. Config validation also refuses a fleet.tabLabel template or a collaborator tab that can render as the fixed lead label.
This commit is contained in:
@@ -254,18 +254,28 @@ final class FleetdAssembly {
|
||||
if (leadTerminals.size() > 1) {
|
||||
log.info("leads: {} panes recognised {}", leadTerminals.size(), leadTerminals.values());
|
||||
}
|
||||
// CB-531/CB-579: discover leads by the tab labels the operator writes, one scanner per
|
||||
// configured lead's own exact `tab:` label. fleetd #669: the same scan also recognises a
|
||||
// configured collaborator's tab, so one herdr pass answers both.
|
||||
// Discover leads by their tab labels, one scanner per configured lead's own space. fleetd
|
||||
// #669: the same scan also recognises a configured collaborator's tab, so one herdr pass
|
||||
// answers both.
|
||||
final Supplier<Map<String, String>> leads;
|
||||
final Supplier<Map<String, String>> collaboratorTerminals;
|
||||
var leaders = cfg.fleet().leaders();
|
||||
var collaboratorsConfig = cfg.fleet().collaborators();
|
||||
if (!leaders.isEmpty() || !collaboratorsConfig.isEmpty()) {
|
||||
Map<String, String> tabToName = new LinkedHashMap<>();
|
||||
Map<String, Map<String, String>> leadLabelsBySpace = new LinkedHashMap<>();
|
||||
Map<String, String> spaceByLeadName = new LinkedHashMap<>();
|
||||
leaders.forEach((name, leader) -> {
|
||||
if (leader != null && leader.tab() != null && !leader.tab().isBlank()) {
|
||||
tabToName.put(leader.tab(), name);
|
||||
if (leader == null) {
|
||||
return;
|
||||
}
|
||||
spaceByLeadName.put(name, leader.workspace());
|
||||
Map<String, String> labelsHere = leadLabelsBySpace
|
||||
.computeIfAbsent(leader.workspace(), k -> new LinkedHashMap<>());
|
||||
leader.acceptedLabels().forEach(label -> labelsHere.put(label, name));
|
||||
if (leader.tab() != null && !leader.tab().isBlank()) {
|
||||
log.warn("lead '{}' (fleet.leaders.{}) still configures tab: \"{}\" — deprecated, "
|
||||
+ "the lead tab label is now fixed to '{}'",
|
||||
name, name, leader.tab(), FleetConfig.Leader.LEAD_TAB_LABEL);
|
||||
}
|
||||
});
|
||||
Map<String, String> collaboratorTabToName = new LinkedHashMap<>();
|
||||
@@ -283,13 +293,13 @@ final class FleetdAssembly {
|
||||
? 10
|
||||
: leaders.values().iterator().next().scanIntervalSeconds();
|
||||
// This must use the lead daemon: scanning member tabs would demote the lead to a worker.
|
||||
LeadTabScanner scanner = new LeadTabScanner(herdr, tabToName, collaboratorTabToName, Set.of(),
|
||||
TimeUnit.SECONDS.toNanos(scanIntervalSeconds), ports.nanoClock());
|
||||
LeadTabScanner scanner = new LeadTabScanner(herdr, leadLabelsBySpace, collaboratorTabToName,
|
||||
Set.of(), TimeUnit.SECONDS.toNanos(scanIntervalSeconds), ports.nanoClock());
|
||||
leads = scanner;
|
||||
collaboratorTerminals = scanner::collaborators;
|
||||
log.info("lead/collaborator scan: tabs {} host a lead, tabs {} host a collaborator "
|
||||
+ "(rescan every {}s, shared fleet space)",
|
||||
tabToName.keySet(), collaboratorTabToName.keySet(), scanIntervalSeconds);
|
||||
log.info("lead/collaborator scan: space per lead {}, tabs {} host a collaborator "
|
||||
+ "(rescan every {}s)",
|
||||
spaceByLeadName, collaboratorTabToName.keySet(), scanIntervalSeconds);
|
||||
} else {
|
||||
leads = () -> leadTerminals;
|
||||
collaboratorTerminals = Map::of;
|
||||
|
||||
@@ -28,6 +28,7 @@ import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -1092,8 +1093,8 @@ public record FleetConfig(
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of the CB-530 {@code leaders:} registry — a pane that orchestrates rather than one
|
||||
* that is orchestrated.
|
||||
* One entry of the {@code leaders:} registry — a pane that orchestrates rather than one that is
|
||||
* orchestrated.
|
||||
*
|
||||
* <p>Why a registry and not a second {@code primary:}: {@code primary.terminal} is singular by
|
||||
* construction, so a session in any other pane resolves as a worker. That is correct while one
|
||||
@@ -1103,46 +1104,49 @@ public record FleetConfig(
|
||||
* <p>{@code kind} and {@code model} are descriptive only: they document what runs in the pane
|
||||
* and are reported back by {@code fleet_whoami}.
|
||||
*
|
||||
* <p><b>A lead is now also creatable (CB-557).</b> Before, nothing spawned one — a lead
|
||||
* pre-existed, which is why it had to be recognised by configuration rather than created. With
|
||||
* {@code profile} and {@code instances} the daemon may stand one up when none is live, so the
|
||||
* pane no longer has to exist before the daemon does. Recognition still comes first: a lead
|
||||
* already running in its configured {@code tab} is adopted, and only the shortfall is launched.
|
||||
* <p>A lead with {@code profile} and {@code instances} set may be launched by the daemon when
|
||||
* none is live; recognition always comes first, so only the shortfall is launched.
|
||||
*
|
||||
* <p><b>{@code tab} replaced {@code terminal} (CB-579).</b> A herdr {@code terminal_id} changes
|
||||
* every time the lead's session restarts, so pinning one cost a config edit and a daemon restart
|
||||
* per restart. A tab is stable: a human opens it once, it holds exactly one pane, and its label
|
||||
* survives restarts of the agent inside it — so identity is now the tab label alone.
|
||||
* <p>Every lead's tab is labelled {@link #LEAD_TAB_LABEL}, a fixed constant — not a per-entry
|
||||
* config value. {@code workspace} is therefore what tells one lead from another: two leaders
|
||||
* sharing one space would both resolve to the one tab named {@code lead} there, so only one
|
||||
* could ever be found. {@code tab} is a deprecated legacy label, still matched within this
|
||||
* lead's own space alongside the constant.
|
||||
*
|
||||
* @param profile the {@code profiles:} entry to launch this lead on when one must
|
||||
* be created; {@code null} ⇒ recognise-only, never create.
|
||||
* <p>fleetd #176: also the field {@code Fleetd.leadSeatLookup} reads
|
||||
* to learn which account this lead's own live session shares — set it
|
||||
* <p>Also the field {@code Fleetd.leadSeatLookup} reads to learn
|
||||
* which account this lead's own live session shares — set it
|
||||
* (safely, even on an already-running recognise-only lead: naming a
|
||||
* profile here never starts anything beyond {@code instances}) so a
|
||||
* {@code subscription: true} worker profile sharing its
|
||||
* {@code effectiveCredentialId()} has this lead's seat subtracted from
|
||||
* {@code fleet_list}'s {@code free}. {@code null} here also means this
|
||||
* lead's seat cannot be derived and is not counted.
|
||||
* @param tab the exact tab label hosting this lead, matched case-insensitively;
|
||||
* the only field identity depends on. Required — a lead with no
|
||||
* {@code tab} can never be discovered, launched or not
|
||||
* @param tab deprecated legacy tab label, matched case-insensitively within
|
||||
* this lead's own space alongside {@link #LEAD_TAB_LABEL}. Optional —
|
||||
* {@code null}/blank means only the constant is accepted
|
||||
* @param instances how many of this lead should be live (default 1). The daemon
|
||||
* launches only the shortfall, so a restart adopts rather than doubles
|
||||
* @param tabPrefix lead-tab naming convention checked against member labels. Lead
|
||||
* identity uses {@code tab}. Default {@code "lead:"}
|
||||
* identity uses {@link #acceptedLabels()}. Default {@code "lead:"}
|
||||
* @param scanIntervalSeconds how long a tab scan is cached before herdr is asked again; also the
|
||||
* worst case before a newly-labelled tab is recognised. Default 10
|
||||
* @param kind which agent runs there ({@code claude}, {@code opencode}, …)
|
||||
* @param model the model or selector it runs, for operators reading the roster
|
||||
* @param workspace the space this lead's tab lives in — the uniqueness boundary
|
||||
* identity now depends on. Default {@link #DEFAULT_WORKSPACE}
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Leader(String profile, String tab, Integer instances, String tabPrefix,
|
||||
Integer scanIntervalSeconds, String kind, String model,
|
||||
String workspace, String cwd) {
|
||||
|
||||
/** The tab label every lead is found by, and an auto-launched instance is created with. */
|
||||
public static final String LEAD_TAB_LABEL = "lead";
|
||||
|
||||
/**
|
||||
* Where an auto-launched lead's tab is created (CB-558). It defaults to the SAME shared
|
||||
* Where an auto-launched lead's tab is created. It defaults to the SAME shared
|
||||
* {@code "fleet"} space the members use, so the operator sees one "session" with many tabs.
|
||||
* The scanner no longer excludes member spaces — it tells a lead from a member by the exact
|
||||
* tab label, so a lead sharing the members' space is still discovered (see LeadLauncher).
|
||||
@@ -1170,9 +1174,23 @@ public record FleetConfig(
|
||||
return profile != null && !profile.isBlank() && instances > 0;
|
||||
}
|
||||
|
||||
/** The tab label an auto-launched instance of this lead gets — its configured {@code tab}. */
|
||||
/** The tab label an auto-launched instance of this lead gets. */
|
||||
public String tabLabel() {
|
||||
return tab;
|
||||
return LEAD_TAB_LABEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* The normalised labels (stripped, lower-cased) a tab in this lead's own space may carry to
|
||||
* be recognised as this lead: {@link #LEAD_TAB_LABEL} first, plus the deprecated {@code tab}
|
||||
* when configured and different. Every matcher in this class's callers reads this method —
|
||||
* none re-derives the set.
|
||||
*/
|
||||
public List<String> acceptedLabels() {
|
||||
String normalizedTab = (tab == null) ? null : tab.toLowerCase(Locale.ROOT);
|
||||
if (normalizedTab == null || normalizedTab.equals(LEAD_TAB_LABEL)) {
|
||||
return List.of(LEAD_TAB_LABEL);
|
||||
}
|
||||
return List.of(LEAD_TAB_LABEL, normalizedTab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2747,23 +2765,42 @@ public record FleetConfig(
|
||||
|
||||
/**
|
||||
* Reject a member tab-label template that could render as a configured lead or collaborator
|
||||
* tab or match a lead-tab naming convention, and reject two {@code fleet.leaders} or
|
||||
* {@code fleet.collaborators} entries — across either registry — that share one exact tab.
|
||||
* tab, as the fixed lead tab label, or that matches a lead-tab naming convention; reject two
|
||||
* {@code fleet.leaders} entries that share one space; reject two {@code fleet.collaborators}
|
||||
* entries — or a lead and a collaborator — that share one exact tab; and reject a collaborator
|
||||
* tab equal to the fixed lead tab label.
|
||||
*
|
||||
* <p>{@code fleet.collaborators} has no {@code tabPrefix}: identity is matched on the exact
|
||||
* {@code tab} alone, so only the exact-render check applies there, not the prefix check.
|
||||
*
|
||||
* @throws IllegalStateException when the fleet template or a profile {@code tabLabel} override
|
||||
* can render as a configured lead or collaborator tab or match a
|
||||
* lead-tab prefix, or when two entries — of either registry, or
|
||||
* one of each — carry the same exact {@code tab}
|
||||
* (case-insensitively)
|
||||
* can render as a configured lead or collaborator tab, as the
|
||||
* fixed lead tab label, or match a lead-tab prefix; when two
|
||||
* leaders share one space; when two collaborators (or a lead and
|
||||
* a collaborator) carry the same exact {@code tab}
|
||||
* (case-insensitively); or when a collaborator's {@code tab}
|
||||
* equals the fixed lead tab label
|
||||
*/
|
||||
public void validateLeadTabPrefixes() {
|
||||
if (fleet == null) {
|
||||
return;
|
||||
}
|
||||
List<String> bad = new ArrayList<>();
|
||||
if (templateCanRenderAs(fleet.tabLabel(), Leader.LEAD_TAB_LABEL)) {
|
||||
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" can render as \""
|
||||
+ Leader.LEAD_TAB_LABEL + "\", the fixed lead tab label");
|
||||
}
|
||||
profiles().entrySet().stream()
|
||||
.map(Map.Entry::getKey)
|
||||
.sorted()
|
||||
.forEach(p -> {
|
||||
String label = profiles().get(p).tabLabel();
|
||||
if (templateCanRenderAs(label, Leader.LEAD_TAB_LABEL)) {
|
||||
bad.add("profile '" + p + "' overrides tabLabel with \"" + label
|
||||
+ "\", which can render as \"" + Leader.LEAD_TAB_LABEL
|
||||
+ "\", the fixed lead tab label");
|
||||
}
|
||||
});
|
||||
fleet.leaders().forEach((leadName, leader) -> {
|
||||
if (leader == null) {
|
||||
return;
|
||||
@@ -2798,6 +2835,10 @@ public record FleetConfig(
|
||||
return;
|
||||
}
|
||||
String tab = collaborator.tab();
|
||||
if (tab != null && tab.equalsIgnoreCase(Leader.LEAD_TAB_LABEL)) {
|
||||
bad.add("fleet.collaborators." + collabName + ".tab=\"" + tab + "\" is the fixed "
|
||||
+ "lead tab label — a collaborator there would shadow a lead");
|
||||
}
|
||||
if (templateCanRenderAs(fleet.tabLabel(), tab)) {
|
||||
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" can render as the tab of "
|
||||
+ "collaborator '" + collabName + "' (\"" + tab + "\")");
|
||||
@@ -2827,18 +2868,20 @@ public record FleetConfig(
|
||||
for (int i = 0; i < leadNames.size(); i++) {
|
||||
String nameA = leadNames.get(i);
|
||||
Leader a = fleet.leaders().get(nameA);
|
||||
if (a == null || a.tab() == null || a.tab().isBlank()) {
|
||||
if (a == null) {
|
||||
continue;
|
||||
}
|
||||
for (int j = i + 1; j < leadNames.size(); j++) {
|
||||
String nameB = leadNames.get(j);
|
||||
Leader b = fleet.leaders().get(nameB);
|
||||
if (b == null || b.tab() == null || b.tab().isBlank()) {
|
||||
if (b == null) {
|
||||
continue;
|
||||
}
|
||||
if (a.tab().equalsIgnoreCase(b.tab())) {
|
||||
collisions.add("lead '" + nameA + "' and lead '" + nameB + "' both use tab \""
|
||||
+ a.tab() + "\"");
|
||||
if (a.workspace().equalsIgnoreCase(b.workspace())) {
|
||||
collisions.add("lead '" + nameA + "' and lead '" + nameB + "' share workspace \""
|
||||
+ a.workspace() + "\" — both would resolve to the tab named \""
|
||||
+ Leader.LEAD_TAB_LABEL + "\" in that space, so only one could ever be "
|
||||
+ "found");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2882,9 +2925,9 @@ public record FleetConfig(
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException("refusing to start: " + String.join("; ", collisions)
|
||||
+ ". Tab identity is matched exactly, so only one of two entries sharing a tab can "
|
||||
+ "ever be found — the other is silently unreachable. Give each lead and "
|
||||
+ "collaborator its own exact tab.");
|
||||
+ ". Identity is matched exactly, so only one of two entries sharing a space or a "
|
||||
+ "tab can ever be found — the other is silently unreachable. Give each lead its "
|
||||
+ "own space, and each collaborator its own exact tab.");
|
||||
}
|
||||
|
||||
private static boolean templateCanRenderAs(String template, String tab) {
|
||||
@@ -3061,14 +3104,13 @@ public record FleetConfig(
|
||||
* so duplicates are unrepresentable by construction once loaded — and {@link #load(Path)}
|
||||
* already rejects a duplicated slot name at parse time, before the map collapses.
|
||||
*
|
||||
* <p>Also rejects a {@code fleet.collaborators} entry with no (or a blank) {@code tab}. A
|
||||
* {@code profile}-less lead is still useful recognise-only — {@code tab} is the only field
|
||||
* that matters to it either way. A collaborator carries no other field at all, so a blank
|
||||
* {@code tab} leaves nothing for the entry to mean.
|
||||
* <p>A lead's {@code profile} is optional — a {@code profile}-less lead is still useful
|
||||
* recognise-only. Also rejects a {@code fleet.collaborators} entry with no (or a blank)
|
||||
* {@code tab}: a collaborator carries no other field at all, so a blank {@code tab} leaves
|
||||
* nothing for the entry to mean.
|
||||
*
|
||||
* @throws IllegalStateException when a slot names no profile or an unknown one, when a lead
|
||||
* can be neither found nor created, or when a collaborator names
|
||||
* no tab, naming the offending entry
|
||||
* @throws IllegalStateException when a slot or a lead references an unknown profile, or a
|
||||
* collaborator names no tab, naming the offending entry
|
||||
*/
|
||||
public void validateMembers() {
|
||||
if (fleet == null) {
|
||||
@@ -3101,10 +3143,6 @@ public record FleetConfig(
|
||||
+ "', which is not a configured profiles: entry (have: " + profiles.keySet()
|
||||
+ ").");
|
||||
}
|
||||
if (leader.tab() == null || leader.tab().isBlank()) {
|
||||
bad.add("fleet.leaders." + name + " has no tab: — a lead is now found (and, if "
|
||||
+ "auto-launched, labelled) purely by its tab, so every entry must name one.");
|
||||
}
|
||||
});
|
||||
fleet.collaborators().forEach((name, collaborator) -> {
|
||||
if (collaborator == null) {
|
||||
|
||||
@@ -25,14 +25,12 @@ import java.util.function.Supplier;
|
||||
* by first starting the session and asking it. Scanning closes that loop: label the tab, and the
|
||||
* pane is recognised on the next resolve.
|
||||
*
|
||||
* <p><strong>CB-579 — matched by name, not prefix.</strong> This used to strip one shared
|
||||
* {@code tabPrefix} off a label to derive the lead's name, and merged a config-supplied
|
||||
* {@code terminal_id} pin over every scan result so the pin could never expire. Both are gone: each
|
||||
* lead now configures its own exact {@code tab} label ({@code fleet.leaders.<name>.tab}), so this
|
||||
* class is handed a {@code tab → name} map up front and matches labels against it exactly
|
||||
* (case-insensitively). There is no merge step — a scan result is the whole answer. That is the
|
||||
* fix for the bug this replaces: a {@code terminal_id} pin surviving in config after the pane it
|
||||
* named was gone, so the daemon kept treating a dead session as a live lead forever.
|
||||
* <p><strong>Matched by label within a space, not by a shared prefix.</strong> Each lead's accepted
|
||||
* labels (the fixed {@code lead} label, plus a deprecated {@code tab} when still configured) are
|
||||
* matched exactly (case-insensitively) against tabs in that lead's own space only — a tab named
|
||||
* {@code lead} in one space never resolves to another space's lead. A scan result is the whole
|
||||
* answer; nothing is merged in from configuration between scans, so a tab that is gone drops out on
|
||||
* the very next scan instead of lingering forever.
|
||||
*
|
||||
* <p><strong>Direction of trust.</strong> The label names the lead; it never <em>grants</em>
|
||||
* anything a pane could take for itself. Three properties keep that honest:
|
||||
@@ -103,7 +101,8 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
||||
private record Entry(String name, Kind kind) {}
|
||||
|
||||
private final HerdrClient herdr;
|
||||
private final Map<String, Entry> tabToEntry;
|
||||
private final Map<String, Map<String, String>> leadLabelsBySpace;
|
||||
private final Map<String, String> collaboratorTabToName;
|
||||
private final Set<String> excludedWorkspaceLabels;
|
||||
private final long ttlNanos;
|
||||
private final LongSupplier clock;
|
||||
@@ -124,15 +123,17 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
||||
/**
|
||||
* @param herdr the herdr client to query ({@code workspace.list},
|
||||
* {@code tab.list}, {@code pane.list} — all read-only)
|
||||
* @param tabToName every configured lead's exact tab label → its name
|
||||
* ({@code fleet.leaders.<name>.tab}), matched case-insensitively
|
||||
* @param leadLabelsBySpace each configured lead's accepted tab labels, keyed by the
|
||||
* lead's own space label, then by label, to its name — matched
|
||||
* case-insensitively on both the space and the label. A tab
|
||||
* matches a lead only within that lead's own space
|
||||
* @param excludedWorkspaceLabels workspaces never scanned — the configured worker spaces
|
||||
* @param ttlNanos how long a scan result is reused before the next one
|
||||
* @param clock nanosecond time source ({@code System::nanoTime} in production)
|
||||
*/
|
||||
public LeadTabScanner(HerdrClient herdr, Map<String, String> tabToName,
|
||||
public LeadTabScanner(HerdrClient herdr, Map<String, Map<String, String>> leadLabelsBySpace,
|
||||
Set<String> excludedWorkspaceLabels, long ttlNanos, LongSupplier clock) {
|
||||
this(herdr, tabToName, Map.of(), excludedWorkspaceLabels, ttlNanos, clock);
|
||||
this(herdr, leadLabelsBySpace, Map.of(), excludedWorkspaceLabels, ttlNanos, clock);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,14 +141,15 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
||||
* for configured collaborator tabs in the same pass.
|
||||
*
|
||||
* @param collaboratorTabToName every configured collaborator's exact tab label → its name
|
||||
* ({@code fleet.collaborators.<name>.tab}), matched the same way as
|
||||
* {@code tabToName}
|
||||
* ({@code fleet.collaborators.<name>.tab}), matched
|
||||
* case-insensitively in any space
|
||||
*/
|
||||
public LeadTabScanner(HerdrClient herdr, Map<String, String> tabToName,
|
||||
public LeadTabScanner(HerdrClient herdr, Map<String, Map<String, String>> leadLabelsBySpace,
|
||||
Map<String, String> collaboratorTabToName,
|
||||
Set<String> excludedWorkspaceLabels, long ttlNanos, LongSupplier clock) {
|
||||
this.herdr = herdr;
|
||||
this.tabToEntry = buildTabIndex(tabToName, collaboratorTabToName);
|
||||
this.leadLabelsBySpace = buildLeadIndex(leadLabelsBySpace);
|
||||
this.collaboratorTabToName = normalizedLabelMap(collaboratorTabToName);
|
||||
this.excludedWorkspaceLabels = excludedWorkspaceLabels == null
|
||||
? Set.of() : Set.copyOf(excludedWorkspaceLabels);
|
||||
this.ttlNanos = ttlNanos;
|
||||
@@ -155,30 +157,46 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys stripped and lower-cased once, so every lookup is a plain map hit. Leads and
|
||||
* collaborators merge into a single index, so {@link #scan()} matches both kinds in one pass
|
||||
* over the tab list; a label naming both a lead and a collaborator takes the lead entry —
|
||||
* leads are put last, so a colliding key's lead entry is the one that overwrites — since a lead
|
||||
* can already do everything a collaborator can. Config validation already refuses a lead and a
|
||||
* collaborator sharing one exact tab, so this ordering is defence in depth, not the control.
|
||||
* Space and label keys stripped and lower-cased once, so every lookup is a plain map hit. A
|
||||
* space with no usable labels is simply absent — {@link #leadLabelsFor} then finds nothing for
|
||||
* it, which is also what a space with a {@code null} label gets.
|
||||
*/
|
||||
private static Map<String, Entry> buildTabIndex(Map<String, String> tabToName,
|
||||
Map<String, String> collaboratorTabToName) {
|
||||
Map<String, Entry> out = new LinkedHashMap<>();
|
||||
putNormalized(out, collaboratorTabToName, Kind.COLLABORATOR);
|
||||
putNormalized(out, tabToName, Kind.LEAD);
|
||||
private static Map<String, Map<String, String>> buildLeadIndex(
|
||||
Map<String, Map<String, String>> leadLabelsBySpace) {
|
||||
Map<String, Map<String, String>> out = new LinkedHashMap<>();
|
||||
if (leadLabelsBySpace == null) {
|
||||
return Map.of();
|
||||
}
|
||||
leadLabelsBySpace.forEach((space, labelsToName) -> {
|
||||
if (space == null || space.isBlank()) {
|
||||
return;
|
||||
}
|
||||
Map<String, String> normalized = normalizedLabelMap(labelsToName);
|
||||
if (!normalized.isEmpty()) {
|
||||
out.put(space.strip().toLowerCase(Locale.ROOT), normalized);
|
||||
}
|
||||
});
|
||||
return Collections.unmodifiableMap(out);
|
||||
}
|
||||
|
||||
private static void putNormalized(Map<String, Entry> out, Map<String, String> tabToName, Kind kind) {
|
||||
if (tabToName == null) {
|
||||
return;
|
||||
private static Map<String, String> normalizedLabelMap(Map<String, String> labelToName) {
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
if (labelToName != null) {
|
||||
labelToName.forEach((label, name) -> {
|
||||
if (label != null && !label.isBlank() && name != null && !name.isBlank()) {
|
||||
out.put(label.strip().toLowerCase(Locale.ROOT), name);
|
||||
}
|
||||
});
|
||||
}
|
||||
tabToName.forEach((tab, name) -> {
|
||||
if (tab != null && !tab.isBlank() && name != null && !name.isBlank()) {
|
||||
out.put(tab.strip().toLowerCase(Locale.ROOT), new Entry(name, kind));
|
||||
}
|
||||
});
|
||||
return Collections.unmodifiableMap(out);
|
||||
}
|
||||
|
||||
/** The accepted lead labels configured for {@code spaceLabel}, or an empty map for no match. */
|
||||
private Map<String, String> leadLabelsFor(String spaceLabel) {
|
||||
if (spaceLabel == null) {
|
||||
return Map.of();
|
||||
}
|
||||
return leadLabelsBySpace.getOrDefault(spaceLabel.strip().toLowerCase(Locale.ROOT), Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,9 +259,10 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
||||
if (ws.workspaceId() == null || excludedWorkspaceLabels.contains(ws.label())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> leadLabelsHere = leadLabelsFor(ws.label());
|
||||
for (JsonNode t : herdr.call("tab.list", Map.of("workspace_id", ws.workspaceId())).path("tabs")) {
|
||||
Tab tab = Tab.from(t);
|
||||
Entry entry = entryOf(tab.label());
|
||||
Entry entry = entryOf(tab.label(), leadLabelsHere);
|
||||
if (entry != null && tab.tabId() != null) {
|
||||
entryByTab.put(tab.tabId(), entry);
|
||||
}
|
||||
@@ -296,19 +315,27 @@ public final class LeadTabScanner implements Supplier<Map<String, String>> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry a tab label declares, or {@code null} if it names neither a configured lead nor a
|
||||
* configured collaborator.
|
||||
* The entry a tab label declares within one space, or {@code null} if it names neither a lead
|
||||
* accepted in {@code leadLabelsHere} nor a configured collaborator.
|
||||
*
|
||||
* <p>Exact match (case-insensitive, ends stripped) against {@link #tabToEntry} — no prefix
|
||||
* stripping, so an operator's {@code "lead: something-else"} tab is never mistaken for a
|
||||
* configured lead just because it shares a prefix. The match strips a trailing
|
||||
* {@link PendingCloseMarker} first, so a tab {@code LeadLauncher} has flagged as maybe-dead but
|
||||
* not yet closed keeps resolving normally while that reconcile is pending.
|
||||
* <p>Exact match (case-insensitive, ends stripped) — no prefix stripping, so an operator's
|
||||
* {@code "lead: something-else"} tab is never mistaken for a configured lead just because it
|
||||
* shares a prefix. The match strips a trailing {@link PendingCloseMarker} first, so a tab
|
||||
* {@code LeadLauncher} has flagged as maybe-dead but not yet closed keeps resolving normally
|
||||
* while that reconcile is pending. A lead match wins over a collaborator match for the same
|
||||
* label — a lead can already do everything a collaborator can, and config validation refuses a
|
||||
* lead and a collaborator sharing one exact tab in the first place.
|
||||
*/
|
||||
private Entry entryOf(String label) {
|
||||
private Entry entryOf(String label, Map<String, String> leadLabelsHere) {
|
||||
if (label == null) {
|
||||
return null;
|
||||
}
|
||||
return tabToEntry.get(PendingCloseMarker.strip(label).toLowerCase(Locale.ROOT));
|
||||
String normalized = PendingCloseMarker.strip(label).toLowerCase(Locale.ROOT);
|
||||
String leadName = leadLabelsHere.get(normalized);
|
||||
if (leadName != null) {
|
||||
return new Entry(leadName, Kind.LEAD);
|
||||
}
|
||||
String collaboratorName = collaboratorTabToName.get(normalized);
|
||||
return collaboratorName == null ? null : new Entry(collaboratorName, Kind.COLLABORATOR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
@@ -297,15 +298,11 @@ public final class LeadLauncher {
|
||||
|
||||
/**
|
||||
* How many live leads exist per configured name, and which of that name's labelled tabs are
|
||||
* <em>not</em> live: a running agent in a tab labelled with that lead's exact {@code tab}
|
||||
* (CB-579). A member sitting in the same shared workspace is not counted as a lead because its
|
||||
* tab carries a different label, not because any workspace is excluded from this count.
|
||||
*
|
||||
* <p>There used to be a second path here — a running agent on the terminal a
|
||||
* {@code fleet.leaders.<name>.terminal} pin named, for a lead opened and pinned by hand. That
|
||||
* pin is retired: {@code tab} is now the only field identity depends on, and {@link Agent}
|
||||
* already carries {@link Agent#tabId()} directly, so a hand-opened lead is found the same way an
|
||||
* auto-launched one is — by labelling its tab to match.
|
||||
* <em>not</em> live: a running agent in a tab, in that lead's own space, carrying one of its
|
||||
* {@link FleetConfig.Leader#acceptedLabels()}. A member sitting in the same shared workspace is
|
||||
* not counted as a lead because its tab carries a different label, not because any workspace is
|
||||
* excluded from this count. A tab matching a lead's label in a <em>different</em> space is not
|
||||
* counted either — space is the uniqueness boundary between leads.
|
||||
*
|
||||
* <p>fleetd #359 review finding 1: a labelled tab with nothing running in it is split into
|
||||
* {@code toClose} (already flagged pending-close by a previous reconcile, and still dead — two
|
||||
@@ -319,12 +316,11 @@ public final class LeadLauncher {
|
||||
}
|
||||
|
||||
private Map<String, LeadCount> countLeads(Map<String, FleetConfig.Leader> leaders) {
|
||||
// A lead and the members share ONE workspace now (the operator asked for a single "session"
|
||||
// with many tabs), so a workspace can no longer be excluded wholesale — the lead lives in the
|
||||
// member workspace by design. The sole discriminator is the exact tab label: a lead carries
|
||||
// its configured `fleet.leaders.<name>.tab` ("lead: opus"), while a member carries its
|
||||
// profile's `worker: {profile} #{n}` template. These never collide, so an exact-label match
|
||||
// separates them without needing to know which workspace anyone is in.
|
||||
// A lead and the members share ONE workspace (the operator asked for a single "session" with
|
||||
// many tabs), so a workspace can no longer be excluded wholesale — the lead lives in the
|
||||
// member workspace by design. The discriminator is the tab label together with the space: a
|
||||
// member's tab never carries one of a lead's accepted labels, and a lead's own label only
|
||||
// counts within that lead's configured space.
|
||||
Map<String, String> nameByTab = new LinkedHashMap<>();
|
||||
Set<String> flaggedTabIds = new LinkedHashSet<>();
|
||||
for (Workspace ws : spaces.listWorkspaces()) {
|
||||
@@ -332,7 +328,7 @@ public final class LeadLauncher {
|
||||
continue;
|
||||
}
|
||||
for (Tab tab : spaces.listTabs(ws.workspaceId())) {
|
||||
String declared = leadNameOf(tab.label(), leaders);
|
||||
String declared = leadNameOf(tab.label(), ws.label(), leaders);
|
||||
if (declared != null && tab.tabId() != null) {
|
||||
nameByTab.put(tab.tabId(), declared);
|
||||
if (PendingCloseMarker.isFlagged(tab.label())) {
|
||||
@@ -382,21 +378,24 @@ public final class LeadLauncher {
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured lead a tab label names, or {@code null} for a label that names none.
|
||||
* The configured lead a tab names, or {@code null} for a label or space that names none.
|
||||
*
|
||||
* <p>Matched exactly (case-insensitively) against each lead's configured {@code tab}, so an
|
||||
* operator's {@code "lead: something-else"} tab is not mistaken for a configured lead. A
|
||||
* trailing {@link PendingCloseMarker} is stripped first, so a tab this class flagged on a
|
||||
* previous reconcile is still recognised as the same lead's tab on this one.
|
||||
* <p>A match requires both: the label (case-insensitively, trailing {@link PendingCloseMarker}
|
||||
* stripped) must be one of the lead's {@link FleetConfig.Leader#acceptedLabels()}, and {@code
|
||||
* space} must be that lead's own {@link FleetConfig.Leader#workspace()}. The same label in a
|
||||
* different space names no lead — space is the uniqueness boundary between leads.
|
||||
*/
|
||||
private String leadNameOf(String label, Map<String, FleetConfig.Leader> leaders) {
|
||||
if (label == null) {
|
||||
private String leadNameOf(String label, String space, Map<String, FleetConfig.Leader> leaders) {
|
||||
if (label == null || space == null) {
|
||||
return null;
|
||||
}
|
||||
String l = PendingCloseMarker.strip(label);
|
||||
String l = PendingCloseMarker.strip(label).toLowerCase(Locale.ROOT);
|
||||
for (Map.Entry<String, FleetConfig.Leader> e : leaders.entrySet()) {
|
||||
String tab = e.getValue().tabLabel();
|
||||
if (tab != null && l.equalsIgnoreCase(tab.strip())) {
|
||||
FleetConfig.Leader lead = e.getValue();
|
||||
if (lead == null || !lead.workspace().equalsIgnoreCase(space)) {
|
||||
continue;
|
||||
}
|
||||
if (lead.acceptedLabels().contains(l)) {
|
||||
return e.getKey();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ class FleetdAssemblyTurnRegistrarBehaviouralTest {
|
||||
primary:
|
||||
tab: "lead: primary"
|
||||
profile: sonnet
|
||||
workspace: "ltms"
|
||||
profiles:
|
||||
sonnet:
|
||||
subscription: true
|
||||
|
||||
@@ -171,6 +171,7 @@ class FleetdLeadContextSourceWindowAssemblyTest {
|
||||
%s:
|
||||
tab: "%s"
|
||||
profile: %s
|
||||
workspace: "ltms"
|
||||
profiles:
|
||||
%s:
|
||||
subscription: true
|
||||
|
||||
@@ -172,6 +172,7 @@ class FleetdLeadRolloverAssemblyTest {
|
||||
opus:
|
||||
tab: "lead: opus"
|
||||
cwd: "%s"
|
||||
workspace: "ltms"
|
||||
leadRollover:
|
||||
handoverPath: handover.md
|
||||
requireOperatorConfirm: false
|
||||
@@ -203,6 +204,7 @@ class FleetdLeadRolloverAssemblyTest {
|
||||
tab: "lead: opus"
|
||||
cwd: "%s"
|
||||
profile: opus
|
||||
workspace: "ltms"
|
||||
profiles:
|
||||
opus:
|
||||
subscription: true
|
||||
|
||||
@@ -143,6 +143,7 @@ class FleetdLeadSeatAssemblyTest {
|
||||
opus:
|
||||
tab: "lead: opus"
|
||||
profile: sonnet
|
||||
workspace: "ltms"
|
||||
profiles:
|
||||
sonnet:
|
||||
subscription: true
|
||||
|
||||
@@ -537,12 +537,13 @@ class FleetConfigTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-579: {@code tab} is the only field a lead's identity depends on now, so it is required
|
||||
* whether the entry is creatable or recognise-only — without it the entry can never be found.
|
||||
* A lead's tab label is a fixed constant, not a per-entry field, so an entry with no {@code
|
||||
* tab:} is the normal case — it is still found by that constant label in its own {@code
|
||||
* workspace}, not refused as useless.
|
||||
*/
|
||||
@Test
|
||||
void aLeadWithNoTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("useless-lead.yaml");
|
||||
void aLeadWithNoTabIsAcceptedAndFoundByTheFixedLabel(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("no-tab-lead.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
@@ -553,9 +554,9 @@ class FleetConfigTest {
|
||||
""");
|
||||
FleetConfig cfg = FleetConfig.load(f);
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
|
||||
assertTrue(e.getMessage().contains("ghost"), "the message must name the useless entry");
|
||||
assertTrue(e.getMessage().contains("tab:"), "the message must say what is missing");
|
||||
assertDoesNotThrow(cfg::validateMembers);
|
||||
assertEquals(List.of(FleetConfig.Leader.LEAD_TAB_LABEL),
|
||||
cfg.fleet().leaders().get("ghost").acceptedLabels());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -775,22 +776,45 @@ class FleetConfigTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* fleetd #677: identity is matched on a lead's exact {@code tab} alone, so two leads sharing
|
||||
* one tab means only one of them is ever found — the guard must catch this independently of
|
||||
* the member-template checks above.
|
||||
* A lead's tab label is fixed, so two leads sharing one {@code workspace} would both resolve
|
||||
* to the one tab named {@code lead} there — the guard must catch this independently of the
|
||||
* member-template checks above.
|
||||
*/
|
||||
@Test
|
||||
void twoLeadsSharingTheSameExactTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("shared-tab.yaml");
|
||||
void twoLeadersSharingTheSameWorkspaceRefuseToStart(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("shared-workspace.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
fleet:
|
||||
leaders:
|
||||
opus:
|
||||
tab: "shared tab"
|
||||
workspace: "shared"
|
||||
sonnet:
|
||||
tab: "shared tab"
|
||||
workspace: "shared"
|
||||
""");
|
||||
FleetConfig cfg = FleetConfig.load(f);
|
||||
|
||||
IllegalStateException e =
|
||||
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||
assertTrue(e.getMessage().contains("opus"), "the message must name one offending lead");
|
||||
assertTrue(e.getMessage().contains("sonnet"), "the message must name the other offending lead");
|
||||
assertTrue(e.getMessage().contains("shared"), "the message must name the shared workspace");
|
||||
}
|
||||
|
||||
/** The workspace collision check is case-insensitive, matching how spaces are looked up. */
|
||||
@Test
|
||||
void twoLeadersSharingTheSameWorkspaceInDifferentCaseRefuseToStart(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("shared-workspace-case.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
fleet:
|
||||
leaders:
|
||||
opus:
|
||||
workspace: "Shared"
|
||||
sonnet:
|
||||
workspace: "shared"
|
||||
""");
|
||||
FleetConfig cfg = FleetConfig.load(f);
|
||||
|
||||
@@ -800,52 +824,77 @@ class FleetConfigTest {
|
||||
assertTrue(e.getMessage().contains("sonnet"), "the message must name the other offending lead");
|
||||
}
|
||||
|
||||
/**
|
||||
* fleetd #693: the guard matches tabs case-insensitively, because
|
||||
* {@code LeadTabScanner} keys its tab map on a lowercased label — two tabs differing only in
|
||||
* case collide there too, and the guard must catch that independently of the exact-match case
|
||||
* above.
|
||||
*/
|
||||
/** Control for the two tests above: distinct workspaces load cleanly, with no {@code tab:} at all. */
|
||||
@Test
|
||||
void twoLeadsSharingTheSameTabInDifferentCaseRefusesToStart(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("shared-tab-case.yaml");
|
||||
void twoLeadersWithDistinctWorkspacesAndNoTabAreAllowed(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("distinct-workspaces.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
fleet:
|
||||
leaders:
|
||||
opus:
|
||||
tab: "Shared Tab"
|
||||
workspace: "space-opus"
|
||||
sonnet:
|
||||
tab: "shared tab"
|
||||
""");
|
||||
FleetConfig cfg = FleetConfig.load(f);
|
||||
|
||||
IllegalStateException e =
|
||||
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||
assertTrue(e.getMessage().contains("opus"), "the message must name one offending lead");
|
||||
assertTrue(e.getMessage().contains("sonnet"), "the message must name the other offending lead");
|
||||
}
|
||||
|
||||
/** Control for {@link #twoLeadsSharingTheSameExactTabRefusesToStart}: distinct tabs load cleanly. */
|
||||
@Test
|
||||
void twoLeadsWithDistinctExactTabsAreAllowed(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("distinct-tabs.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
fleet:
|
||||
leaders:
|
||||
opus:
|
||||
tab: "opus tab"
|
||||
sonnet:
|
||||
tab: "sonnet tab"
|
||||
workspace: "space-sonnet"
|
||||
""");
|
||||
FleetConfig cfg = FleetConfig.load(f);
|
||||
|
||||
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
|
||||
}
|
||||
|
||||
/**
|
||||
* A member tab-label template that can render exactly as the fixed lead tab label would let a
|
||||
* member's own tab be read back as a lead — refused outright, with no lead needing to be
|
||||
* configured at all.
|
||||
*/
|
||||
@Test
|
||||
void aFleetTabLabelTemplateThatCanRenderAsTheFixedLeadTabLabelRefusesToStart(@TempDir Path dir)
|
||||
throws Exception {
|
||||
Path f = dir.resolve("template-renders-as-lead.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
fleet:
|
||||
tabLabel: "lead"
|
||||
leaders:
|
||||
opus:
|
||||
workspace: "fleet"
|
||||
""");
|
||||
FleetConfig cfg = FleetConfig.load(f);
|
||||
|
||||
IllegalStateException e =
|
||||
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||
assertTrue(e.getMessage().contains("fleet.tabLabel"),
|
||||
"the message must name the offending template");
|
||||
assertTrue(e.getMessage().contains(FleetConfig.Leader.LEAD_TAB_LABEL),
|
||||
"the message must name the fixed lead tab label it collides with");
|
||||
}
|
||||
|
||||
/**
|
||||
* A collaborator's {@code tab} equal to the fixed lead tab label would shadow a lead sharing
|
||||
* that space — refused outright.
|
||||
*/
|
||||
@Test
|
||||
void aCollaboratorTabEqualToTheFixedLeadTabLabelRefusesToStart(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("collaborator-is-lead.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
fleet:
|
||||
collaborators:
|
||||
impostor:
|
||||
tab: "lead"
|
||||
""");
|
||||
FleetConfig cfg = FleetConfig.load(f);
|
||||
|
||||
IllegalStateException e =
|
||||
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||
assertTrue(e.getMessage().contains("impostor"), "the message must name the offending collaborator");
|
||||
assertTrue(e.getMessage().contains(FleetConfig.Leader.LEAD_TAB_LABEL),
|
||||
"the message must name the fixed lead tab label it collides with");
|
||||
}
|
||||
|
||||
// ── validatePanePlacementAgainstLeadTabs ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -158,15 +158,24 @@ class LeadTabScannerTest {
|
||||
return Map.of("lead: opus-5.0", "opus-5.0", "lead: gpt-sol-5.6", "gpt-sol-5.6");
|
||||
}
|
||||
|
||||
/** {@code twoLeads()}'s own space — every lead-label fixture below lives here unless noted. */
|
||||
private static final String MAIN_SPACE = "main";
|
||||
|
||||
/** Wraps a flat label → name map under one space, the shape {@link LeadTabScanner} now takes. */
|
||||
private static Map<String, Map<String, String>> inSpace(String space, Map<String, String> labelToName) {
|
||||
return Map.of(space, labelToName);
|
||||
}
|
||||
|
||||
private LeadTabScanner scanner(TopologyHerdr herdr, Map<String, String> tabToName,
|
||||
AtomicLong clock) {
|
||||
return new LeadTabScanner(herdr, tabToName, Set.of("fleetd-workers"), TTL, clock::get);
|
||||
return new LeadTabScanner(herdr, inSpace(MAIN_SPACE, tabToName), Set.of("fleetd-workers"),
|
||||
TTL, clock::get);
|
||||
}
|
||||
|
||||
private LeadTabScanner scannerWithCollaborators(TopologyHerdr herdr, Map<String, String> tabToName,
|
||||
Map<String, String> collaboratorTabToName,
|
||||
AtomicLong clock) {
|
||||
return new LeadTabScanner(herdr, tabToName, collaboratorTabToName,
|
||||
return new LeadTabScanner(herdr, inSpace(MAIN_SPACE, tabToName), collaboratorTabToName,
|
||||
Set.of("fleetd-workers"), TTL, clock::get);
|
||||
}
|
||||
|
||||
@@ -237,14 +246,61 @@ class LeadTabScannerTest {
|
||||
.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);
|
||||
LeadTabScanner s = new LeadTabScanner(herdr,
|
||||
inSpace("fleet", 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");
|
||||
}
|
||||
|
||||
// ── fleetd #770: space is the uniqueness boundary, not the label alone ──────────────────────
|
||||
|
||||
/**
|
||||
* Two leads can share the exact same label (the fixed {@code lead} tab label) as long as they
|
||||
* sit in different spaces — each tab resolves to its own space's lead, never the other one's.
|
||||
*/
|
||||
@Test
|
||||
void aTabLabelledLeadResolvesToItsOwnSpacesLeadNotTheOtherSpaces() {
|
||||
TopologyHerdr herdr = new TopologyHerdr()
|
||||
.workspace("wa", "space-a")
|
||||
.workspace("wb", "space-b")
|
||||
.tab("wa:t1", "wa", "lead")
|
||||
.tab("wb:t1", "wb", "lead")
|
||||
.pane("wa:p1", "wa:t1", "term_a")
|
||||
.pane("wb:p1", "wb:t1", "term_b");
|
||||
Map<String, Map<String, String>> leadLabelsBySpace = Map.of(
|
||||
"space-a", Map.of("lead", "alpha"),
|
||||
"space-b", Map.of("lead", "beta"));
|
||||
LeadTabScanner s = new LeadTabScanner(herdr, leadLabelsBySpace, Set.of(), TTL,
|
||||
new AtomicLong()::get);
|
||||
|
||||
Map<String, String> leads = s.get();
|
||||
assertEquals("alpha", leads.get("term_a"), "space-a's tab must resolve to space-a's lead");
|
||||
assertEquals("beta", leads.get("term_b"), "space-b's tab must resolve to space-b's lead");
|
||||
}
|
||||
|
||||
/**
|
||||
* A lead's deprecated legacy {@code tab:} label is still matched, but only within that lead's
|
||||
* own configured space — exactly the shape {@code FleetdAssembly} builds via {@code
|
||||
* Leader.acceptedLabels()}.
|
||||
*/
|
||||
@Test
|
||||
void aLegacyTabLabelStillResolvesWithinItsOwnSpace() {
|
||||
TopologyHerdr herdr = new TopologyHerdr()
|
||||
.workspace("w1", "fleet")
|
||||
.tab("w1:t1", "w1", "lead: opus")
|
||||
.pane("w1:p1", "w1:t1", "term_opus");
|
||||
Map<String, Map<String, String>> leadLabelsBySpace =
|
||||
Map.of("fleet", Map.of("lead", "opus", "lead: opus", "opus"));
|
||||
LeadTabScanner s = new LeadTabScanner(herdr, leadLabelsBySpace, Set.of(), TTL,
|
||||
new AtomicLong()::get);
|
||||
|
||||
assertEquals("opus", s.get().get("term_opus"),
|
||||
"the deprecated tab label must still resolve this lead within its own space");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLabelWithNoConfiguredEntryIsIgnored() {
|
||||
TopologyHerdr herdr = new TopologyHerdr().workspace("w1", "main")
|
||||
|
||||
@@ -114,13 +114,13 @@ class LeadLauncherTest {
|
||||
"name is lead-<name>-<nonce>-<seq>: " + startedName(herdr));
|
||||
}
|
||||
|
||||
/** The tab is labelled with the configured `tab:` so the scanner finds the lead on the next resolve. */
|
||||
/** The tab is labelled with the fixed lead tab label so the scanner finds the lead on the next resolve. */
|
||||
@Test
|
||||
void labelsTheTabWithTheConfiguredTabValue() {
|
||||
void labelsTheTabWithTheFixedLeadTabLabel() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
launcher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads();
|
||||
|
||||
assertEquals("lead: opus",
|
||||
assertEquals("lead",
|
||||
((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"));
|
||||
}
|
||||
|
||||
@@ -148,6 +148,25 @@ class LeadLauncherTest {
|
||||
assertFalse(herdr.called("tab.close"), "a labelled tab WITH a live agent must never be closed");
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab a live lead actually sits in still carries its deprecated legacy {@code tab:} label,
|
||||
* not the fixed {@code lead} tab label a freshly auto-launched instance would get. Counting must
|
||||
* still recognise it as the live lead via {@link FleetConfig.Leader#acceptedLabels()}, or a
|
||||
* daemon restart would read it as missing and launch a second orchestrator next to the first.
|
||||
*/
|
||||
@Test
|
||||
void aLiveLeadInALegacyLabelledTabIsCountedSoNothingIsLaunched() {
|
||||
FakeHerdr herdr = new FakeHerdr()
|
||||
.withWorkspace("wL", "fleet")
|
||||
.withTab("wL", "wL:t1", "lead: opus")
|
||||
.withAgent("lead-opus", "term_lead", "wL:p1", "wL:t1");
|
||||
|
||||
assertEquals(0, launcher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads(),
|
||||
"the legacy-labelled live lead must be counted — nothing may be launched");
|
||||
assertFalse(herdr.called("agent.start"),
|
||||
"a tab label fixed to a constant must not blind the count to a legacy-labelled lead");
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason liveness is not "does the label exist". A tab left labelled by a session that has
|
||||
* since died must not block the relaunch, or one crash disables auto-launch permanently.
|
||||
@@ -263,7 +282,7 @@ class LeadLauncherTest {
|
||||
assertFalse(herdr.called("tab.close"), "a tab running an agent again must never be closed");
|
||||
assertFalse(herdr.called("agent.start"), "the lead is live again — nothing to relaunch");
|
||||
assertEquals("wL:t1", ((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("tab_id"));
|
||||
assertEquals("lead: opus", ((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"),
|
||||
assertEquals("lead", ((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"),
|
||||
"the pending-close flag must be cleared once the tab is confirmed live again");
|
||||
}
|
||||
|
||||
@@ -285,7 +304,7 @@ class LeadLauncherTest {
|
||||
@Test
|
||||
void aHandOpenedLeadWithTheConfiguredTabLabelCountsAsLive() {
|
||||
FakeHerdr herdr = new FakeHerdr()
|
||||
.withWorkspace("wX", "main")
|
||||
.withWorkspace("wX", "fleet")
|
||||
.withTab("wX", "wX:t1", "lead: opus")
|
||||
.withAgent("hand-opened", "term_hand", "wX:p1", "wX:t1");
|
||||
|
||||
@@ -597,7 +616,7 @@ class LeadLauncherTest {
|
||||
"terminalId() must be herdr's own generated id: " + started.terminalId());
|
||||
}
|
||||
|
||||
/** The new tab is labelled with the lead's configured {@code tab:}, and AFTER the start. */
|
||||
/** The new tab is labelled with the fixed lead tab label, and AFTER the start. */
|
||||
@Test
|
||||
void relaunchLabelsTheNewTabAfterStarting() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
@@ -606,7 +625,7 @@ class LeadLauncherTest {
|
||||
launcher(herdr, configWith(lead("opus", "lead: opus", 1))).relaunch("opus");
|
||||
|
||||
assertNotNull(started);
|
||||
assertEquals("lead: opus", ((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"));
|
||||
assertEquals("lead", ((Map<?, ?>) herdr.lastCall("tab.rename").params()).get("label"));
|
||||
|
||||
int startIndex = indexOfLastCall(herdr, "agent.start");
|
||||
int renameIndex = indexOfLastCall(herdr, "tab.rename");
|
||||
|
||||
Reference in New Issue
Block a user