fix(#2019): pin harness claude session via --resume so choom can't clobber it

This commit is contained in:
damocles 2026-06-27 14:57:01 +02:00 committed by mara
commit c9115bdbf5

View file

@ -63,6 +63,19 @@ const AUTH_FAIL_MARKERS: &[&str] = &[
"Failed to authenticate. API Error: 401",
];
/// Substring claude-code emits when `--resume <id>` 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 <id>`
/// 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 <id>` 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<String> = 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::<String>));
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::<serde_json::Value>(&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 <c> -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() {