Files
huskies/server/src/agents/session_store.rs
T

222 lines
8.2 KiB
Rust
Raw Normal View History

//! Persistent session store — tracks the last Claude Code session_id per
//! (story_id, agent_name, model) triple so respawned agents can resume prior reasoning.
//!
//! The session_id is extracted from the `Done. Session: Some(<uuid>)` log entry
//! emitted at agent shutdown. When the same (story, agent, model) triple is
//! spawned again, the orchestrator passes `--resume <session_id>` so the new
//! session inherits the prior conversation context.
//!
//! Model is part of the key intentionally: resuming across models is not
//! supported (e.g. opus should not resume a sonnet session).
use std::collections::HashMap;
use std::path::Path;
/// Composite key for the session store: `{story_id}:{agent_name}:{model}`.
fn session_key(story_id: &str, agent_name: &str, model: &str) -> String {
format!("{story_id}:{agent_name}:{model}")
}
/// Path to the persistent session store file.
fn store_path(project_root: &Path) -> std::path::PathBuf {
project_root.join(".huskies/session_store.json")
}
/// Read the session store from disk. Returns an empty map if the file is
/// missing, empty, or corrupt.
fn read_store(project_root: &Path) -> HashMap<String, String> {
let path = store_path(project_root);
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
/// Write the session store to disk. Silently ignores write errors (the store
/// is best-effort — a missed resume just means a fresh session starts).
fn write_store(project_root: &Path, data: &HashMap<String, String>) {
let path = store_path(project_root);
if let Ok(json) = serde_json::to_string_pretty(data) {
let _ = std::fs::write(path, json);
}
}
/// Record the session_id from a completed agent run, persisted to disk.
///
/// Called after an agent process exits with a valid session_id. The next
/// spawn of the same (story_id, agent_name, model) triple will find this
/// session and pass `--resume <session_id>`.
pub fn record_session(
project_root: &Path,
story_id: &str,
agent_name: &str,
model: &str,
session_id: &str,
) {
let key = session_key(story_id, agent_name, model);
let mut data = read_store(project_root);
data.insert(key, session_id.to_string());
write_store(project_root, &data);
}
/// Look up the last session_id for a (story_id, agent_name, model) triple.
///
/// Returns `None` if no prior session exists (fresh story) or if the model
/// has changed (intentional — resuming across models is not supported).
pub fn lookup_session(
project_root: &Path,
story_id: &str,
agent_name: &str,
model: &str,
) -> Option<String> {
let key = session_key(story_id, agent_name, model);
read_store(project_root).get(&key).cloned()
}
/// Remove all session entries for a story (called when a story reaches done/archived).
#[cfg(test)]
pub fn remove_sessions_for_story(project_root: &Path, story_id: &str) {
let mut data = read_store(project_root);
let prefix = format!("{story_id}:");
let before = data.len();
data.retain(|k, _| !k.starts_with(&prefix));
if data.len() < before {
write_store(project_root, &data);
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── AC1: record and lookup round-trip ─────────────────────────────────
#[test]
fn record_and_lookup_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".huskies")).unwrap();
record_session(root, "42_story_foo", "coder-1", "sonnet", "sess-abc");
let result = lookup_session(root, "42_story_foo", "coder-1", "sonnet");
assert_eq!(result, Some("sess-abc".to_string()));
}
#[test]
fn lookup_returns_none_for_unknown_story() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".huskies")).unwrap();
let result = lookup_session(root, "unknown_story", "coder-1", "sonnet");
assert_eq!(result, None);
}
// ── AC3: model change semantics ───────────────────────────────────────
/// When an operator escalates from sonnet to opus, the new opus spawn
/// must NOT resume the prior sonnet session. The key includes the model,
/// so a different model produces a cache miss.
#[test]
fn model_change_does_not_resume_prior_session() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".huskies")).unwrap();
// Record a sonnet session.
record_session(root, "42_story_foo", "coder-1", "sonnet", "sess-sonnet");
// Looking up with opus model returns None — no resume.
let result = lookup_session(root, "42_story_foo", "coder-1", "opus");
assert_eq!(result, None, "opus must not resume a sonnet session");
// Looking up with sonnet still works.
let result = lookup_session(root, "42_story_foo", "coder-1", "sonnet");
assert_eq!(result, Some("sess-sonnet".to_string()));
}
// ── AC9: no prior session → fresh start ───────────────────────────────
#[test]
fn no_prior_session_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".huskies")).unwrap();
// Empty store — every lookup is None.
assert_eq!(
lookup_session(root, "99_story_new", "coder-1", "sonnet"),
None
);
}
// ── AC1: persistence across "restarts" (re-read from disk) ────────────
#[test]
fn session_survives_store_reload() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".huskies")).unwrap();
record_session(root, "42_story_foo", "coder-1", "sonnet", "sess-persist");
// Simulate restart: create a fresh store read.
let result = lookup_session(root, "42_story_foo", "coder-1", "sonnet");
assert_eq!(result, Some("sess-persist".to_string()));
}
#[test]
fn record_overwrites_previous_session() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".huskies")).unwrap();
record_session(root, "42_story_foo", "coder-1", "sonnet", "sess-old");
record_session(root, "42_story_foo", "coder-1", "sonnet", "sess-new");
let result = lookup_session(root, "42_story_foo", "coder-1", "sonnet");
assert_eq!(result, Some("sess-new".to_string()));
}
#[test]
fn remove_sessions_for_story_cleans_up() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".huskies")).unwrap();
record_session(root, "42_story_foo", "coder-1", "sonnet", "sess-a");
record_session(root, "42_story_foo", "coder-2", "opus", "sess-b");
record_session(root, "99_story_bar", "coder-1", "sonnet", "sess-c");
remove_sessions_for_story(root, "42_story_foo");
assert_eq!(
lookup_session(root, "42_story_foo", "coder-1", "sonnet"),
None
);
assert_eq!(
lookup_session(root, "42_story_foo", "coder-2", "opus"),
None
);
// Other story is untouched.
assert_eq!(
lookup_session(root, "99_story_bar", "coder-1", "sonnet"),
Some("sess-c".to_string())
);
}
// ── AC1: corrupt/empty store file is handled gracefully ───────────────
#[test]
fn corrupt_store_file_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".huskies")).unwrap();
std::fs::write(root.join(".huskies/session_store.json"), "NOT JSON").unwrap();
let result = lookup_session(root, "42_story_foo", "coder-1", "sonnet");
assert_eq!(result, None, "corrupt store should return None, not panic");
}
}