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