feat: wire memory hygiene into the heartbeat loop (#195)

* feat: wire memory hygiene into heartbeat loop (#166)

* refactor: address PR review comments for hygiene wiring

* style: fix fmt import ordering and clippy too_many_arguments warning

* fix: update heartbeat integration test to pass HygieneConfig argument

HeartbeatRunner::new() now requires a HygieneConfig as its second
argument after the hygiene wiring refactor. Pass the default config
in the integration test.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
This commit is contained in:
Nitanshu Lokhande
2026-02-20 01:13:25 +00:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Illia Polosukhin
parent 356f56f77c
commit fa64df05ff
8 changed files with 117 additions and 2 deletions
+6
View File
@@ -68,6 +68,12 @@ HEARTBEAT_INTERVAL_SECS=1800
HEARTBEAT_NOTIFY_CHANNEL=cli HEARTBEAT_NOTIFY_CHANNEL=cli
HEARTBEAT_NOTIFY_USER=default HEARTBEAT_NOTIFY_USER=default
# Memory hygiene settings (automatic cleanup of stale workspace documents)
# Runs on each heartbeat tick; identity files (IDENTITY.md, SOUL.md) are never deleted
# MEMORY_HYGIENE_ENABLED=true
# MEMORY_HYGIENE_RETENTION_DAYS=30 # delete daily/ docs older than this many days
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
# Safety settings # Safety settings
SAFETY_MAX_OUTPUT_LENGTH=100000 SAFETY_MAX_OUTPUT_LENGTH=100000
SAFETY_INJECTION_CHECK_ENABLED=true SAFETY_INJECTION_CHECK_ENABLED=true
+11
View File
@@ -85,6 +85,7 @@ pub struct Agent {
pub(super) session_manager: Arc<SessionManager>, pub(super) session_manager: Arc<SessionManager>,
pub(super) context_monitor: ContextMonitor, pub(super) context_monitor: ContextMonitor,
pub(super) heartbeat_config: Option<HeartbeatConfig>, pub(super) heartbeat_config: Option<HeartbeatConfig>,
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
pub(super) routine_config: Option<RoutineConfig>, pub(super) routine_config: Option<RoutineConfig>,
} }
@@ -93,11 +94,13 @@ impl Agent {
/// ///
/// Optionally accepts pre-created `ContextManager` and `SessionManager` for sharing /// Optionally accepts pre-created `ContextManager` and `SessionManager` for sharing
/// with external components (job tools, web gateway). Creates new ones if not provided. /// with external components (job tools, web gateway). Creates new ones if not provided.
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
config: AgentConfig, config: AgentConfig,
deps: AgentDeps, deps: AgentDeps,
channels: ChannelManager, channels: ChannelManager,
heartbeat_config: Option<HeartbeatConfig>, heartbeat_config: Option<HeartbeatConfig>,
hygiene_config: Option<crate::config::HygieneConfig>,
routine_config: Option<RoutineConfig>, routine_config: Option<RoutineConfig>,
context_manager: Option<Arc<ContextManager>>, context_manager: Option<Arc<ContextManager>>,
session_manager: Option<Arc<SessionManager>>, session_manager: Option<Arc<SessionManager>>,
@@ -127,6 +130,7 @@ impl Agent {
session_manager, session_manager,
context_monitor: ContextMonitor::new(), context_monitor: ContextMonitor::new(),
heartbeat_config, heartbeat_config,
hygiene_config,
routine_config, routine_config,
} }
} }
@@ -358,8 +362,15 @@ impl Agent {
"Heartbeat enabled with {}s interval", "Heartbeat enabled with {}s interval",
hb_config.interval_secs hb_config.interval_secs
); );
let hygiene = self
.hygiene_config
.as_ref()
.map(|h| h.to_workspace_config())
.unwrap_or_default();
Some(spawn_heartbeat( Some(spawn_heartbeat(
config, config,
hygiene,
workspace.clone(), workspace.clone(),
self.cheap_llm().clone(), self.cheap_llm().clone(),
Some(notify_tx), Some(notify_tx),
+1
View File
@@ -232,6 +232,7 @@ impl Agent {
let runner = crate::agent::HeartbeatRunner::new( let runner = crate::agent::HeartbeatRunner::new(
crate::agent::HeartbeatConfig::default(), crate::agent::HeartbeatConfig::default(),
crate::workspace::hygiene::HygieneConfig::default(),
workspace.clone(), workspace.clone(),
self.llm().clone(), self.llm().clone(),
); );
+22 -1
View File
@@ -31,6 +31,7 @@ use tokio::sync::mpsc;
use crate::channels::OutgoingResponse; use crate::channels::OutgoingResponse;
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider}; use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
use crate::workspace::Workspace; use crate::workspace::Workspace;
use crate::workspace::hygiene::HygieneConfig;
/// Configuration for the heartbeat runner. /// Configuration for the heartbeat runner.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -96,6 +97,7 @@ pub enum HeartbeatResult {
/// Heartbeat runner for proactive periodic execution. /// Heartbeat runner for proactive periodic execution.
pub struct HeartbeatRunner { pub struct HeartbeatRunner {
config: HeartbeatConfig, config: HeartbeatConfig,
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>, workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>, llm: Arc<dyn LlmProvider>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>, response_tx: Option<mpsc::Sender<OutgoingResponse>>,
@@ -106,11 +108,13 @@ impl HeartbeatRunner {
/// Create a new heartbeat runner. /// Create a new heartbeat runner.
pub fn new( pub fn new(
config: HeartbeatConfig, config: HeartbeatConfig,
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>, workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>, llm: Arc<dyn LlmProvider>,
) -> Self { ) -> Self {
Self { Self {
config, config,
hygiene_config,
workspace, workspace,
llm, llm,
response_tx: None, response_tx: None,
@@ -145,6 +149,22 @@ impl HeartbeatRunner {
loop { loop {
interval.tick().await; interval.tick().await;
// Run memory hygiene in the background so it never delays the
// heartbeat checklist. Failures are logged inside run_if_due.
let hygiene_workspace = Arc::clone(&self.workspace);
let hygiene_config = self.hygiene_config.clone();
tokio::spawn(async move {
let report =
crate::workspace::hygiene::run_if_due(&hygiene_workspace, &hygiene_config)
.await;
if report.had_work() {
tracing::info!(
daily_logs_deleted = report.daily_logs_deleted,
"heartbeat: memory hygiene deleted stale documents"
);
}
});
match self.check_heartbeat().await { match self.check_heartbeat().await {
HeartbeatResult::Ok => { HeartbeatResult::Ok => {
tracing::debug!("Heartbeat OK"); tracing::debug!("Heartbeat OK");
@@ -332,11 +352,12 @@ fn strip_html_comments(content: &str) -> String {
/// Returns a handle that can be used to stop the runner. /// Returns a handle that can be used to stop the runner.
pub fn spawn_heartbeat( pub fn spawn_heartbeat(
config: HeartbeatConfig, config: HeartbeatConfig,
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>, workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>, llm: Arc<dyn LlmProvider>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>, response_tx: Option<mpsc::Sender<OutgoingResponse>>,
) -> tokio::task::JoinHandle<()> { ) -> tokio::task::JoinHandle<()> {
let mut runner = HeartbeatRunner::new(config, workspace, llm); let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm);
if let Some(tx) = response_tx { if let Some(tx) = response_tx {
runner = runner.with_response_channel(tx); runner = runner.with_response_channel(tx);
} }
+70
View File
@@ -0,0 +1,70 @@
use crate::config::helpers::optional_env;
use crate::error::ConfigError;
/// Memory hygiene configuration.
///
/// Controls automatic cleanup of stale workspace documents.
/// Maps to `crate::workspace::hygiene::HygieneConfig`.
#[derive(Debug, Clone)]
pub struct HygieneConfig {
/// Whether hygiene is enabled. Env: `MEMORY_HYGIENE_ENABLED` (default: true).
pub enabled: bool,
/// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_RETENTION_DAYS` (default: 30).
pub retention_days: u32,
/// Minimum hours between hygiene passes. Env: `MEMORY_HYGIENE_CADENCE_HOURS` (default: 12).
pub cadence_hours: u32,
}
impl Default for HygieneConfig {
fn default() -> Self {
Self {
enabled: true,
retention_days: 30,
cadence_hours: 12,
}
}
}
impl HygieneConfig {
pub(crate) fn resolve() -> Result<Self, ConfigError> {
Ok(Self {
enabled: optional_env("MEMORY_HYGIENE_ENABLED")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "MEMORY_HYGIENE_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(true),
retention_days: optional_env("MEMORY_HYGIENE_RETENTION_DAYS")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "MEMORY_HYGIENE_RETENTION_DAYS".to_string(),
message: format!("must be a positive integer: {e}"),
})?
.unwrap_or(30),
cadence_hours: optional_env("MEMORY_HYGIENE_CADENCE_HOURS")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "MEMORY_HYGIENE_CADENCE_HOURS".to_string(),
message: format!("must be a positive integer: {e}"),
})?
.unwrap_or(12),
})
}
/// Convert to the workspace hygiene config, resolving the state directory
/// to the standard `~/.ironclaw` location.
pub fn to_workspace_config(&self) -> crate::workspace::hygiene::HygieneConfig {
crate::workspace::hygiene::HygieneConfig {
enabled: self.enabled,
retention_days: self.retention_days,
cadence_hours: self.cadence_hours,
state_dir: dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".ironclaw"),
}
}
}
+4
View File
@@ -12,6 +12,7 @@ mod database;
mod embeddings; mod embeddings;
mod heartbeat; mod heartbeat;
pub(crate) mod helpers; pub(crate) mod helpers;
mod hygiene;
mod llm; mod llm;
mod routines; mod routines;
mod safety; mod safety;
@@ -34,6 +35,7 @@ pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig};
pub use self::database::{DatabaseBackend, DatabaseConfig, default_libsql_path}; pub use self::database::{DatabaseBackend, DatabaseConfig, default_libsql_path};
pub use self::embeddings::EmbeddingsConfig; pub use self::embeddings::EmbeddingsConfig;
pub use self::heartbeat::HeartbeatConfig; pub use self::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig;
pub use self::llm::{ pub use self::llm::{
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiApiMode, NearAiConfig, OllamaConfig, AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiApiMode, NearAiConfig, OllamaConfig,
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig, OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
@@ -67,6 +69,7 @@ pub struct Config {
pub secrets: SecretsConfig, pub secrets: SecretsConfig,
pub builder: BuilderModeConfig, pub builder: BuilderModeConfig,
pub heartbeat: HeartbeatConfig, pub heartbeat: HeartbeatConfig,
pub hygiene: HygieneConfig,
pub routines: RoutineConfig, pub routines: RoutineConfig,
pub sandbox: SandboxModeConfig, pub sandbox: SandboxModeConfig,
pub claude_code: ClaudeCodeConfig, pub claude_code: ClaudeCodeConfig,
@@ -190,6 +193,7 @@ impl Config {
secrets: SecretsConfig::resolve().await?, secrets: SecretsConfig::resolve().await?,
builder: BuilderModeConfig::resolve()?, builder: BuilderModeConfig::resolve()?,
heartbeat: HeartbeatConfig::resolve(settings)?, heartbeat: HeartbeatConfig::resolve(settings)?,
hygiene: HygieneConfig::resolve()?,
routines: RoutineConfig::resolve()?, routines: RoutineConfig::resolve()?,
sandbox: SandboxModeConfig::resolve()?, sandbox: SandboxModeConfig::resolve()?,
claude_code: ClaudeCodeConfig::resolve()?, claude_code: ClaudeCodeConfig::resolve()?,
+1
View File
@@ -1496,6 +1496,7 @@ async fn main() -> anyhow::Result<()> {
deps, deps,
channels, channels,
Some(config.heartbeat.clone()), Some(config.heartbeat.clone()),
Some(config.hygiene.clone()),
Some(config.routines.clone()), Some(config.routines.clone()),
Some(context_manager), Some(context_manager),
Some(session_manager), Some(session_manager),
+2 -1
View File
@@ -95,7 +95,8 @@ async fn test_heartbeat_end_to_end() {
println!("[6/6] Running check_heartbeat()...\n"); println!("[6/6] Running check_heartbeat()...\n");
let hb_config = ironclaw::agent::HeartbeatConfig::default(); let hb_config = ironclaw::agent::HeartbeatConfig::default();
let runner = HeartbeatRunner::new(hb_config, workspace, llm); let hygiene_config = ironclaw::workspace::hygiene::HygieneConfig::default();
let runner = HeartbeatRunner::new(hb_config, hygiene_config, workspace, llm);
let result = runner.check_heartbeat().await; let result = runner.check_heartbeat().await;