diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 8782c4dd..be80156b 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -63,6 +63,19 @@ const AUTH_FAIL_MARKERS: &[&str] = &[ "Failed to authenticate. API Error: 401", ]; +/// Substring claude-code emits when `--resume ` is handed a session id +/// that doesn't exist in this cwd's project (stale persisted id, or a crash +/// before the first turn ever completed a `.jsonl`). On a hit we clear the +/// persisted id so the NEXT turn starts a fresh session and re-captures — +/// the agent self-heals instead of failing `--resume` forever. +const SESSION_NOT_FOUND_MARKER: &str = "No conversation found with session ID"; + +/// Name of the harness-owned file under `paths::harness_dir()` that holds +/// the claude session id to resume. Written after every turn with the id +/// claude reported on its stream (the id can change across resume turns in +/// some claude-code versions, so we always rewrite with the last-seen value). +const CLAUDE_SESSION_ID_FILE: &str = "claude-session-id"; + /// How long to sleep after detecting a rate-limit before re-entering the /// serve loop. Overridable via `HIVE_RATE_LIMIT_SLEEP_SECS`. Default is /// 5 minutes — enough for most short-lived throttles; the operator can @@ -552,8 +565,11 @@ fn session_refreshed(prev: DirSnapshot, now: DirSnapshot) -> bool { /// Spawn `claude` for one turn and pump `stream-json` stdout into the /// live event bus. Prompt goes over stdin (variadic /// `--allowedTools`/`--tools` would otherwise eat a trailing positional -/// prompt). The session is persistent across turns via `--continue` and -/// claude's in-session auto-compact is disabled via the managed +/// prompt). The session is persistent across turns via `--resume ` +/// against the harness's own captured session id (NOT bare `--continue`, +/// which resumes the *latest* session in this cwd and so lets a `choom` +/// session hijack the live harness context). claude's in-session +/// auto-compact is disabled via the managed /// settings at `/etc/claude-code/managed-settings.json` so it doesn't /// stall mid-turn — hyperhive owns compaction. pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome { @@ -624,16 +640,32 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, const STDERR_TAIL_LINES: usize = 20; let model = bus.model(); let effort = bus.effort(); - let resume = !bus.take_skip_continue(); - if !resume { - // Flag the fresh session so the bin loop mints a new `sessions` - // row + stamps its id onto this turn's stats (and subsequent - // turns until the next fresh start). + // Resolve which claude session to resume. We NEVER pass bare + // `--continue`: that resumes the latest session in this cwd, which a + // `choom` invocation (same cwd) can hijack, wiping the harness context. + // Instead we `--resume ` against the id claude reported on a prior + // turn, persisted under `harness_dir()/claude-session-id`. + let persist_path = crate::paths::harness_dir().join(CLAUDE_SESSION_ID_FILE); + let resume_id: Option = if bus.take_skip_continue() { + // Fresh session requested: mint a new one (no --resume). Flag it so + // the bin loop mints a new `sessions` row + stamps its id onto this + // turn's stats. Drop any stale persisted id — the new id claude + // reports this turn is captured + written below. bus.mark_fresh_session(); + let _ = std::fs::remove_file(&persist_path); bus.emit(LiveEvent::Note { - text: "fresh session (--continue suppressed for this turn)".into(), + text: "fresh session (continue suppressed for this turn)".into(), }); - } + None + } else { + // Continue: resume OUR captured id. Absent (first turn / just + // self-healed from a stale id) → fall through to a fresh session + // and capture the new id below. + match std::fs::read_to_string(&persist_path) { + Ok(s) if !s.trim().is_empty() => Some(s.trim().to_string()), + _ => None, + } + }; let mut cmd = Command::new("claude"); // Spawn inside the agent's state dir so relative paths in tool calls // (Read foo.md, Bash ls, Write notes.md) land in the durable dir @@ -651,8 +683,8 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, .arg(&model) .arg("--effort") .arg(&effort); - if resume { - cmd.arg("--continue"); + if let Some(id) = &resume_id { + cmd.arg("--resume").arg(id); } cmd.arg("--system-prompt-file").arg(&files.system_prompt); cmd.arg("--mcp-config") @@ -679,12 +711,21 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, let prompt_too_long = Arc::new(AtomicBool::new(false)); let rate_limited = Arc::new(AtomicBool::new(false)); let auth_failed = Arc::new(AtomicBool::new(false)); + // `--resume` against a stale/missing id: clear the persist file so the + // next turn self-heals into a fresh session. + let session_not_found = Arc::new(AtomicBool::new(false)); + // Last `session_id` claude reported on its stream this turn; persisted + // after the child exits so the next turn `--resume`s it. + let session_id_seen = Arc::new(Mutex::new(None::)); let flag_out = prompt_too_long.clone(); let flag_err = prompt_too_long.clone(); let rate_out = rate_limited.clone(); let rate_err = rate_limited.clone(); let auth_out = auth_failed.clone(); let auth_err = auth_failed.clone(); + let notfound_out = session_not_found.clone(); + let notfound_err = session_not_found.clone(); + let session_id_out = session_id_seen.clone(); let bus_out = bus.clone(); let bus_err = bus.clone(); let pump_stdout = tokio::spawn(async move { @@ -712,7 +753,19 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) { auth_out.store(true, Ordering::Relaxed); } + if line.contains(SESSION_NOT_FOUND_MARKER) { + notfound_out.store(true, Ordering::Relaxed); + } if let Ok(v) = serde_json::from_str::(&line) { + // Track the session id claude reports (init + result events + // both carry it). Persisted after exit so the next turn + // `--resume`s it; re-captured each turn since the id can + // change across resumes in some claude-code versions. + if let Some(sid) = v.get("session_id").and_then(|s| s.as_str()) + && !sid.is_empty() + { + *session_id_out.lock().unwrap() = Some(sid.to_string()); + } // Rate-limit detection: only fire on JSON `error` events, // not on arbitrary text content. An agent discussing a past // rate limit in its response would otherwise trigger a false @@ -779,6 +832,9 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) { auth_err.store(true, Ordering::Relaxed); } + if line.contains(SESSION_NOT_FOUND_MARKER) { + notfound_err.store(true, Ordering::Relaxed); + } // Mirror to journald so post-mortems work without the web UI // or the events sqlite. The bus event is what the dashboard // renders; the tracing line is what `journalctl -M -b` @@ -801,6 +857,18 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, let too_long = prompt_too_long.load(Ordering::Relaxed); let is_rate_limited = rate_limited.load(Ordering::Relaxed); let is_auth_failed = auth_failed.load(Ordering::Relaxed); + // Session-id bookkeeping. On a stale/missing `--resume` id, drop the + // persist file so the next turn starts fresh and self-heals. Otherwise + // rewrite it with the id claude reported this turn (handles the id + // changing across resumes in some claude-code versions). + if session_not_found.load(Ordering::Relaxed) { + let _ = std::fs::remove_file(&persist_path); + } else if let Some(sid) = session_id_seen.lock().unwrap().clone() { + if let Some(parent) = persist_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&persist_path, sid); + } if !status.success() && !too_long && !is_rate_limited && !is_auth_failed { let tail = stderr_tail.lock().unwrap(); if tail.is_empty() {