From 5e689fed51077cf5ab307551d993cde2017958f9 Mon Sep 17 00:00:00 2001 From: Spherrrical Date: Mon, 4 May 2026 14:45:31 -0700 Subject: [PATCH] fix(claude-cli): use a fresh UUID per spawn for `claude --session-id` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--no-session-persistence` only blocks resumability — Claude Code still writes `~/.claude/projects//.jsonl` for every session. Reusing our deterministic brightstaff session id (a v5 UUID hashed from the conversation prefix) caused the CLI to fail every second request for the same conversation with `Error: Session ID ... is already in use`. Generate a per-spawn random v4 UUID inside `ClaudeProcess::spawn` and pass that to `claude --session-id` (and stamp it on every stdin JSONL event so the CLI accepts the turn). Keep the deterministic brightstaff session id as the `SessionManager` map key so retries still hit the hot child. --- .../src/handlers/claude_cli/process.rs | 30 ++++++++++++++++++- .../src/handlers/claude_cli/server.rs | 23 +++++++------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/crates/brightstaff/src/handlers/claude_cli/process.rs b/crates/brightstaff/src/handlers/claude_cli/process.rs index e93642fa..2e404124 100644 --- a/crates/brightstaff/src/handlers/claude_cli/process.rs +++ b/crates/brightstaff/src/handlers/claude_cli/process.rs @@ -105,7 +105,16 @@ pub struct ClaudeProcess { /// which keeps `SessionManager` callers from holding the session-map lock /// across an async hop. last_used: StdMutex, + /// Brightstaff-internal identifier — a deterministic UUID v5 derived from + /// the conversation prefix (or supplied by the client header). Stable + /// across retries so the manager can route follow-up turns to this same + /// child. NEVER passed to `claude` itself. pub session_id: String, + /// Per-spawn random UUID v4 passed to `claude --session-id`. Always fresh + /// so we never collide with on-disk state (`~/.claude/projects/...`) + /// from a previous run of the same conversation. Also stamped onto every + /// stdin JSONL event so the CLI can verify the turn matches its session. + cli_session_id: String, } impl ClaudeProcess { @@ -119,6 +128,14 @@ impl ClaudeProcess { cwd: Option<&std::path::Path>, config: ClaudeCliConfig, ) -> Result, ProcessError> { + // Always hand the CLI a brand-new UUID. `--no-session-persistence` + // does NOT actually prevent Claude Code from writing + // `~/.claude/projects//.jsonl` — it only blocks + // resumability — so re-using our deterministic `session_id` would + // collide with any prior run of the same conversation and the CLI + // would exit with `Session ID ... is already in use`. + let cli_session_id = uuid::Uuid::new_v4().to_string(); + let mut cmd = Command::new(&config.binary); cmd.arg("-p") .arg("--output-format") @@ -132,7 +149,7 @@ impl ClaudeProcess { .arg("--model") .arg(normalize_model_arg(model)) .arg("--session-id") - .arg(&session_id) + .arg(&cli_session_id) .arg("--no-session-persistence"); if let Some(prompt) = system_prompt { @@ -226,6 +243,7 @@ impl ClaudeProcess { info!( session = %session_id, + cli_session = %cli_session_id, model = %normalize_model_arg(model), "spawned claude-cli" ); @@ -237,9 +255,19 @@ impl ClaudeProcess { config, last_used: StdMutex::new(Instant::now()), session_id, + cli_session_id, })) } + /// The UUID that `claude --session-id` was launched with. The bridge has + /// to stamp every stdin JSONL event with this id so the CLI accepts the + /// turn as belonging to its current session — see + /// [`Self::session_id`] for why this is distinct from the brightstaff + /// session id. + pub fn cli_session_id(&self) -> &str { + &self.cli_session_id + } + /// Write the user-turn JSONL events to the child's stdin and return a /// stream that yields parsed CLI events for this turn until the terminal /// `result` event (or watchdog) ends it. diff --git a/crates/brightstaff/src/handlers/claude_cli/server.rs b/crates/brightstaff/src/handlers/claude_cli/server.rs index 91cb96fc..68b7b703 100644 --- a/crates/brightstaff/src/handlers/claude_cli/server.rs +++ b/crates/brightstaff/src/handlers/claude_cli/server.rs @@ -134,16 +134,19 @@ async fn handle( } }; - let stdin_payload = match messages_request_to_stdin_payload(&parsed, Some(&session_id)) { - Ok(p) => p, - Err(err) => { - warn!(error = %err, "failed to build claude-cli stdin payload"); - return Ok(json_error( - StatusCode::BAD_REQUEST, - &format!("failed to build claude-cli stdin payload: {err}"), - )); - } - }; + // Stamp stdin events with the CLI's per-spawn UUID, NOT our deterministic + // brightstaff session id. The CLI rejects the turn if the two disagree. + let stdin_payload = + match messages_request_to_stdin_payload(&parsed, Some(process.cli_session_id())) { + Ok(p) => p, + Err(err) => { + warn!(error = %err, "failed to build claude-cli stdin payload"); + return Ok(json_error( + StatusCode::BAD_REQUEST, + &format!("failed to build claude-cli stdin payload: {err}"), + )); + } + }; let streaming = parsed.stream.unwrap_or(false); let model = parsed.model.clone();