fleetd #811: move the repair interval to a fleet-wide key, clean up comments and logging
CI / shell-tests (pull_request) Failing after 9s
CI / contract (pull_request) Successful in 1m1s
CI / build (pull_request) Failing after 1m57s

- repairIntervalSeconds moves off the per-lead Leader record onto the
  top-level Fleet record: the repair loop covers every lead in one pass,
  so a per-lead value only ever honored an arbitrary map entry.
- drop ticket references from LeadLabelRepairLoop's javadoc and the
  FleetdAssembly wiring comment; trim the latter to the maintainer-facing
  constraint and drop the loop-count ordinal.
- log.warn in the tick's catch now passes the exception object instead of
  only its message, so a stack trace survives an unexpected failure.
- document that the repair's lead set and interval are fixed at boot.
This commit is contained in:
Dai Ha
2026-10-07 11:36:38 +02:00
parent 4a88406bfd
commit fb80e64131
10 changed files with 64 additions and 32 deletions
+5 -2
View File
@@ -622,6 +622,11 @@ fleet:
# comes from a closed enum, a generated label can never begin with a lead's tabPrefix.
# tabLabel: "{role}: {profile} #{n}"
# How often a launched lead's tab label is re-asserted against whatever Claude Code last
# retitled it to. Fleet-wide: one recurring pass covers every lead below, so this is not a
# per-lead field.
# repairIntervalSeconds: 30
# Panes that orchestrate rather than are orchestrated. A lead may now be CREATED as well as
# recognised: give it a `profile:` and the daemon launches the shortfall when fewer than
# `instances` are live. Omit `profile:` and it is recognise-only, as before.
@@ -671,8 +676,6 @@ 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
# repairIntervalSeconds: 30 # how often a launched lead's tab label is re-asserted against
# # whatever Claude Code last retitled it to
# 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
@@ -565,15 +565,12 @@ final class FleetdAssembly {
configWatcher = null;
}
// fleetd #811: re-asserts a lead's tab label against the tab LeadLauncher launched it in.
// ensureLeads() above calls the same repair once, at boot, but launchedTabByLead is only
// ever filled by a launch that happens later in that same pass — so without this loop the
// repair never actually runs. SEVENTH of the recurring background loops to start (optional
// in the sense that an empty fleet.leaders gives it nothing to repair).
// This loop is what makes the label repair reachable: ensureLeads() runs it once, at boot,
// but only this recurring call drives it afterward.
var leadLabelRepairScheduler = ports.newScheduler("bridge-lead-repair-");
final LeadLabelRepairLoop leadLabelRepair;
if (!leaders.isEmpty()) {
long repairIntervalSeconds = leaders.values().iterator().next().repairIntervalSeconds();
long repairIntervalSeconds = cfg.fleet().repairIntervalSeconds();
leadLabelRepair = new LeadLabelRepairLoop(() -> leadLauncher.reassertLeadLabels(leaders),
leadLabelRepairScheduler, repairIntervalSeconds);
leadLabelRepair.start();
@@ -141,7 +141,7 @@ final class FleetdRuntime implements AutoCloseable {
if (leadCoordScheduler != null) leadCoordScheduler.shutdownNow();
if (healthMonitor != null) healthMonitor.stop();
if (configWatcher != null) configWatcher.stop(); // CB-559: stop polling the config file
if (leadLabelRepair != null) leadLabelRepair.stop(); // fleetd #811: stop the label repair loop
if (leadLabelRepair != null) leadLabelRepair.stop(); // stop the label repair loop
mcp.close();
if (reaper != null) reaper.stop();
// Idle-sleep guard: release unconditionally, even though sessions.close() above already
@@ -1160,13 +1160,11 @@ public record FleetConfig(
* @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}
* @param repairIntervalSeconds how often the daemon re-asserts this lead's tab label against
* the tab it launched. Default 30
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Leader(String profile, String tab, Integer instances, String tabPrefix,
Integer scanIntervalSeconds, String kind, String model,
String workspace, String cwd, Integer repairIntervalSeconds) {
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";
@@ -1187,14 +1185,12 @@ public record FleetConfig(
workspace = (workspace == null || workspace.isBlank())
? DEFAULT_WORKSPACE : workspace.strip();
tab = (tab == null || tab.isBlank()) ? null : tab.strip();
repairIntervalSeconds =
(repairIntervalSeconds == null || repairIntervalSeconds <= 0) ? 30 : repairIntervalSeconds;
}
/** Back-compat 7-arg form — no workspace, cwd or repairIntervalSeconds, so each takes its default. */
/** Back-compat 7-arg form — no workspace or cwd, so each takes its default. */
public Leader(String profile, String tab, Integer instances, String tabPrefix,
Integer scanIntervalSeconds, String kind, String model) {
this(profile, tab, instances, tabPrefix, scanIntervalSeconds, kind, model, null, null, null);
this(profile, tab, instances, tabPrefix, scanIntervalSeconds, kind, model, null, null);
}
/** True when this lead may be launched by the daemon rather than only recognised. */
@@ -1290,6 +1286,8 @@ public record FleetConfig(
* @param collaborators tabs fleetd recognises as collaborators (fleetd #669), keyed by name.
* Recognise-only, exactly like a {@code profile}-less {@link Leader}:
* nothing here is ever auto-launched.
* @param repairIntervalSeconds how often a lead's tab label is re-asserted against the tab it
* was launched in — fleet-wide, since one pass covers every lead. Default 30
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Fleet(Map<String, Leader> leaders,
@@ -1299,7 +1297,8 @@ public record FleetConfig(
Map<String, Slot> reviewers,
Map<String, String> charters,
String tabLabel,
Map<String, Collaborator> collaborators) {
Map<String, Collaborator> collaborators,
Integer repairIntervalSeconds) {
/**
* Role first, so the tab bar identifies the member's fleet role.
@@ -1315,6 +1314,8 @@ public record FleetConfig(
charters = unmodifiableOrEmpty(charters);
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel;
collaborators = unmodifiableOrEmpty(collaborators);
repairIntervalSeconds =
(repairIntervalSeconds == null || repairIntervalSeconds <= 0) ? 30 : repairIntervalSeconds;
}
/**
@@ -1332,12 +1333,12 @@ public record FleetConfig(
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
Map<String, Slot> developers, Map<String, Slot> reviewers,
Map<String, String> charters, String tabLabel) {
this(leaders, architects, developers, null, reviewers, charters, tabLabel, null);
this(leaders, architects, developers, null, reviewers, charters, tabLabel, null, null);
}
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
Map<String, Slot> developers, Map<String, Slot> reviewers, String tabLabel) {
this(leaders, architects, developers, null, reviewers, null, tabLabel, null);
this(leaders, architects, developers, null, reviewers, null, tabLabel, null, null);
}
/**
@@ -7,9 +7,12 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Drives {@link LeadLauncher#reassertLeadLabels} on a recurring schedule (fleetd #811) — the
* only caller that makes the repair reachable, since {@link LeadLauncher#ensureLeads()} runs
* once, at boot, before any lead has a remembered tab id to repair.
* Drives {@link LeadLauncher#reassertLeadLabels} on a recurring schedule — the only caller that
* makes the repair reachable, since {@link LeadLauncher#ensureLeads()} runs once, at boot,
* before any lead has a remembered tab id to repair.
*
* <p>The repair action passed to the constructor, and the interval it runs at, are fixed for
* this loop's lifetime: neither changes if the underlying config is reloaded afterward.
*/
public final class LeadLabelRepairLoop {
@@ -46,7 +49,7 @@ public final class LeadLabelRepairLoop {
try {
repair.run();
} catch (Throwable e) {
log.warn("lead label repair tick failed, still scheduled: {}", e.getMessage());
log.warn("lead label repair tick failed, still scheduled", e);
}
}
@@ -95,7 +95,7 @@ class ConfigRefTopLevelReportingCoverageTest {
v.put("primary", new FleetConfig.Primary("term-a", 1, 1000));
v.put("fleet", new FleetConfig.Fleet(
Map.of("opus", new FleetConfig.Leader("sonnet", "lead: opus-a", 1, null, 10,
"claude", null, null, null, null)),
"claude", null, null, null)),
Map.of(), Map.of(), Map.of(), Map.of(), "{role}: {profile} #{n}"));
v.put("leadHeartbeat", new FleetConfig.LeadHeartbeat(300, 60_000L, 3));
v.put("health", new FleetConfig.Health(true, 30, 600, null, null));
@@ -143,7 +143,7 @@ class ConfigRefTopLevelReportingCoverageTest {
// test — that is by design, not a gap: this map exists to prove fleet.leaders is covered.
v.put("fleet", new FleetConfig.Fleet(
Map.of("opus", new FleetConfig.Leader("sonnet", "lead: opus-b", 1, null, 10,
"claude", null, null, null, null)),
"claude", null, null, null)),
Map.of(), Map.of(), Map.of(), Map.of(), "{role}: {profile} #{n}"));
v.put("leadHeartbeat", new FleetConfig.LeadHeartbeat(600, 120_000L, 5));
v.put("health", new FleetConfig.Health(false, 90, 900, null, null));
@@ -485,8 +485,6 @@ class FleetConfigTest {
assertEquals("lead:", lead.tabPrefix());
assertEquals(10, lead.scanIntervalSeconds());
assertEquals(1, lead.instances(), "one of a lead is the assumption worth defaulting to");
assertEquals(30, lead.repairIntervalSeconds(),
"no fleetd.yaml edit is required for the label repair loop to work");
}
@Test
@@ -501,13 +499,43 @@ class FleetConfigTest {
tab: "drive: opus"
tabPrefix: "drive:"
scanIntervalSeconds: 30
repairIntervalSeconds: 45
""");
FleetConfig.Leader lead = FleetConfig.load(f).fleet().leaders().get("opus");
assertEquals("drive:", lead.tabPrefix());
assertEquals(30, lead.scanIntervalSeconds());
assertEquals(45, lead.repairIntervalSeconds());
}
@Test
void fleetDefaultsItsLabelRepairIntervalWhenAbsent(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-repair-interval.yaml");
Files.writeString(f, """
bind:
port: 8080
fleet:
leaders:
opus:
tab: "lead: opus"
""");
assertEquals(30, FleetConfig.load(f).fleet().repairIntervalSeconds(),
"no fleetd.yaml edit is required for the label repair loop to work");
}
@Test
void fleetReadsAnExplicitLabelRepairInterval(@TempDir Path dir) throws Exception {
Path f = dir.resolve("repair-interval.yaml");
Files.writeString(f, """
bind:
port: 8080
fleet:
repairIntervalSeconds: 45
leaders:
opus:
tab: "lead: opus"
""");
assertEquals(45, FleetConfig.load(f).fleet().repairIntervalSeconds());
}
/**
@@ -81,7 +81,7 @@ class FleetConfigWithDefaultsPreservesEveryComponentTest {
v.put("primary", new FleetConfig.Primary("term-guard", 4, 4000));
v.put("fleet", new FleetConfig.Fleet(
Map.of("opus", new FleetConfig.Leader("sonnet", "lead: opus-guard", 1, null, 10,
"claude", null, null, null, null)),
"claude", null, null, null)),
Map.of(), Map.of(), Map.of(), Map.of(), "{role}: {profile} #{n}"));
v.put("leadHeartbeat", new FleetConfig.LeadHeartbeat(301, 61_000L, 4));
v.put("health", new FleetConfig.Health(true, 31, 601, 61, null));
@@ -63,7 +63,7 @@ class LeadLauncherTest {
private static FleetConfig.Leader lead(String profile, String tab, int instances) {
return new FleetConfig.Leader(profile, tab, instances, "lead:", 10, null, null,
"fleet", "/repo", null);
"fleet", "/repo");
}
private static LeadLauncher launcher(FakeHerdr herdr, FleetConfig cfg) {
@@ -114,7 +114,7 @@ class LeadRolloverTest {
null, null, null,
Map.of(), null, null, true, null);
FleetConfig.Leader leader = new FleetConfig.Leader("opus", "lead: opus", 1, "lead:", 10,
null, null, "fleet", null, null);
null, null, "fleet", null);
Map<String, FleetConfig.Leader> leaders = new LinkedHashMap<>();
leaders.put(LEAD_NAME, leader);
FleetConfig.Fleet fleet = new FleetConfig.Fleet(leaders, Map.of(), Map.of(), Map.of(), Map.of(), null);