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..94f4916 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); + warnConflictingAutoCompactWindows(yaml); rejectMalformedProfilePatterns(yaml); rejectUnknownKind(yaml); rejectUnknownAuthMode(yaml); @@ -2225,6 +2226,71 @@ public record FleetConfig( } } + /** + * Warn (never refuse to start) about a Claude Code profile whose auto-compaction flag and + * environment setting disagree. + * + *

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 warnConflictingAutoCompactWindows(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 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) + || !(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)); + 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 + ")"); + } + } + 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)); + } + /** * 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..ce2e214 --- /dev/null +++ b/fleetd/src/main/java/dev/ltms/fleet/launch/ClaudeCodeArguments.java @@ -0,0 +1,35 @@ +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. + * + *

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) { + 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..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,6 +96,72 @@ 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 aClaudeProfileWithConflictingAutoCompactFlagAndEnvironmentWindowLoadsAndWarnsWithBothValues( + @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" + """); + + 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(); + } + + 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 aClaudeProfileWithEqualAutoCompactFlagAndEnvironmentWindowLoadsWithNoWarning(@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" + """); + + 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 ──────────────────────────────────────────────────────── @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() {