diff --git a/codex-rs/memories/write/src/metrics.rs b/codex-rs/memories/write/src/metrics.rs index caa77565db..408c366928 100644 --- a/codex-rs/memories/write/src/metrics.rs +++ b/codex-rs/memories/write/src/metrics.rs @@ -1,4 +1,5 @@ pub(crate) const MEMORY_STARTUP: &str = "codex.memory.startup"; +pub(crate) const MEMORY_STORAGE_BYTES: &str = "codex.memory.storage_bytes"; pub(crate) const MEMORY_PHASE_ONE_JOBS: &str = "codex.memory.phase1"; pub(crate) const MEMORY_PHASE_ONE_E2E_MS: &str = "codex.memory.phase1.e2e_ms"; diff --git a/codex-rs/memories/write/src/phase2.rs b/codex-rs/memories/write/src/phase2.rs index 82ed4d5394..d6d245f591 100644 --- a/codex-rs/memories/write/src/phase2.rs +++ b/codex-rs/memories/write/src/phase2.rs @@ -135,7 +135,7 @@ pub async fn run( { tracing::error!("Phase 2 no changes"); // We check only after sync of the file system. - job::succeed( + if job::succeed( context.as_ref(), &db, &claim, @@ -143,7 +143,11 @@ pub async fn run( &raw_memories, "succeeded_no_workspace_changes", ) - .await; + .await + { + drop(phase_two_e2e_timer); + context.record_storage_size(&root).await; + } return; } @@ -373,7 +377,6 @@ mod agent { let Some(db) = context.memory_store().await else { return; }; - let _phase_two_e2e_timer = phase_two_e2e_timer; let SpawnedConsolidationAgent { thread_id, thread } = agent; // Loop the agent until we have the final status. @@ -457,6 +460,9 @@ mod agent { tracing::error!( "failed marking global memory consolidation job succeeded after resetting workspace baseline" ); + } else { + drop(phase_two_e2e_timer); + context.record_storage_size(&memory_root).await; } } } else if !agent_completed { diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 3efdfb21c5..83d2d205bb 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -1,3 +1,5 @@ +use crate::metrics::MEMORY_STORAGE_BYTES; +use crate::workspace::memory_storage_bytes; use codex_core::CodexThread; use codex_core::ModelClient; use codex_core::NewThread; @@ -38,6 +40,7 @@ use codex_rollout_trace::InferenceTraceContext; use codex_state::MemoryStore; use codex_terminal_detection::user_agent; use futures::StreamExt; +use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -210,6 +213,36 @@ impl MemoryStartupContext { self.thread_id } + pub(crate) async fn record_storage_size(&self, root: &Path) { + let bytes = match memory_storage_bytes(root).await { + Ok(bytes) => bytes, + Err(err) => { + tracing::warn!("failed measuring memory storage size: {err}"); + return; + } + }; + self.session_telemetry.histogram_with_boundaries( + MEMORY_STORAGE_BYTES, + i64::try_from(bytes).unwrap_or(i64::MAX), + // Log-spaced byte buckets cover small summaries through large memory collections. + &[ + 0.0, + 1_024.0, + 4_096.0, + 16_384.0, + 65_536.0, + 262_144.0, + 1_048_576.0, + 4_194_304.0, + 16_777_216.0, + 67_108_864.0, + 268_435_456.0, + 1_073_741_824.0, + ], + &memory_metric_tags(self.version, &[]), + ); + } + pub(crate) async fn memory_store(&self) -> Option { match self .thread diff --git a/codex-rs/memories/write/src/workspace.rs b/codex-rs/memories/write/src/workspace.rs index e7081fa351..76b9fda9ad 100644 --- a/codex-rs/memories/write/src/workspace.rs +++ b/codex-rs/memories/write/src/workspace.rs @@ -48,6 +48,26 @@ pub async fn reset_memory_workspace_baseline(root: &Path) -> anyhow::Result<()> reset_git_repository(root).await } +/// Sums regular-file contents without reading them, excluding git metadata and symbolic links. +pub(crate) async fn memory_storage_bytes(root: &Path) -> std::io::Result { + let mut paths = vec![root.to_path_buf()]; + let mut bytes = 0_u64; + while let Some(path) = paths.pop() { + let metadata = tokio::fs::symlink_metadata(&path).await?; + if metadata.is_file() { + bytes = bytes.saturating_add(metadata.len()); + } else if metadata.is_dir() { + let mut entries = tokio::fs::read_dir(&path).await?; + while let Some(entry) = entries.next_entry().await? { + if entry.file_name() != ".git" { + paths.push(entry.path()); + } + } + } + } + Ok(bytes) +} + /// Verifies that a completed consolidation run left the required memory artifacts in place. pub async fn validate_consolidation_artifacts(root: &Path) -> anyhow::Result<()> { validate_consolidation_artifacts_for_version(root, MemoryVersion::V1).await diff --git a/codex-rs/memories/write/src/workspace_tests.rs b/codex-rs/memories/write/src/workspace_tests.rs index 4b0fe7a01b..96ac98f5f1 100644 --- a/codex-rs/memories/write/src/workspace_tests.rs +++ b/codex-rs/memories/write/src/workspace_tests.rs @@ -48,12 +48,51 @@ async fn reset_memory_workspace_baseline_removes_generated_diff() { .expect("reset baseline"); assert!(!root.join(crate::workspace_diff::FILENAME).exists()); + assert_eq!(memory_storage_bytes(&root).await.expect("storage size"), 6); let diff = memory_workspace_diff(&root) .await .expect("load workspace diff"); assert_eq!(diff.changes, Vec::new()); } +#[tokio::test] +async fn memory_storage_size_counts_nested_files_but_not_git_or_symlinks() -> anyhow::Result<()> { + let home = TempDir::new()?; + for version in [MemoryVersion::V1, MemoryVersion::V2] { + let root = home.path().join(version.directory_name()); + fs::create_dir_all(root.join("rollout_summaries"))?; + fs::create_dir_all(root.join("extensions/notes"))?; + fs::create_dir_all(root.join(".git/objects"))?; + fs::write( + root.join(".git/objects/baseline"), + "git metadata is not memory", + )?; + let files = [ + ("memory_summary.md", "summary"), + ("rollout_summaries/thread.md", "rollout"), + ("extensions/notes/note.md", "café"), + ]; + for (path, content) in files { + fs::write(root.join(path), content)?; + } + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + symlink(root.join("memory_summary.md"), root.join("file-link"))?; + symlink(&root, root.join("directory-loop"))?; + symlink(root.join("missing"), root.join("broken-link"))?; + } + assert_eq!( + memory_storage_bytes(&root).await?, + files + .iter() + .map(|(_, content)| content.len() as u64) + .sum::(), + ); + } + Ok(()) +} + #[tokio::test] async fn prepare_memory_workspace_recovers_unusable_git_dir() { let home = TempDir::new().expect("tempdir");