fix(agent): pin claude session by constant title, archive on reset

This commit is contained in:
müde 2026-07-05 18:13:54 +02:00
commit e35acca814
4 changed files with 369 additions and 164 deletions

View file

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

View file

@ -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)]

View file

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