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 0e55579..d37773e 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java @@ -278,18 +278,22 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { /** * Add the Claude-specific session-identity flags to {@code argv} and return the peer's OWN - * session id — the resume handle. A resume request passes the prior id via {@code -r} and - * returns that id; a fresh named session mints a new UUID, passes it via {@code --session-id}, - * and returns the mint. The bridge's logical name rides along as {@code -n} when present. When - * no identity is requested (sessionName and resumeSessionId both blank) this adds - * nothing and returns {@code null}, keeping the legacy no-identity launch byte-identical. + * session id — the resume handle. A resume passes the prior id via {@code -r} and returns + * that id; every other spawn mints a new UUID, passes it via {@code --session-id}, and + * returns the mint. The bridge's logical name rides along as {@code -n} when present. + * + *

fleetd #214: the mint is unconditional. A plain {@code fleet_spawn} passes neither + * sessionName nor resumeSessionId, yet the member must still be resumable, and this id is + * the only resume handle a claude-code member has — unlike opencode, nothing resolves it + * after the launch. Checked against the real binary (claude 2.1.252): the flag is safe on + * every spawn. The binary takes only a valid UUID — it refuses any other value at argument + * parsing ("Invalid session ID. Must be a valid UUID.") — so the {@code UUID.randomUUID()} + * mint is required, not incidental. The only flag interaction the binary documents is with + * {@code -r} (both claim the session id), and the resume branch above never combines the two. */ private static String applySessionIdentity(List argv, String sessionName, String resumeSessionId) { boolean resuming = resumeSessionId != null && !resumeSessionId.isBlank(); boolean named = sessionName != null && !sessionName.isBlank(); - if (!resuming && !named) { - return null; // no identity requested — keep the legacy launch byte-identical - } if (named) { argv.add("-n"); argv.add(sessionName); 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 6b6be77..0fdeefd 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java @@ -262,10 +262,16 @@ class ClaudeCodeLauncherTest { Map start = (Map) herdr.lastCall("agent.start").params(); assertEquals("claude", start.get("kind"), "herdr launches the canonical executable by kind"); - // CB-533: the shared fixture pins model "coder", so the model flag is the whole args list. // What this test guards is that argv[0] is NOT repeated — herdr supplies it from `kind`. - assertEquals(List.of("--model", "coder"), start.get("args"), - "the configured executable is not repeated in args"); + // fleetd #214: a plain spawn now also carries fleetd's own minted session id. + List args = spawnedArgs(herdr); + assertFalse(args.contains("claude"), "the configured executable is not repeated in args"); + int flag = args.indexOf("--session-id"); + assertTrue(flag >= 0, "a plain spawn mints a session id: " + args); + assertDoesNotThrow(() -> UUID.fromString(args.get(flag + 1)), "the minted id is a valid UUID"); + assertEquals(List.of("--model", "coder"), + List.of(args.get(args.size() - 2), args.get(args.size() - 1)), + "the CB-533 model flag still trails the launch flags: " + args); } @Test @@ -278,7 +284,15 @@ class ClaudeCodeLauncherTest { assertFalse(args.contains("--append-system-prompt"), "no reply charter without mcpUrl"); // CB-533: the model flag is independent of the MCP mount — pinning the model is not part of // "mount the bridge", so an unmounted worker still runs the model its profile names. - assertEquals(List.of("--verbose", "--model", "coder"), args, + // fleetd #214: a plain spawn now carries fleetd's own minted session id; drop the two + // mint elements when checking the rest of the argv. + int flag = args.indexOf("--session-id"); + assertTrue(flag >= 0, "a plain spawn mints a session id even without a bridge mount: " + args); + assertDoesNotThrow(() -> UUID.fromString(args.get(flag + 1)), "the minted id is a valid UUID"); + List rest = new java.util.ArrayList<>(args); + rest.remove(flag + 1); + rest.remove(flag); + assertEquals(List.of("--verbose", "--model", "coder"), rest, "the operator's own args are preserved, in order, ahead of the model flag"); } @@ -649,7 +663,7 @@ class ClaudeCodeLauncherTest { assertEquals("/work/proj", cwd, "effectiveCwd via SpawnRequest must match the three-arg resolution"); } - // --- CB-547a: durable session identity (mint / resume / no-identity legacy) ----------------- + // --- CB-547a / fleetd #214: durable session identity (always mint / resume) ----------------- @Test void freshSpawnMintsASessionIdAndPassesTheName() { @@ -684,18 +698,28 @@ class ClaudeCodeLauncherTest { } @Test - void noIdentitySpawnKeepsTheLegacyArgvAndCarriesNoSessionHandle() { + void plainSpawnMintsASessionIdSoEveryMemberIsResumable() { + // fleetd #214: a plain spawn passes no sessionName and no resumeSessionId, yet the member + // must still be resumable — the id is minted unconditionally, and it is the ONLY resume + // handle a claude-code member has (unlike opencode, nothing resolves it after the launch). + // The binary requires a valid UUID (checked against claude 2.1.252: a non-UUID is refused + // at argument parsing with "Invalid session ID. Must be a valid UUID."). FakeHerdr herdr = new FakeHerdr(); ClaudeCodeLauncher svc = service(herdr, List.of("ccs", "ltms-local"), null); PeerHandle handle = svc.spawn(new SpawnRequest("ltms-local", null, null)); List args = spawnedArgs(herdr); - assertFalse(args.contains("--session-id"), "no identity → no --session-id"); - assertFalse(args.contains("-n"), "no identity → no -n"); - assertFalse(args.contains("-r"), "no identity → no -r"); - assertNull(handle.agentSessionId(), "no identity → no resume handle"); - assertNull(handle.sessionName(), "no identity → no logical name"); + int flag = args.indexOf("--session-id"); + assertTrue(flag >= 0 && flag + 1 < args.size(), + "--session-id is minted even when no identity is requested: " + args); + String minted = args.get(flag + 1); + assertDoesNotThrow(() -> UUID.fromString(minted), "--session-id is a valid UUID: " + minted); + assertEquals(minted, handle.agentSessionId(), + "the resume handle is the minted id, so every member is resumable from fleet_list"); + assertFalse(args.contains("-n"), "no sessionName was requested → no -n"); + assertFalse(args.contains("-r"), "no resume was requested → no -r"); + assertNull(handle.sessionName(), "no sessionName was requested → no logical name"); } @Test diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java index 9fc0d37..9c3a56c 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java @@ -887,15 +887,18 @@ class SessionManagerTest { } @Test - void acquireWithNeitherSessionFieldLeavesAgentSessionIdNull() { + void acquireWithNeitherSessionFieldStillMintsAnAgentSessionId() { + // fleetd #214: the claude-code launcher mints a session id for EVERY spawn, so a member is + // resumable even when the spawn asked for no session identity. FakeHerdr herdr = new FakeHerdr(); SessionManager sessions = sessionManager(herdr); MemberSession s = sessions.acquire("ltms-local", null, null, null); - assertNull(s.agentSessionId(), "no identity requested — unchanged from before CB-584"); - assertFalse(SessionManager.rosterView(s, null).containsKey("agentSessionId"), - "a null id is omitted from the roster, like charterSha256 for a receipt-less session"); + assertNotNull(s.agentSessionId(), + "fleetd #214: a plain spawn mints a session id, so every member is resumable"); + assertTrue(SessionManager.rosterView(s, null).containsKey("agentSessionId"), + "the minted id is in the roster, so fleet_list advertises every member's resume handle"); } @Test