From e35acca814c152362427c0ffa5893ccf525be171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 5 Jul 2026 18:13:54 +0200 Subject: [PATCH] fix(agent): pin claude session by constant title, archive on reset --- docs/turn-loop.md | 78 +++++--- hive-ag3nt/src/events.rs | 48 ++--- hive-ag3nt/src/turn.rs | 378 +++++++++++++++++++++++++++++---------- hive-ag3nt/src/web_ui.rs | 29 ++- 4 files changed, 369 insertions(+), 164 deletions(-) diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 7e704198..557e7712 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -137,7 +137,7 @@ if a new inbox message arrives before this turn ends"). The ``` claude --print --verbose --output-format stream-json --model \ - --effort --continue \ + --effort --resume # or --name <title> on first use \ --system-prompt-file /run/hive/claude-system-prompt.md \ --mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \ --tools <builtins> --allowedTools <builtins+mcp> @@ -180,16 +180,34 @@ The effective window drives watermarks and is exposed at runtime via `/api/state.context_window_tokens` so the UI can show a percentage-of-window ctx badge. -`--continue` keeps a persistent session per agent (claude stores -sessions in `~/.claude/projects/`, which is bind-mounted -persistently). Auto-compact and auto-memory are disabled via the -managed settings at `/etc/claude-code/managed-settings.json` because -hyperhive owns compaction — see [Compaction](#compaction) below. -A one-shot `--continue` suppression is available via -`POST /api/new-session` (or `/new-session` slash command in the -per-agent terminal) — `Bus::take_skip_continue()` flips an -`AtomicBool` once per turn, the next claude invocation drops -`--continue`, every subsequent turn resumes normal behaviour. +**Session identity — a constant title.** Every turn keys on one fixed, +harness-owned session title (`turn::session_title()`, default +`hive-session`, override `HIVE_SESSION_TITLE`). A turn `--resume <title>`s +it; the *first* use (bootstrap, post-archive, post-purge) misses and +`turn::run_claude_resume_or_create` re-runs the same prompt once with +`--name <title>` to mint it. That single self-heal rule is the whole +identity system — there is **no** scraped session-id file. Because the +title is constant, `/compact` and its post-compact retry provably target +the same session (killing the old "compact ran on a different/empty +session" bug), and a `choom` invocation in the same cwd can't hijack the +context (it won't carry our title). claude stores sessions in +`~/.claude/projects/<cwd-slug>/<uuid>.jsonl` (bind-mounted persistently); +`--name` writes the title into the file as a `custom-title` event, which +is what `--resume <title>` resolves against. We never pass bare +`--continue` (it resumes the *latest* session in the cwd — the hijack +vector). Auto-compact and auto-memory are disabled via the managed +settings at `/etc/claude-code/managed-settings.json` because hyperhive +owns compaction — see [Compaction](#compaction) below. + +**Session reset** is available via `POST /api/new-session` (or +`/new-session` slash command). It does *not* touch the session inline — +that would race a mid-write claude process. Instead `Bus::request_session_reset()` +sets a one-shot flag consumed at the next turn boundary by `drive_turn`, +which **archives** the current session: the backing `<uuid>.jsonl` is +renamed to `<uuid>.jsonl.archived` (dropped out of claude's `*.jsonl` +resolution glob, history preserved on disk, only the file carrying *our* +title — any `choom` session sharing the cwd is left alone). The next +turn's `--resume <title>` then misses and self-heals into a fresh session. ### Compaction @@ -231,20 +249,22 @@ Useful for agents running large-context models (sonnet/opus) where the 75% heuristic fires before the session is actually full — the reactive path (compact-on-overflow when the session hits the hard limit) still applies. -- **Auto session-reset** — a third path that fires when both - conditions hold: context is ≥ a watermark (`HIVE_AUTO_RESET_WATERMARK_TOKENS`, - default **50% of `context_window_tokens(model)`**) AND the time since - the last turn exceeds the assumed prompt-cache TTL - (`HIVE_CACHE_TTL_SECS`, default `3600`). - Claude's prompt cache lives ~5 minutes; if the cache is already - cold, resuming with `--continue` pays the full re-upload cost of - the current context with no benefit over starting fresh. So: - `drive_turn` injects one `AUTO_RESET_CHECKPOINT_PROMPT` notes turn - ("flush state to files, cache is cold") then arms - `Bus::take_skip_continue()` for the real turn — the next turn runs - without `--continue`, starting a fresh session. Unlike proactive - compaction the session is dropped entirely, not compacted. Set - `HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to disable. +- **Auto session-reset** — a third path (`turn::maybe_auto_reset`, + pre-turn) that fires when both conditions hold: context is ≥ a watermark + (`HIVE_AUTO_RESET_WATERMARK_TOKENS`, default **50% of + `context_window_tokens(model)`**) AND the time since the last turn + exceeds the assumed prompt-cache TTL (`HIVE_CACHE_TTL_SECS`, default + `3600`). Claude's prompt cache goes cold after a while; once it's cold, + `--resume`-ing a large session pays the full re-upload cost with no + benefit over starting fresh. So `drive_turn` **archives** the current + session (same mechanism as the operator reset — rename `<uuid>.jsonl` → + `.archived`) so the next turn's `--resume <title>` misses and starts + fresh. Unlike proactive compaction the session is dropped entirely, not + compacted — and *no* preceding checkpoint turn runs, because any turn + before the reset would just re-warm the cache and defeat the purpose. + Set `HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to disable. Auto-reset and the + operator reset are mutually exclusive per turn (both archive → fresh + turn), so an explicit operator reset short-circuits the heuristic. The child runs with `cwd = /state` (when the bind exists; falls back to the parent's cwd in dev), so any relative path in a tool @@ -318,9 +338,11 @@ socket at `/run/hive/` once at startup: same way. The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config, -write_settings, write_system_prompt, run_turn, drive_turn, -emit_turn_end, wait_for_login, compact_session}` — the single code path -every agent role runs through. +write_system_prompt, run_turn, drive_turn, emit_turn_end, wait_for_login, +compact_session, session_title}` — the single code path every agent role +runs through. Session identity is internal to the module: +`run_claude_resume_or_create` (resume-or-create self-heal) and +`archive_session` (turn-boundary reset) sit under `run_turn` / `drive_turn`. ### Reference docs (`hyperhive.docs.enable`) diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index c6fb2d46..6d50253a 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -680,21 +680,24 @@ pub struct Bus { /// `container_view` can surface the status on the dashboard without /// a live socket call. rate_limited: Arc<AtomicBool>, - /// One-shot: next `run_claude` call drops `--continue`, starting - /// a fresh claude session. Set by `POST /api/new-session` from - /// the per-agent web UI; consumed (cleared back to false) by the - /// next turn. Subsequent turns resume normal `--continue` - /// behavior. Atomic so the consumer can take-and-clear without a - /// lock. - skip_continue_once: Arc<AtomicBool>, + /// One-shot: operator asked to reset the claude session (`POST + /// /api/new-session`). Consumed at the *next* turn boundary by + /// `turn::drive_turn`, which archives the current session so the turn + /// starts fresh. Deferred (not applied in the handler) so the archive + /// never races a claude process mid-write — one claude per container, + /// serialized by the serve loop, so the turn boundary is the only point + /// where no session file is open. + session_reset_pending: Arc<AtomicBool>, /// Current fresh-claude-session id (FK to `sessions.id`). Set by the /// bin loop after minting a session row on a fresh start; stamped onto /// every `turn_stats` row until the next fresh session. `None` before /// the first fresh turn or when the stats db is unavailable. session_id: Arc<Mutex<Option<i64>>>, - /// One-shot: `run_claude` flips this true when it suppresses - /// `--continue` (a fresh session). The bin loop takes-and-clears it - /// after the turn to decide whether to mint a new `sessions` row. + /// One-shot: `run_claude` flips this true when it creates a fresh claude + /// session (`--name <title>` on a title miss). The bin loop takes-and- + /// clears it after the turn to decide whether to mint a new `sessions` + /// row. Session identity itself is handled entirely in `turn.rs` via the + /// constant title + archive — this flag is purely a stats signal. fresh_session: Arc<AtomicBool>, /// Per-turn tool-call counter. Reset by the bin loop between /// turns via `take_tool_calls`. Populated by `observe_stream` as @@ -773,7 +776,7 @@ impl Bus { last_ctx_usage: Arc::new(Mutex::new(None)), last_cost_usage: Arc::new(Mutex::new(None)), rate_limited: Arc::new(AtomicBool::new(was_rate_limited)), - skip_continue_once: Arc::new(AtomicBool::new(false)), + session_reset_pending: Arc::new(AtomicBool::new(false)), session_id: Arc::new(Mutex::new(None)), fresh_session: Arc::new(AtomicBool::new(false)), tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())), @@ -797,23 +800,24 @@ impl Bus { self.event_seq.fetch_add(1, Ordering::SeqCst) + 1 } - /// Arm the one-shot: the next claude invocation will run without - /// `--continue`, dropping any prior session context. Idempotent - /// — calling twice in a row before the next turn still consumes - /// to a single fresh-start. - pub fn request_new_session(&self) { - self.skip_continue_once.store(true, Ordering::SeqCst); + /// Request a session reset (operator `POST /api/new-session`). Deferred: + /// the flag is consumed at the next turn boundary by `drive_turn`, which + /// archives the current session so no claude process is mid-write when the + /// file is renamed. Idempotent — two clicks before the next turn still + /// archive once. + pub fn request_session_reset(&self) { + self.session_reset_pending.store(true, Ordering::SeqCst); } - /// Take + clear the one-shot. Returns true iff the caller should - /// run claude without `--continue` for this turn. + /// Take + clear the session-reset one-shot. Returns true iff `drive_turn` + /// should archive the current session before this turn. #[must_use] - pub fn take_skip_continue(&self) -> bool { - self.skip_continue_once.swap(false, Ordering::SeqCst) + pub fn take_session_reset(&self) -> bool { + self.session_reset_pending.swap(false, Ordering::SeqCst) } /// Mark that the current turn started a fresh claude session. - /// `run_claude` calls this when it suppresses `--continue`. + /// `run_claude` calls this when it creates a new titled session. pub fn mark_fresh_session(&self) { self.fresh_session.store(true, Ordering::SeqCst); } diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 523ed568..d053a57b 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -3,6 +3,7 @@ //! role: agent). use std::collections::VecDeque; +use std::io::BufRead as _; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; @@ -63,18 +64,28 @@ 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"; +/// Substrings claude-code emits when a `--resume <title>` target can't be +/// resolved: either no session carries our constant title yet (first turn, +/// post-archive, post-purge) or a stale id was handed in. On a hit the +/// harness re-runs the SAME prompt once with `--name <title>` to mint a +/// fresh session titled `<title>`, so the agent self-heals instead of +/// failing `--resume` forever. Empirically matched against claude 2.1.197: +/// resume-by-title miss → "…does not match any session title"; bare stale +/// id → "No conversation found with session ID". +const TITLE_NOT_FOUND_MARKERS: &[&str] = &[ + "does not match any session title", + "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"; +/// Fixed, harness-owned claude session title. Every turn / compact / +/// checkpoint resumes THIS title (`--resume <title>`); the create path +/// names it (`--name <title>`). One constant identity per agent means +/// compaction and the post-compact retry provably target the same session — +/// there is no scraped UUID to go stale, empty, or diverge. Each agent runs +/// in its own container (own `~/.claude` + own `/state` cwd), so even the +/// shared default never collides across agents. Override via +/// `HIVE_SESSION_TITLE`. +const DEFAULT_SESSION_TITLE: &str = "hive-session"; /// How long to sleep after detecting a rate-limit before re-entering the /// serve loop. Overridable via `HIVE_RATE_LIMIT_SLEEP_SECS`. Default is @@ -267,9 +278,10 @@ fn compact_watermark_tokens(bus: &Bus) -> u64 { /// /// - **Auto-reset (pre-turn)** — context is large AND the prompt cache has /// gone cold (idle gap ≥ cache TTL). Resuming would re-upload the full -/// transcript uncached at the same cost as a fresh start. The harness runs -/// one checkpoint turn (agent flushes state), then arms a one-shot -/// `request_new_session` so the actual turn starts fresh. +/// transcript uncached at the same cost as a fresh start. The harness +/// archives the current session (so the next `--resume <title>` misses and +/// self-heals into a fresh `--name <title>`); no checkpoint turn runs here +/// because any turn before the archive would just re-warm the cache. /// - **Reactive (on overflow)** — `run_turn` returns `PromptTooLong`: the /// session is already past the context window and *no* turn can run on it, /// so we compact immediately and retry the same wake-up prompt once. No @@ -284,7 +296,21 @@ fn compact_watermark_tokens(bus: &Bus) -> u64 { /// /// Called once per turn by the `hive` serve loop (every agent role). pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome { - maybe_auto_reset(bus); + // Start this turn on a fresh session when either trigger fires. Both + // archive the current session at this turn boundary (no claude is mid- + // write — one claude per container, serialized by the serve loop) and + // produce the same end state, so they're mutually exclusive: an explicit + // operator reset makes the auto-reset heuristic moot for this turn. + if bus.take_session_reset() { + // Operator-requested (deferred from `POST /api/new-session`). + bus.emit(LiveEvent::Note { + text: "operator: resetting session — archiving before this turn".into(), + }); + archive_session(bus); + } else { + // Heuristic: context large AND prompt cache gone cold. + maybe_auto_reset(bus); + } let outcome = match run_turn(prompt, files, bus).await { TurnOutcome::PromptTooLong => { // Compact has its own three-flag surface (it's the same claude @@ -403,10 +429,11 @@ async fn do_compact(files: &TurnFiles, bus: &Bus) { } /// Pre-turn auto-reset check. If context is large AND the prompt cache has -/// gone cold (idle time >= cache TTL), arm `request_new_session` so the -/// next wake-up turn starts fresh. No preceding checkpoint turn — running -/// any turn before the reset would re-upload and re-warm the cache, which -/// defeats the cost-optimisation purpose entirely. +/// gone cold (idle time >= cache TTL), archive the current session so the +/// next wake-up turn's `--resume <title>` misses and self-heals into a fresh +/// `--name <title>` session. No preceding checkpoint turn — running any turn +/// before the reset would re-upload and re-warm the cache, which defeats the +/// cost-optimisation purpose entirely. fn maybe_auto_reset(bus: &Bus) { let watermark = auto_reset_watermark_tokens(bus); if watermark == 0 { @@ -437,7 +464,7 @@ fn maybe_auto_reset(bus: &Bus) { — dropping session (cache cold, fresh start is equally cheap)" ), }); - bus.request_new_session(); + archive_session(bus); } /// Emit the per-turn `TurnEnd` event + log line. Single owner so outcome @@ -575,22 +602,22 @@ 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 `--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 +/// Run one turn against the constant-title session, resuming it or creating +/// it on first use. Delegates to [`run_claude_resume_or_create`]; the session +/// is pinned by a fixed `--name`/`--resume <title>` (NOT bare `--continue`, +/// which resumes the *latest* session in this cwd and lets a `choom` session +/// hijack the live harness context — a constant title is immune since choom +/// won't carry it). 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 { - match run_claude(prompt, files, bus).await { - Ok((true, _, _)) => TurnOutcome::PromptTooLong, - Ok((_, true, _)) => TurnOutcome::RateLimited, - Ok((_, _, true)) => TurnOutcome::AuthFailed, - Ok(_) => TurnOutcome::Ok, + match run_claude_resume_or_create(prompt, files, bus).await { + Ok(ClaudeResult::PromptTooLong) => TurnOutcome::PromptTooLong, + Ok(ClaudeResult::RateLimited) => TurnOutcome::RateLimited, + Ok(ClaudeResult::AuthFailed) => TurnOutcome::AuthFailed, + // `Ok`, and a residual `TitleNotFound` (create path also missed — + // shouldn't happen) both settle as a normal completed turn. + Ok(ClaudeResult::Ok | ClaudeResult::TitleNotFound) => TurnOutcome::Ok, Err(e) => TurnOutcome::Failed(e), } } @@ -613,11 +640,21 @@ pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> TurnOutcome { bus.emit(LiveEvent::Note { text: "context overflow — running /compact on the persistent session".into(), }); - let outcome = match run_claude("/compact", files, bus).await { - Ok((true, _, _)) => TurnOutcome::PromptTooLong, - Ok((_, true, _)) => TurnOutcome::RateLimited, - Ok((_, _, true)) => TurnOutcome::AuthFailed, - Ok(_) => TurnOutcome::Ok, + // Resume-only: never create a session just to compact it. If the titled + // session doesn't exist there's genuinely nothing to compact — compacting + // a freshly-minted empty session would just print "not enough messages" + // (the historical version-B failure), so treat a title miss as a no-op Ok. + let outcome = match run_claude("/compact", files, bus, false).await { + Ok(ClaudeResult::TitleNotFound) => { + bus.emit(LiveEvent::Note { + text: "no titled session to compact — skipping".into(), + }); + TurnOutcome::Ok + } + Ok(ClaudeResult::PromptTooLong) => TurnOutcome::PromptTooLong, + Ok(ClaudeResult::RateLimited) => TurnOutcome::RateLimited, + Ok(ClaudeResult::AuthFailed) => TurnOutcome::AuthFailed, + Ok(ClaudeResult::Ok) => TurnOutcome::Ok, Err(e) => TurnOutcome::Failed(e), }; match &outcome { @@ -640,45 +677,190 @@ pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> TurnOutcome { outcome } +/// The recognized outcome of one `run_claude` invocation. Genuine failures +/// (spawn error, non-zero exit with no recognized sentinel) come back as +/// `Err` from `run_claude`; this enum captures every non-error outcome the +/// stdout/stderr classifier can distinguish. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ClaudeResult { + /// Turn completed with no sentinel raised. + Ok, + /// `Prompt is too long` — the session is past the context window. + PromptTooLong, + /// API refused for rate-limit / usage-cap / credit reasons. + RateLimited, + /// API rejected with 401 (OAuth session expired/revoked). + AuthFailed, + /// `--resume <title>` matched no session (bootstrap / post-archive / + /// post-purge). Drives the one-shot `--name <title>` create retry in + /// [`run_claude_resume_or_create`]. + TitleNotFound, +} + +/// The constant session title for this agent. `HIVE_SESSION_TITLE` overrides +/// the compiled-in [`DEFAULT_SESSION_TITLE`]; each agent runs in its own +/// container (own `~/.claude` + own `/state` cwd), so even the shared default +/// never collides across agents. +#[must_use] +pub fn session_title() -> String { + std::env::var("HIVE_SESSION_TITLE") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_SESSION_TITLE.to_string()) +} + +/// The cwd claude is spawned in (mirrors `run_claude`): the agent's durable +/// `/state` dir when it exists, else the harness process cwd. Claude derives +/// its per-project session dir from this path. +fn session_cwd() -> PathBuf { + let state_dir = crate::paths::state_dir(); + if state_dir.is_dir() { + state_dir + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + } +} + +/// `~/.claude/projects/<slug>` for the current cwd. Claude slugises the +/// absolute cwd by replacing every `/` and `.` with `-` (empirically verified +/// against claude 2.1.197 — e.g. `/agents/iris/state` → `-agents-iris-state`). +/// Sessions (including any `choom` sessions sharing the cwd) live here as +/// `<uuid>.jsonl`. +fn claude_project_dir() -> PathBuf { + let cwd = session_cwd(); + let slug: String = cwd + .to_string_lossy() + .chars() + .map(|c| if c == '/' || c == '.' { '-' } else { c }) + .collect(); + crate::paths::claude_dir().join("projects").join(slug) +} + +/// Find the `<uuid>.jsonl` in the current project dir whose `customTitle` +/// equals `title` (the value `--name` sets). Reads each session file +/// line-by-line and stops at the first marker hit, so a huge transcript isn't +/// slurped into memory. Returns `None` if no session carries the title +/// (bootstrap / post-archive) or the project dir is absent. Skips already +/// archived (`*.jsonl.archived`) files and any `choom` sessions that don't +/// carry our title. +fn find_session_file(title: &str) -> Option<PathBuf> { + let marker = format!("\"customTitle\":\"{title}\""); + let dir = claude_project_dir(); + for entry in std::fs::read_dir(&dir).ok()?.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + let Ok(file) = std::fs::File::open(&path) else { + continue; + }; + if std::io::BufReader::new(file) + .lines() + .map_while(Result::ok) + .any(|line| line.contains(&marker)) + { + return Some(path); + } + } + None +} + +/// Archive (do NOT delete) the harness's own session so the next turn's +/// `--resume <title>` misses and self-heals into a fresh `--name <title>` +/// session. Renames the backing `<uuid>.jsonl` → `<uuid>.jsonl.archived`, +/// which drops it out of claude's `*.jsonl` resolution glob while preserving +/// the full transcript on disk for forensics. Only the file carrying OUR +/// `customTitle` is touched — any `choom` sessions sharing the cwd are left +/// alone. Best-effort: emits a Note on success, on nothing-to-archive, and on +/// error; never fails a turn. Only ever called at a turn boundary (top of +/// `drive_turn` for an operator reset, or `maybe_auto_reset` pre-turn) so no +/// claude process holds the session file open when it's renamed. +fn archive_session(bus: &Bus) { + let title = session_title(); + let Some(path) = find_session_file(&title) else { + bus.emit(LiveEvent::Note { + text: format!( + "no existing session titled \"{title}\" to archive — next turn starts fresh" + ), + }); + return; + }; + let mut target = path.clone().into_os_string(); + target.push(".archived"); + let target = PathBuf::from(target); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?") + .to_string(); + match std::fs::rename(&path, &target) { + Ok(()) => { + tracing::info!(from = %path.display(), to = %target.display(), "archived claude session"); + bus.emit(LiveEvent::Note { + text: format!("archived session \"{title}\" ({name}) — next turn starts fresh"), + }); + } + Err(e) => { + tracing::warn!(error = %e, path = %path.display(), "failed to archive claude session"); + bus.emit(LiveEvent::Note { + text: format!("failed to archive session \"{title}\": {e}"), + }); + } + } +} + +/// Resume the constant-title session, creating it on first use. Runs +/// `--resume <title>`; if claude reports the title doesn't resolve yet +/// (bootstrap / post-archive / post-purge), re-runs the SAME prompt once with +/// `--name <title>` to mint it. This is the single self-heal rule that +/// replaces the old scrape-persist-UUID machinery. +async fn run_claude_resume_or_create( + prompt: &str, + files: &TurnFiles, + bus: &Bus, +) -> Result<ClaudeResult> { + match run_claude(prompt, files, bus, false).await? { + ClaudeResult::TitleNotFound => run_claude(prompt, files, bus, true).await, + other => Ok(other), + } +} + #[allow( clippy::too_many_lines, reason = "one linear subprocess driver: spawn claude, stream + classify \ stdout/stderr, then assemble the outcome; splitting it would \ fragment the streaming state across helpers" )] -async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, bool, bool)> { +async fn run_claude( + prompt: &str, + files: &TurnFiles, + bus: &Bus, + create: bool, +) -> Result<ClaudeResult> { // Keep the last STDERR_TAIL_LINES of stderr so a non-zero exit can // include real context in the bail message (and downstream in the // failure notification to the manager) instead of just "exit 1". const STDERR_TAIL_LINES: usize = 20; let model = bus.model(); let effort = bus.effort(); - // 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. + // Constant session identity. Every call keys on the same fixed title: + // `--resume <title>` normally, `--name <title>` on the create path (first + // use / post-archive / post-purge, driven by `run_claude_resume_or_create` + // on a title miss). We NEVER pass bare `--continue`: that resumes the + // latest session in this cwd, which a `choom` invocation (same cwd) can + // hijack — a constant title is immune since choom won't carry it. No + // scraped UUID, no persist file, so compaction + the post-compact retry + // provably target the same session. + let title = session_title(); + if create { + // Fresh session: flag it so the bin loop mints a new `sessions` row + + // stamps its id onto this turn's stats. 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: format!("creating fresh session titled \"{title}\""), }); - 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 @@ -696,8 +878,10 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, .arg(&model) .arg("--effort") .arg(&effort); - if let Some(id) = &resume_id { - cmd.arg("--resume").arg(id); + if create { + cmd.arg("--name").arg(&title); + } else { + cmd.arg("--resume").arg(&title); } cmd.arg("--system-prompt-file").arg(&files.system_prompt); cmd.arg("--mcp-config") @@ -736,21 +920,17 @@ 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>)); + // `--resume <title>` found no session carrying the title: the caller + // (`run_claude_resume_or_create`) re-runs once with `--name <title>`. + let title_not_found = Arc::new(AtomicBool::new(false)); 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 notfound_out = title_not_found.clone(); + let notfound_err = title_not_found.clone(); let bus_out = bus.clone(); let bus_err = bus.clone(); let pump_stdout = tokio::spawn(async move { @@ -778,19 +958,10 @@ 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) { + if TITLE_NOT_FOUND_MARKERS.iter().any(|m| line.contains(m)) { 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 @@ -857,7 +1028,7 @@ 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) { + if TITLE_NOT_FOUND_MARKERS.iter().any(|m| line.contains(m)) { notfound_err.store(true, Ordering::Relaxed); } // Mirror to journald so post-mortems work without the web UI @@ -882,19 +1053,15 @@ 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 is_title_not_found = title_not_found.load(Ordering::Relaxed); + // A title miss is a clean exit-1 (no session to resume yet), so it must + // not be treated as a hard failure — the caller re-runs with `--name`. + if !status.success() + && !too_long + && !is_rate_limited + && !is_auth_failed + && !is_title_not_found + { let tail = stderr_tail.lock().unwrap(); if tail.is_empty() { bail!("claude exited {status} (no stderr)"); @@ -902,7 +1069,20 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, let tail_str = tail.iter().cloned().collect::<Vec<_>>().join("\n"); bail!("claude exited {status}\nstderr tail:\n{tail_str}"); } - Ok((too_long, is_rate_limited, is_auth_failed)) + // Assemble the single recognized outcome. The failure sentinels keep + // their historical priority (too-long > rate > auth); a title miss only + // ever arises on a resume that made no model call, so it can't coincide. + Ok(if too_long { + ClaudeResult::PromptTooLong + } else if is_rate_limited { + ClaudeResult::RateLimited + } else if is_auth_failed { + ClaudeResult::AuthFailed + } else if is_title_not_found { + ClaudeResult::TitleNotFound + } else { + ClaudeResult::Ok + }) } #[cfg(test)] diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 1925ea90..f083c8bc 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -1134,23 +1134,22 @@ async fn post_compact(State(state): State<AppState>) -> Response { (axum::http::StatusCode::OK, "ok").into_response() } -/// Cancel the in-flight claude turn. Coarse-grained: shells out -/// `pkill -INT claude` since there's at most one claude per container. -/// SIGINT (not SIGTERM) so claude flushes anything in-flight and emits a -/// final result row. Emits a Note so the operator sees the cancel -/// landed; the actual state transition back to `idle` happens when -/// `run_claude` wakes up and the harness emits `TurnEnd`. -/// Arm a one-shot: the next claude turn drops `--continue`, starting a -/// fresh session. Subsequent turns resume normal `--continue` -/// behavior. Idempotent before the next turn fires — calling twice -/// still results in a single fresh start. Useful when the -/// session-resume context is poisoned (claude went off the rails, -/// hit an unrecoverable refusal, etc.) and a full reset is cheaper -/// than asking claude to forget mid-stream. +/// Request a session reset. The current session is archived (its backing +/// `<uuid>.jsonl` renamed out of claude's resolution glob) at the next turn +/// boundary, so the following turn's `--resume` misses and self-heals into a +/// freshly-named session. History is preserved on disk, not deleted. +/// +/// Deferred (a one-shot flag consumed by `drive_turn`) rather than applied +/// here: renaming the session file while a claude turn is mid-write would +/// race the live process. Between turns there is no open session file (one +/// claude per container, serialized by the serve loop), so the archive is +/// safe there. Useful when the session-resume context is poisoned (claude +/// went off the rails, hit an unrecoverable refusal, etc.) and a full reset +/// is cheaper than asking claude to forget mid-stream. async fn post_new_session(State(state): State<AppState>) -> Response { - state.bus.request_new_session(); + state.bus.request_session_reset(); state.bus.emit(crate::events::LiveEvent::Note { - text: "operator: new session armed — next turn runs without --continue".into(), + text: "operator: session reset queued — takes effect at the next turn".into(), }); (axum::http::StatusCode::OK, "ok").into_response() }