Serialized AgentCycleState for Claude Code hook path

SavedAgentState (JSON) persists agent pid/phase/log_path across
hook invocations. The Claude Code hook loads saved state, runs
cycles, saves back. Pids are liveness-checked with kill(pid, 0)
on load. No more scan_pid_files for agent lifecycle tracking.

poc-agent keeps everything in memory (child handles). The hook
path uses serialized state. Same AgentCycleState, different
persistence model.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-02 01:31:59 -04:00
parent 90d2717423
commit fbc8572840

View file

@ -143,10 +143,10 @@ pub struct AgentInfo {
child: Option<std::process::Child>, child: Option<std::process::Child>,
} }
/// Snapshot of agent state for sending to TUI (no Child handle). /// Snapshot of agent state — serializable, sendable to TUI.
#[derive(Clone, Debug)] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AgentSnapshot { pub struct AgentSnapshot {
pub name: &'static str, pub name: String,
pub pid: Option<u32>, pub pid: Option<u32>,
pub phase: Option<String>, pub phase: Option<String>,
pub log_path: Option<std::path::PathBuf>, pub log_path: Option<std::path::PathBuf>,
@ -155,7 +155,7 @@ pub struct AgentSnapshot {
impl AgentInfo { impl AgentInfo {
fn snapshot(&self) -> AgentSnapshot { fn snapshot(&self) -> AgentSnapshot {
AgentSnapshot { AgentSnapshot {
name: self.name, name: self.name.to_string(),
pid: self.pid, pid: self.pid,
phase: self.phase.clone(), phase: self.phase.clone(),
log_path: self.log_path.clone(), log_path: self.log_path.clone(),
@ -163,6 +163,47 @@ impl AgentInfo {
} }
} }
/// Serializable state for persisting across Claude Code hook invocations.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct SavedAgentState {
pub agents: Vec<AgentSnapshot>,
}
impl SavedAgentState {
fn state_path(session_id: &str) -> std::path::PathBuf {
let dir = dirs::home_dir().unwrap_or_default().join(".consciousness/sessions");
fs::create_dir_all(&dir).ok();
dir.join(format!("agent-state-{}.json", session_id))
}
pub fn load(session_id: &str) -> Self {
let path = Self::state_path(session_id);
let mut state: Self = fs::read_to_string(&path).ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(SavedAgentState { agents: Vec::new() });
// Check if saved pids are still alive
for agent in &mut state.agents {
if let Some(pid) = agent.pid {
unsafe {
if libc::kill(pid as i32, 0) != 0 {
agent.pid = None;
agent.phase = None;
}
}
}
}
state
}
pub fn save(&self, session_id: &str) {
let path = Self::state_path(session_id);
if let Ok(json) = serde_json::to_string(self) {
fs::write(path, json).ok();
}
}
}
/// Persistent state for the agent orchestration cycle. /// Persistent state for the agent orchestration cycle.
/// Created once, `trigger()` called on each user message. /// Created once, `trigger()` called on each user message.
/// TUI reads `agents` and `last_output` for display. /// TUI reads `agents` and `last_output` for display.
@ -207,7 +248,7 @@ impl AgentCycleState {
} }
fn agent_running(&self, name: &str) -> bool { fn agent_running(&self, name: &str) -> bool {
self.agents.iter().any(|a| a.name == name && a.child.is_some()) self.agents.iter().any(|a| a.name == name && a.pid.is_some())
} }
fn agent_spawned(&mut self, name: &str, phase: &str, fn agent_spawned(&mut self, name: &str, phase: &str,
@ -220,18 +261,23 @@ impl AgentCycleState {
} }
} }
/// Check if any spawned agents have completed. Reap them. /// Check if any agents have completed. Reap child handles, or
/// check pid liveness for restored-from-disk agents.
fn poll_children(&mut self) { fn poll_children(&mut self) {
for agent in &mut self.agents { for agent in &mut self.agents {
if let Some(ref mut child) = agent.child { if let Some(ref mut child) = agent.child {
match child.try_wait() { if let Ok(Some(_)) = child.try_wait() {
Ok(Some(_status)) => {
agent.pid = None; agent.pid = None;
agent.phase = None; agent.phase = None;
agent.child = None; agent.child = None;
// log_path stays — TUI can still view the log
} }
_ => {} } else if let Some(pid) = agent.pid {
// No child handle (restored from saved state) — check pid
unsafe {
if libc::kill(pid as i32, 0) != 0 {
agent.pid = None;
agent.phase = None;
}
} }
} }
} }
@ -241,6 +287,24 @@ impl AgentCycleState {
self.agents.iter().map(|a| a.snapshot()).collect() self.agents.iter().map(|a| a.snapshot()).collect()
} }
/// Restore agent state from a saved snapshot (for Claude Code hook path).
pub fn restore(&mut self, saved: &SavedAgentState) {
for sa in &saved.agents {
if let Some(agent) = self.agents.iter_mut().find(|a| a.name == sa.name) {
agent.pid = sa.pid;
agent.phase = sa.phase.clone();
agent.log_path = sa.log_path.clone();
// No child handle — we just track the pid
}
}
}
/// Save current state for the Claude Code hook path.
pub fn save(&self, session_id: &str) {
let state = SavedAgentState { agents: self.snapshots() };
state.save(session_id);
}
/// Run all agent cycles. Call on each user message. /// Run all agent cycles. Call on each user message.
pub fn trigger(&mut self, session: &HookSession) { pub fn trigger(&mut self, session: &HookSession) {
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S");
@ -258,9 +322,12 @@ impl AgentCycleState {
} }
/// Standalone entry point for the Claude Code hook path. /// Standalone entry point for the Claude Code hook path.
/// Loads saved state, runs cycles, saves state back.
pub fn run_agent_cycles(session: &HookSession) -> AgentCycleOutput { pub fn run_agent_cycles(session: &HookSession) -> AgentCycleOutput {
let mut state = AgentCycleState::new(&session.session_id); let mut state = AgentCycleState::new(&session.session_id);
state.restore(&SavedAgentState::load(&session.session_id));
state.trigger(session); state.trigger(session);
state.save(&session.session_id);
state.last_output state.last_output
} }