diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java
index 5455d53..94f4916 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java
@@ -1875,7 +1875,7 @@ public record FleetConfig(
rejectDuplicateMemberSlots(yaml);
rejectNegativeMaxLoad(yaml);
rejectAutoCompactWindowOutOfRange(yaml);
- rejectConflictingAutoCompactWindows(yaml);
+ warnConflictingAutoCompactWindows(yaml);
rejectMalformedProfilePatterns(yaml);
rejectUnknownKind(yaml);
rejectUnknownAuthMode(yaml);
@@ -2227,13 +2227,29 @@ public record FleetConfig(
}
/**
- * Reject a Claude Code profile when its auto-compaction flag and environment setting disagree.
+ * Warn (never refuse to start) about a Claude Code profile whose auto-compaction flag and
+ * environment setting disagree.
*
- *
Claude Code gives {@code CLAUDE_CODE_AUTO_COMPACT_WINDOW} priority over
- * {@code --autocompact}. A disagreement would therefore make the configured flag misleading.
- * Equal values remain valid because either input produces the same session window.
+ *
Renamed from {@code rejectConflictingAutoCompactWindows} (fleetd #601 review, measured
+ * 2026-09-22): that method threw {@link IllegalStateException}, so {@link #load(Path)} refused
+ * to start on a config carrying this conflict. On this host, four profiles trip it, including
+ * the lead's own profile and the one every worker spawns on — so the throw is not a rare edge
+ * case. Under launchd, a throw inside {@code load()} is a restart loop, not an error an operator
+ * reads once, and the config that would fix it ({@code fleetd/fleetd.yaml}) is gitignored, so
+ * the cause is invisible on the host where it bites. A WARN gives the operator the same
+ * information — which profiles, and now both values, so they can fix it without reading the
+ * source — without ever taking the fleet down.
+ *
+ *
Which of the two inputs Claude Code actually follows when they disagree is intentionally
+ * not asserted here. {@code ClaudeCodeArguments}'s javadoc used to state the
+ * environment variable always wins; nobody had measured that, and this host's own
+ * {@code fleetd.yaml} asserts the opposite in a comment. This method only detects and reports
+ * the disagreement — see {@link dev.ltms.fleet.launch.ClaudeCodeArguments}.
+ *
+ *
Equal values never warn: either input then produces the same session window, so there is
+ * nothing to reconcile.
*/
- static void rejectConflictingAutoCompactWindows(String yaml) {
+ static void warnConflictingAutoCompactWindows(String yaml) {
Map, ?> raw;
try {
raw = YAML.readValue(yaml, Map.class);
@@ -2243,7 +2259,8 @@ public record FleetConfig(
if (raw == null || !(raw.get("profiles") instanceof Map, ?> profiles)) {
return;
}
- List bad = new ArrayList<>();
+ List names = new ArrayList<>();
+ List detail = new ArrayList<>();
for (Map.Entry, ?> entry : profiles.entrySet()) {
if (!(entry.getValue() instanceof Map, ?> profile)
|| !(profile.get("autoCompactWindow") instanceof Number window)
@@ -2254,16 +2271,24 @@ public record FleetConfig(
Object kind = profile.get("kind");
boolean claudeCode = kind == null || String.valueOf(kind).isBlank()
|| Profile.KIND_CLAUDE_CODE.equalsIgnoreCase(String.valueOf(kind));
- if (claudeCode && !String.valueOf(window).equals(String.valueOf(env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW")))) {
- bad.add(String.valueOf(entry.getKey()));
+ Object envValue = env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW");
+ if (claudeCode && !String.valueOf(window).equals(String.valueOf(envValue))) {
+ String name = String.valueOf(entry.getKey());
+ names.add(name);
+ detail.add(name + " (autoCompactWindow=" + window
+ + ", env.CLAUDE_CODE_AUTO_COMPACT_WINDOW=" + envValue + ")");
}
}
- bad.sort(String::compareTo);
- if (!bad.isEmpty()) {
- throw new IllegalStateException("refusing to start: Claude Code profile(s) ["
- + String.join(", ", bad) + "] set disagreeing autoCompactWindow and env."
- + "CLAUDE_CODE_AUTO_COMPACT_WINDOW; remove one key or set equal values.");
+ if (names.isEmpty()) {
+ return;
}
+ names.sort(String::compareTo);
+ detail.sort(String::compareTo);
+ log.warn("Claude Code profile(s) {} set disagreeing autoCompactWindow and env."
+ + "CLAUDE_CODE_AUTO_COMPACT_WINDOW — the daemon starts anyway. Fix by "
+ + "removing one key or setting equal values on each: {}. Which input Claude "
+ + "Code actually follows when they disagree is not verified here.",
+ names, String.join(", ", detail));
}
/**
diff --git a/fleetd/src/main/java/dev/ltms/fleet/launch/ClaudeCodeArguments.java b/fleetd/src/main/java/dev/ltms/fleet/launch/ClaudeCodeArguments.java
index 01de599..ce2e214 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/launch/ClaudeCodeArguments.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/launch/ClaudeCodeArguments.java
@@ -14,10 +14,14 @@ public final class ClaudeCodeArguments {
/**
* Append the configured Claude Code auto-compaction window when the profile opts in.
*
- * The environment variable {@code CLAUDE_CODE_AUTO_COMPACT_WINDOW} takes precedence over
- * this flag and saved settings. {@link FleetConfig#load(java.nio.file.Path)} rejects a Claude
- * profile when this flag's value disagrees with that environment entry, so a launched session
- * cannot silently use a different window.
+ *
This flag and the environment variable {@code CLAUDE_CODE_AUTO_COMPACT_WINDOW} can
+ * disagree. Which one Claude Code actually follows when they do is NOT verified here — this
+ * javadoc used to claim the environment variable always wins, but nobody had measured that, and
+ * this host's own {@code fleetd.yaml} asserts the opposite in a comment. So this javadoc no
+ * longer picks a side. {@link FleetConfig#load(java.nio.file.Path)} only WARNS when a Claude
+ * Code profile sets both to different values (see {@code
+ * FleetConfig.warnConflictingAutoCompactWindows}) — it does not stop the daemon from starting,
+ * and a launched session may end up honouring either window.
*/
public static List withAutoCompactWindow(List argv, FleetConfig.Profile profile) {
if (profile.autoCompactWindow() == null) {
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
index 1f74377..ada9a71 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
@@ -1,8 +1,11 @@
package dev.ltms.fleet.config;
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.spi.ILoggingEvent;
import dev.ltms.fleet.auth.MemberRegistry;
import dev.ltms.fleet.msg.LeadMailbox;
import dev.ltms.fleet.peer.MemberRole;
+import dev.ltms.fleet.testing.CapturedLog;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -93,9 +96,19 @@ class FleetConfigTest {
"unset means off — today's behaviour, unchanged");
}
+ /**
+ * fleetd #601 review (measured 2026-09-22): this guard used to throw {@link
+ * IllegalStateException} and refuse to start. On a host with the conflict configured, that
+ * turned into a launchd restart loop with no readable cause, since {@code fleetd.yaml} is
+ * gitignored. It must now WARN and let the daemon start, and the warning must carry both values
+ * so an operator can fix the config without reading the source. Pinning the log line itself (via
+ * {@link CapturedLog}) rather than a snippet of production source text — the latter is the
+ * anti-pattern this repo avoids; the former is the actual observable behaviour a reader (or an
+ * alert on the log) depends on.
+ */
@Test
- void aClaudeProfileWithConflictingAutoCompactFlagAndEnvironmentWindowIsRejected(@TempDir Path dir)
- throws Exception {
+ void aClaudeProfileWithConflictingAutoCompactFlagAndEnvironmentWindowLoadsAndWarnsWithBothValues(
+ @TempDir Path dir) throws Exception {
Path f = dir.resolve("conflicting-auto-compact-window.yaml");
Files.writeString(f, """
profiles:
@@ -105,15 +118,29 @@ class FleetConfigTest {
CLAUDE_CODE_AUTO_COMPACT_WINDOW: "300000"
""");
- IllegalStateException e = assertThrows(IllegalStateException.class, () -> FleetConfig.load(f));
+ FleetConfig cfg;
+ List warnings;
+ try (CapturedLog log = CapturedLog.at(FleetConfig.class, Level.WARN)) {
+ cfg = FleetConfig.load(f);
+ warnings = log.events().stream().map(ILoggingEvent::getFormattedMessage).toList();
+ }
- assertTrue(e.getMessage().contains("claude-profile"));
- assertTrue(e.getMessage().contains("autoCompactWindow"));
- assertTrue(e.getMessage().contains("CLAUDE_CODE_AUTO_COMPACT_WINDOW"));
+ assertEquals(250_000, cfg.profiles().get("claude-profile").autoCompactWindow(),
+ "the disagreement is reported, not corrected — the flag value still loads as-is");
+ assertEquals(1, warnings.size(), "exactly one warning for the one conflicting profile: " + warnings);
+ String warning = warnings.get(0);
+ assertTrue(warning.contains("claude-profile"), "names the offending profile: " + warning);
+ assertTrue(warning.contains("autoCompactWindow=250000"), "names the flag value: " + warning);
+ assertTrue(warning.contains("CLAUDE_CODE_AUTO_COMPACT_WINDOW=300000"), "names the env value: " + warning);
}
+ /**
+ * The negative probe paired with the test above (per fleetd #601 review): a warning that fires
+ * on every load and a warning that never fires read the same from a single test, so both must be
+ * checked. No conflict here — the flag and the env value agree — so no warning should be logged.
+ */
@Test
- void aClaudeProfileWithEqualAutoCompactFlagAndEnvironmentWindowLoads(@TempDir Path dir)
+ void aClaudeProfileWithEqualAutoCompactFlagAndEnvironmentWindowLoadsWithNoWarning(@TempDir Path dir)
throws Exception {
Path f = dir.resolve("equal-auto-compact-window.yaml");
Files.writeString(f, """
@@ -124,7 +151,15 @@ class FleetConfigTest {
CLAUDE_CODE_AUTO_COMPACT_WINDOW: "250000"
""");
- assertEquals(250_000, FleetConfig.load(f).profiles().get("claude-profile").autoCompactWindow());
+ FleetConfig cfg;
+ List events;
+ try (CapturedLog log = CapturedLog.at(FleetConfig.class, Level.WARN)) {
+ cfg = FleetConfig.load(f);
+ events = log.events();
+ }
+
+ assertEquals(250_000, cfg.profiles().get("claude-profile").autoCompactWindow());
+ assertTrue(events.isEmpty(), "equal values must not warn: " + events);
}
// ── fleetd #201 Unit 5: errorPattern ────────────────────────────────────────────────────────