From be6c45ff78f88edf2a978131f5f47850132ad744 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Tue, 22 Sep 2026 10:11:44 +0700 Subject: [PATCH] CB-617 review: warn instead of refuse on conflicting autoCompactWindow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rejectConflictingAutoCompactWindows threw and stopped fleetd from starting when a Claude Code profile's autoCompactWindow flag and CLAUDE_CODE_AUTO_COMPACT_WINDOW env var disagreed. Under launchd that is a restart loop, and the config that would fix it (fleetd.yaml) is gitignored, so the cause is invisible on the host where it bites (measured live: 4 profiles on this host trip it, including the lead's own profile and the one every worker spawns on). Renamed to warnConflictingAutoCompactWindows: it now logs a WARN naming each offending profile with BOTH values (autoCompactWindow=... and env.CLAUDE_CODE_AUTO_COMPACT_WINDOW=...) instead of throwing, so the daemon starts and an operator can fix the config without reading the source. Equal values still load silently. Also reworded ClaudeCodeArguments' javadoc, which stated as fact that the env var takes precedence over the flag. That was never measured, and this host's own fleetd.yaml comment asserts the opposite — the javadoc no longer picks a side. --- .../dev/ltms/fleet/config/FleetConfig.java | 53 ++++++++++++++----- .../fleet/launch/ClaudeCodeArguments.java | 12 +++-- .../ltms/fleet/config/FleetConfigTest.java | 51 +++++++++++++++--- 3 files changed, 90 insertions(+), 26 deletions(-) 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 ────────────────────────────────────────────────────────