fix(claude-cli): use a fresh UUID per spawn for claude --session-id

`--no-session-persistence` only blocks resumability — Claude Code
still writes `~/.claude/projects/<workspace>/<id>.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.
This commit is contained in:
Spherrrical 2026-05-04 14:45:31 -07:00
parent 2aa9981f46
commit 5e689fed51
2 changed files with 42 additions and 11 deletions

View file

@ -105,7 +105,16 @@ pub struct ClaudeProcess {
/// which keeps `SessionManager` callers from holding the session-map lock
/// across an async hop.
last_used: StdMutex<Instant>,
/// 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<Arc<Self>, ProcessError> {
// Always hand the CLI a brand-new UUID. `--no-session-persistence`
// does NOT actually prevent Claude Code from writing
// `~/.claude/projects/<workspace>/<id>.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.

View file

@ -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();