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

@ -137,7 +137,7 @@ if a new inbox message arrives before this turn ends"). The
```
claude --print --verbose --output-format stream-json --model <name> \
--effort <level> --continue \
--effort <level> --resume <title> # 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`)

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