From e99cb70a8b52470ab5e89502bd3145c45a4054d3 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 19 Sep 2026 16:21:33 +0700 Subject: [PATCH] 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() {