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 ────────────────────────────────────────────────────────