From e99cb70a8b52470ab5e89502bd3145c45a4054d3 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 19 Sep 2026 16:21:33 +0700 Subject: [PATCH 1/2] CB-617: pass auto-compact window to leads --- fleetd/fleetd.example.yaml | 10 ++--- .../dev/ltms/fleet/config/FleetConfig.java | 45 ++++++++++++++++++- .../fleet/launch/ClaudeCodeArguments.java | 31 +++++++++++++ .../dev/ltms/fleet/lead/LeadLauncher.java | 6 ++- .../ltms/fleet/member/ClaudeCodeLauncher.java | 27 +---------- .../ltms/fleet/config/FleetConfigTest.java | 34 ++++++++++++++ .../dev/ltms/fleet/lead/LeadLauncherTest.java | 45 ++++++++++++++++++- 7 files changed, 163 insertions(+), 35 deletions(-) create mode 100644 fleetd/src/main/java/dev/ltms/fleet/launch/ClaudeCodeArguments.java diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml index 41f54dd..48dc750 100644 --- a/fleetd/fleetd.example.yaml +++ b/fleetd/fleetd.example.yaml @@ -209,13 +209,13 @@ herdrSocket: ~/.config/herdr/herdr.sock # e.g. `env DISPLAY=:10.0 idea {dir}`. Best-effort: a failure is logged, never # fails the spawn. Omit to open the member's module by hand. There is no close # half yet — an opened module stays open until the operator closes it. -# autoCompactWindow → opt-in, default off. A bounded token window that forces a spawned member to -# compact its context instead of running on the backend's own default and dying -# mid-turn (losing its fleet_reply — the whole point of the turn — with it). +# autoCompactWindow → opt-in, default off. A bounded token window that forces a launched Claude Code +# lead or member to compact its context instead of running on the backend's own +# default. A member that runs out of context can die mid-turn and lose its fleet_reply. # Validated at config load to [100000, 1000000] — the band Claude Code's own # --autocompact flag accepts. # CROSS-BACKEND SEMANTICS DIFFER: on claude-code this is a launch-time -# `--autocompact ` flag — the member compacts AT this window. opencode +# `--autocompact ` flag — the Claude Code session compacts AT this window. opencode # has no equivalent flag (it only forces `compaction.auto: true`, unconditionally, # already), so this is instead applied as the model's `limit.context` in the # generated opencode.json — the member compacts WITHIN this window, not exactly @@ -415,7 +415,7 @@ profiles: # ideMcpUrl: http://127.0.0.1:29170/index-mcp/streamable-http # opt-in (CB-634): IDE code intelligence, pinned to the worktree # ideProjectDir: fleetd # CB-634: module dir the IDE opens + the overlay pins (this repo's pom is in fleetd/) # ideOpenCommand: env DISPLAY=:10.0 idea {dir} # CB-634 auto-open: opens {dir} in the IDE at spawn; omit to open by hand - # autoCompactWindow: 250000 # opt-in: bound member context; claude-code compacts AT this, opencode within it (model limit.context) + # autoCompactWindow: 250000 # opt-in: bound Claude Code lead/member context; claude-code compacts AT this, opencode within it (model limit.context) gx11: # a second backend, so `placement: weighted` has a choice baseUrl: http://gx01.gw:8000 # self-hosted; ccs handles the model + token placement: tab 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 2d25241..5455d53 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -462,8 +462,8 @@ public record FleetConfig( * profile that does not opt in. Read live off the current config, so it is * HOT: a change takes effect on the next exhaustion classification / spawn, * no restart needed. - * @param autoCompactWindow opt-in per-profile token window that forces a spawned member to - * auto-compact its context at (Claude Code) or within (opencode) a bound the + * @param autoCompactWindow opt-in per-profile token window that forces a launched Claude Code + * session to auto-compact its context at, or an opencode session within, a bound the * operator chooses, instead of the backend's own default. {@code null} (the * default) leaves today's behaviour exactly — opencode already forces * {@code compaction.auto: true} unconditionally (CB-523) but has no absolute @@ -1875,6 +1875,7 @@ public record FleetConfig( rejectDuplicateMemberSlots(yaml); rejectNegativeMaxLoad(yaml); rejectAutoCompactWindowOutOfRange(yaml); + rejectConflictingAutoCompactWindows(yaml); rejectMalformedProfilePatterns(yaml); rejectUnknownKind(yaml); rejectUnknownAuthMode(yaml); @@ -2225,6 +2226,46 @@ public record FleetConfig( } } + /** + * Reject a Claude Code profile when its 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. + */ + static void rejectConflictingAutoCompactWindows(String yaml) { + Map raw; + try { + raw = YAML.readValue(yaml, Map.class); + } catch (IOException | IllegalArgumentException e) { + return; + } + if (raw == null || !(raw.get("profiles") instanceof Map profiles)) { + return; + } + List bad = new ArrayList<>(); + for (Map.Entry entry : profiles.entrySet()) { + if (!(entry.getValue() instanceof Map profile) + || !(profile.get("autoCompactWindow") instanceof Number window) + || !(profile.get("env") instanceof Map env) + || !env.containsKey("CLAUDE_CODE_AUTO_COMPACT_WINDOW")) { + continue; + } + 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())); + } + } + 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."); + } + } + /** * Reject a profile whose {@code errorPattern} (fleetd #201 Unit 5) or {@code exhaustedPattern} * (CB-578 stage A) is not a valid Java regex, naming the profile, the key, and the parser's own diff --git a/fleetd/src/main/java/dev/ltms/fleet/launch/ClaudeCodeArguments.java b/fleetd/src/main/java/dev/ltms/fleet/launch/ClaudeCodeArguments.java new file mode 100644 index 0000000..01de599 --- /dev/null +++ b/fleetd/src/main/java/dev/ltms/fleet/launch/ClaudeCodeArguments.java @@ -0,0 +1,31 @@ +package dev.ltms.fleet.launch; + +import dev.ltms.fleet.config.FleetConfig; + +import java.util.ArrayList; +import java.util.List; + +/** Arguments shared by every fleetd path that starts Claude Code. */ +public final class ClaudeCodeArguments { + + private 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. + */ + public static List withAutoCompactWindow(List argv, FleetConfig.Profile profile) { + if (profile.autoCompactWindow() == null) { + return argv; + } + List withAutoCompact = new ArrayList<>(argv); + withAutoCompact.add("--autocompact"); + withAutoCompact.add(String.valueOf(profile.autoCompactWindow())); + return withAutoCompact; + } +} diff --git a/fleetd/src/main/java/dev/ltms/fleet/lead/LeadLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/lead/LeadLauncher.java index e322d67..05f1af4 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/lead/LeadLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/lead/LeadLauncher.java @@ -8,6 +8,7 @@ import dev.ltms.fleet.herdr.PendingCloseMarker; import dev.ltms.fleet.herdr.Tab; import dev.ltms.fleet.herdr.Workspace; import dev.ltms.fleet.herdr.WorkspaceControl; +import dev.ltms.fleet.launch.ClaudeCodeArguments; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -358,7 +359,8 @@ public final class LeadLauncher { } /** - * The lead's argv: the profile's own command, the model pin, and the bridge MCP mount. + * The lead's argv: the profile's own command, the model and auto-compaction pins, and the bridge + * MCP mount. * *

No {@code --append-system-prompt}. That flag carries the worker reply charter, and a lead * is not a worker — it reads its orchestration rules from the project's {@code CLAUDE.md} like @@ -380,7 +382,7 @@ public final class LeadLauncher { argv.add("--model"); argv.add(profile.model()); } - return argv; + return profile.isOpenCode() ? argv : ClaudeCodeArguments.withAutoCompactWindow(argv, profile); } /** diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java index 41c20d9..9df3985 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java @@ -8,6 +8,7 @@ import dev.ltms.fleet.guard.SubscriptionGuard; import dev.ltms.fleet.herdr.Agent; import dev.ltms.fleet.herdr.AgentControl; import dev.ltms.fleet.herdr.WorkspaceControl; +import dev.ltms.fleet.launch.ClaudeCodeArguments; import dev.ltms.fleet.peer.Capability; import dev.ltms.fleet.peer.PeerLauncher; import org.slf4j.Logger; @@ -298,7 +299,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { // has neither MCP nor a charter — session flags must be added into a list we own. List argv = mutableArgv(argvWithFleet(cfg, spec)); String agentSessionId = applySessionIdentity(argv, spec.sessionName(), spec.resumeSessionId()); - return new Launch(workerEnv, argvWithAutoCompact(argvWithModel(argv, cfg), cfg), agentSessionId); + return new Launch(workerEnv, ClaudeCodeArguments.withAutoCompactWindow(argvWithModel(argv, cfg), cfg), agentSessionId); } /** @@ -908,30 +909,6 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { return withModel; } - /** - * Pin a bounded auto-compaction window on the command line via {@code --autocompact }, - * opt-in per profile (CB-634's sibling ticket: a member that runs out of context dies mid-turn - * and its {@code fleet_reply} — the whole point of the turn — is lost with it; opencode already - * forces {@code compaction.auto: true} unconditionally, CB-523, but Claude Code has no equivalent - * and runs at the backend's own default window). - * - *

Mirrors {@link #argvWithModel}: appended after it, so it survives the {@code ccs } - * wrapper the same way {@code --model} does, and outranks env/settings and the operator's own - * {@code argv}. Verified: {@code claude 2.1.241 --help} lists {@code --autocompact } - * (either the literal {@code auto}, or an integer 100k–1M) — {@link FleetConfig#load} rejects a - * configured value outside that band before this ever runs, so the flag Claude Code receives here - * is always in range. - */ - private static List argvWithAutoCompact(List argv, FleetConfig.Profile cfg) { - if (cfg.autoCompactWindow() == null) { - return argv; - } - List withAutoCompact = mutableArgv(argv); - withAutoCompact.add("--autocompact"); - withAutoCompact.add(String.valueOf(cfg.autoCompactWindow())); - return withAutoCompact; - } - // --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) --- /** Spawn a worker for the default profile in the resolved default cwd. */ 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 4cfbf64..1f74377 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java @@ -93,6 +93,40 @@ class FleetConfigTest { "unset means off — today's behaviour, unchanged"); } + @Test + void aClaudeProfileWithConflictingAutoCompactFlagAndEnvironmentWindowIsRejected(@TempDir Path dir) + throws Exception { + Path f = dir.resolve("conflicting-auto-compact-window.yaml"); + Files.writeString(f, """ + profiles: + claude-profile: + autoCompactWindow: 250000 + env: + CLAUDE_CODE_AUTO_COMPACT_WINDOW: "300000" + """); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> FleetConfig.load(f)); + + assertTrue(e.getMessage().contains("claude-profile")); + assertTrue(e.getMessage().contains("autoCompactWindow")); + assertTrue(e.getMessage().contains("CLAUDE_CODE_AUTO_COMPACT_WINDOW")); + } + + @Test + void aClaudeProfileWithEqualAutoCompactFlagAndEnvironmentWindowLoads(@TempDir Path dir) + throws Exception { + Path f = dir.resolve("equal-auto-compact-window.yaml"); + Files.writeString(f, """ + profiles: + claude-profile: + autoCompactWindow: 250000 + env: + CLAUDE_CODE_AUTO_COMPACT_WINDOW: "250000" + """); + + assertEquals(250_000, FleetConfig.load(f).profiles().get("claude-profile").autoCompactWindow()); + } + // ── fleetd #201 Unit 5: errorPattern ──────────────────────────────────────────────────────── @Test diff --git a/fleetd/src/test/java/dev/ltms/fleet/lead/LeadLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/lead/LeadLauncherTest.java index 2a3578a..1a8c0b3 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/lead/LeadLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/lead/LeadLauncherTest.java @@ -32,13 +32,26 @@ class LeadLauncherTest { Map.of("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "300000"), null, null, true, null); } + private static FleetConfig.Profile profileWithAutoCompactWindow(String kind) { + return new FleetConfig.Profile( + "opus", null, "claude-opus-5", null, "FLEETD_WORKER_TOKEN", + List.of("ccs", "ltms"), "tab", "fleet", null, + "http://127.0.0.1:8765/mcp", null, null, + null, null, kind, Map.of(), null, null, true, null, null, + null, null, null, 250_000); + } + private static FleetConfig configWith(FleetConfig.Leader lead) { + return configWith(lead, opusProfile()); + } + + private static FleetConfig configWith(FleetConfig.Leader lead, FleetConfig.Profile profile) { Map leaders = new LinkedHashMap<>(); leaders.put("opus", lead); FleetConfig.Fleet fleet = new FleetConfig.Fleet(leaders, Map.of(), Map.of(), Map.of(), null); return new FleetConfig( - null, null, Map.of("opus", opusProfile()), null, null, null, null, null, + null, null, Map.of("opus", profile), null, null, null, null, null, null, null, fleet, null, "fixed", null).withDefaults(); } @@ -330,6 +343,36 @@ class LeadLauncherTest { "--model is appended last so it outranks the ccs wrapper (CB-533)"); } + @Test + void aClaudeLeadPassesItsConfiguredAutoCompactWindowToClaudeCode() { + FakeHerdr herdr = new FakeHerdr(); + FleetConfig.Profile profile = profileWithAutoCompactWindow("claude-code"); + + launcher(herdr, configWith(lead("opus", "lead: opus", 1), profile)).ensureLeads(); + + List args = startedArgs(herdr); + assertEquals("250000", args.get(args.indexOf("--autocompact") + 1)); + } + + @Test + void aClaudeLeadWithNoAutoCompactWindowGetsNoAutoCompactFlag() { + FakeHerdr herdr = new FakeHerdr(); + + launcher(herdr, configWith(lead("opus", "lead: opus", 1))).ensureLeads(); + + assertFalse(startedArgs(herdr).contains("--autocompact")); + } + + @Test + void aNonClaudeLeadDoesNotGetAnAutoCompactFlag() { + FakeHerdr herdr = new FakeHerdr(); + FleetConfig.Profile profile = profileWithAutoCompactWindow("opencode"); + + launcher(herdr, configWith(lead("opus", "lead: opus", 1), profile)).ensureLeads(); + + assertFalse(startedArgs(herdr).contains("--autocompact")); + } + /** A lead runs on the operator's subscription. Nothing may move it off. */ @Test void theLeadEnvCarriesNoAnthropicBinding() { -- 2.52.0 From be6c45ff78f88edf2a978131f5f47850132ad744 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Tue, 22 Sep 2026 10:11:44 +0700 Subject: [PATCH 2/2] 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 ──────────────────────────────────────────────────────── -- 2.52.0