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 d37773e..6dddea8 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java @@ -324,11 +324,19 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * *

CB-618: Claude Code refuses to start when BOTH {@code --append-system-prompt} and * {@code --append-system-prompt-file} are on the command line ("Cannot use both ... Please use - * only one"), so the two charters can never travel on separate flags. When both are present they - * are concatenated into the one file, role charter first and reply charter last — last is where - * the reply rule must sit, because it is the rule that must survive. When only the reply charter - * is present it keeps its proven inline {@code --append-system-prompt} delivery, which is also - * the only form that reaches a member with no repo checkout. + * only one"), so the two charters can never travel on separate flags. They are concatenated + * into the one file, role charter first and reply charter last — last is where the reply rule + * must sit, because it is the rule that must survive. + * + *

fleetd #220: a lone reply charter used to ride inline on {@code --append-system-prompt}, + * which put ~800 bytes of prose on the command line herdr types into the pane. That line is + * capped at {@value HerdrPeerLauncher#PANE_COMMAND_BYTE_LIMIT} bytes by the pty itself, and + * everything past the cap is dropped with no error from any layer. The charter alone left about + * 50 bytes of headroom, so adding one flag ({@code --session-id}, fleetd #214) truncated the + * LAST argument instead — {@code --autocompact 250000} arrived as {@code --autocompact 25}, + * claude rejected it, and every claude-code spawn died as an unexplained readiness timeout. + * The charter now always travels as a file, which takes the prose off the command line for + * good; {@link HerdrPeerLauncher#checkPaneCommandFits} is the backstop for whatever grows next. */ private List argvWithFleet(FleetConfig.Profile cfg, LaunchSpec spec) { String roleCharter = nonBlank(spec.roleCharter()); @@ -345,16 +353,13 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { argv.add("--mcp-config"); argv.add(mcpConfigJson(cfg)); } - // Combine the charters in order role -> reply, dropping any that are absent. When two - // or more survive they must ride one --append-system-prompt-file (CB-618 forbids the inline - // flag and the file flag together). A lone reply charter keeps its proven inline delivery. + // Combine the charters in order role -> reply, dropping any that are absent. They ride one + // --append-system-prompt-file (CB-618 forbids the inline flag and the file flag together), + // always — fleetd #220: charter prose on the command line overruns the pane's byte cap. List charters = new java.util.ArrayList<>(2); if (roleCharter != null) charters.add(roleCharter); if (replyCharter != null) charters.add(replyCharter); - if (charters.size() == 1 && replyCharter != null && roleCharter == null) { - argv.add("--append-system-prompt"); - argv.add(replyCharter); - } else if (!charters.isEmpty()) { + if (!charters.isEmpty()) { argv.add("--append-system-prompt-file"); argv.add(writeCharterFile(String.join("\n\n", charters)).toString()); } diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java index 63053a6..88cbcf6 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/HerdrPeerLauncher.java @@ -682,6 +682,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { // Protocol 19 resolves the executable from the agent kind (== namePrefix here), so // argv[0] — the configured executable — is dropped and only the extra args are passed. List args = argv.isEmpty() ? argv : argv.subList(1, argv.size()); + checkPaneCommandFits(cfg, argv); HerdrException last = null; for (int attempt = 0; attempt < NAME_RETRIES; attempt++) { long seq = nameSeq.incrementAndGet(); @@ -697,6 +698,59 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { throw last; } + /** + * fleetd #220: herdr does not exec the launch command — it TYPES it into the pane as one line, + * and a pty line buffer holds only {@value #PANE_COMMAND_BYTE_LIMIT} bytes (BSD/macOS {@code + * MAX_CANON}). Everything past that byte is dropped. Nothing reports it: herdr answers "agent + * started", the backend exits on the mangled argument it was handed, the pane closes, and the + * only symptom is {@link #waitUntilInjectableOrThrow} timing out 20 seconds later with no + * reason. That is exactly how #214 broke every claude-code spawn — one 50-byte flag pushed a + * 978-byte command to 1028, and the tail that got cut was {@code --autocompact 250000}. + * + *

So measure it here and refuse, loudly and immediately, rather than spawn something that + * cannot work. The estimate is deliberately conservative: fleetd cannot see herdr's quoting, so + * every argument is charged its own bytes plus a separator and a quote pair. An over-estimate + * costs a clear error at a length that was already unsafe; an under-estimate would let the + * silent truncation back in. + * + * @throws PeerUnreachableException when the command cannot fit — the same failure the spawn + * would have hit anyway, named at the point it is still + * explainable + */ + private void checkPaneCommandFits(FleetConfig.Profile cfg, List argv) { + int bytes = 0; + String longest = null; + int longestBytes = 0; + for (String arg : argv) { + int argBytes = arg == null ? 0 : arg.getBytes(java.nio.charset.StandardCharsets.UTF_8).length; + bytes += argBytes + QUOTING_OVERHEAD_PER_ARG; + if (argBytes > longestBytes) { + longestBytes = argBytes; + longest = arg; + } + } + if (bytes <= PANE_COMMAND_BYTE_LIMIT) { + return; + } + String culprit = longest == null ? "" + : longest.substring(0, Math.min(longest.length(), 60)) + (longest.length() > 60 ? "…" : ""); + throw new PeerUnreachableException( + "launch command for profile " + cfg.profile() + " is about " + bytes + " bytes, over the " + + PANE_COMMAND_BYTE_LIMIT + "-byte limit of the pane line herdr types it into. " + + "The pty would drop the tail silently and the backend would exit on a mangled " + + "argument. Longest argument is " + longestBytes + " bytes: " + culprit + + " — move it off the command line (a file flag) or shorten it."); + } + + /** + * The pty line buffer herdr types a launch command into: BSD/macOS {@code MAX_CANON}. Not a + * fleetd choice and not configurable — see {@link #checkPaneCommandFits}. + */ + static final int PANE_COMMAND_BYTE_LIMIT = 1024; + + /** Per-argument allowance for the separating space and a shell quote pair fleetd cannot see. */ + private static final int QUOTING_OVERHEAD_PER_ARG = 3; + /** Start the agent into {@code paneId}, waiting out the seed shell's boot with the sleeper. */ private Agent startAwaitingShellPrompt(String name, List args, String paneId) { HerdrException busy = null; @@ -860,20 +914,51 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { */ private void waitUntilInjectableOrThrow(String paneId) { long deadline = nowMillis.getAsLong() + spawnReadyTimeoutMs; + Object lastStatus = null; while (nowMillis.getAsLong() < deadline) { - if (agents.status(paneId).injectable()) { + var status = agents.status(paneId); + lastStatus = status; + if (status.injectable()) { log.debug("peer pane={} reached injectable state", paneId); return; } sleeper.run(); } - log.warn("peer pane={} did not become injectable within {}ms — closing", paneId, spawnReadyTimeoutMs); + // fleetd #220: read the pane BEFORE stop() closes it. Without this the gate says only that + // it timed out, which is true of every cause — a backend that never launched, a binary that + // rejected an argument and exited, a trust prompt, a login shell that hung. The pane holds + // the one copy of that answer and it is destroyed a line later. + log.warn("peer pane={} did not become injectable within {}ms (last status {}) — closing. " + + "Pane tail:\n{}", + paneId, spawnReadyTimeoutMs, lastStatus, readPaneQuietly(paneId)); stop(paneId); throw new PeerUnreachableException( "worker pane " + paneId + " did not reach injectable state within " + spawnReadyTimeoutMs + "ms"); } + /** + * fleetd #220: the pane's recent output, clipped, for the readiness-gate timeout log — or a + * short note when it cannot be read. Best-effort by construction: this runs on a path that is + * already failing, so it must never replace the real error with one of its own. + */ + private String readPaneQuietly(String paneId) { + try { + String pane = agents.read(paneId, "recent"); + if (pane == null || pane.isBlank()) { + return ""; + } + return pane.length() <= SPAWN_FAILURE_PANE_CHARS + ? pane + : pane.substring(pane.length() - SPAWN_FAILURE_PANE_CHARS); + } catch (RuntimeException e) { + return ""; + } + } + + /** How much of a failed spawn's pane the timeout log carries. */ + private static final int SPAWN_FAILURE_PANE_CHARS = 4000; + /** * A concrete {@link PeerHandle} wrapping herdr agent coordinates, the profile that spawned it, * the session identity the launch resolved (CB-547a): the bridge's logical name and the peer's diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java index 0fdeefd..3d7a4ec 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java @@ -61,8 +61,75 @@ class ClaudeCodeLauncherTest { assertTrue(args.stream().noneMatch(a -> a.contains("\"bridge\"")), "the mount is named fleet since CB-632 — a member addresses its tools as " + "mcp__fleet__*, and CLAUDE.md's role-detection ladder names that prefix"); - assertTrue(args.contains("--append-system-prompt")); - assertTrue(args.stream().anyMatch(a -> a.contains("fleet_reply")), "reply charter present"); + // fleetd #220: the charter travels as a FILE, never inline — charter prose on the command + // line overruns the byte cap of the pane line herdr types it into. + assertTrue(args.contains("--append-system-prompt-file")); + assertFalse(args.contains("--append-system-prompt"), + "the inline flag would put ~800 bytes of prose on the pane command line"); + String charterFile = args.get(args.indexOf("--append-system-prompt-file") + 1); + assertTrue(readFile(charterFile).contains("fleet_reply"), "reply charter present in the file"); + } + + /** Read a charter file the launcher wrote, failing the test rather than the build on an IO error. */ + private static String readFile(String path) { + try { + return java.nio.file.Files.readString(java.nio.file.Path.of(path)); + } catch (java.io.IOException e) { + throw new AssertionError("charter file " + path + " is not readable", e); + } + } + + /** + * fleetd #220 regression. herdr TYPES the launch command into the pane, and the pty line buffer + * holds 1024 bytes — past that the tail is dropped with no error anywhere, so the backend exits + * on a mangled argument and the spawn dies as an unexplained readiness timeout. That is what + * happened when #214 added --session-id to a command already 978 bytes long: --autocompact + * 250000 arrived as --autocompact 25. This asserts the whole assembled command still fits, with + * the flags a real spawn carries (MCP mount, charter, model, autocompact, session id). + */ + @Test + void theAssembledLaunchCommandFitsThePaneLineLimit() { + FakeHerdr herdr = new FakeHerdr(); + // Shaped like the live sonnet profile, because the bug is a SUM: the charter alone fits, + // and so does every flag alone. Only model + autocompact + session id on top of the charter + // crossed the cap, which is why nothing caught it until a member failed to spawn. + FleetConfig.Profile cfg = new FleetConfig.Profile( + "sonnet", null, "claude-sonnet-5", null, null, + List.of("claude"), "tab", "fleetd-workers", "w #{n}", "http://127.0.0.1:8765/mcp", + null, null, null, null, null, null, null, null, true, null, null, null, null, null, + 250000); + new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of()), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null) + .spawn(); + + List args = spawnedArgs(herdr); + int bytes = "claude".length(); + for (String arg : args) { + bytes += arg.getBytes(java.nio.charset.StandardCharsets.UTF_8).length + 3; + } + assertTrue(bytes <= HerdrPeerLauncher.PANE_COMMAND_BYTE_LIMIT, + "the launch command must fit the pane line: " + bytes + " bytes vs limit " + + HerdrPeerLauncher.PANE_COMMAND_BYTE_LIMIT + " — args " + args); + } + + /** + * fleetd #220: the guard refuses a command that cannot fit, instead of letting the pty drop the + * tail. The refusal must name the size and the argument to blame — a spawn that fails with + * "did not reach injectable state" tells the operator nothing, which is the whole reason this + * bug took a live pane scrape to find. + */ + @Test + void anOverlongLaunchCommandIsRefusedWithTheSizeAndTheCulprit() { + FakeHerdr herdr = new FakeHerdr(); + String huge = "x".repeat(1500); + ClaudeCodeLauncher launcher = service(herdr, List.of("claude", huge), null); + + PeerUnreachableException refused = assertThrows(PeerUnreachableException.class, launcher::spawn); + + assertTrue(refused.getMessage().contains("1024"), "names the limit: " + refused.getMessage()); + assertTrue(refused.getMessage().contains("1500"), "names the culprit's size: " + refused.getMessage()); + assertFalse(herdr.called("agent.start"), + "nothing may be started — a truncated command is worse than no spawn"); } // CB-634: a profile with ideMcpUrl set mounts the IDE Index MCP as a second server and pins