diff --git a/README.md b/README.md index 192047985..ac45531a8 100644 --- a/README.md +++ b/README.md @@ -200,9 +200,10 @@ repair agents. oversized local logs and agent-evidence event streams, prune old backup files, compact old terminal-run protocol events after preserving their summary, and checkpoint the SQLite WAL. `decodex serve` also runs the auto-safe subset at startup and periodically -while it is polling, including 14-day protocol-event compaction for terminal unowned -runs after the compact summary is preserved. If the runtime database is busy or -candidate detection fails, serve logs a warning and continues polling. +while it is polling, including 14-day retention for rotated logs and agent-evidence +event streams plus 14-day protocol-event compaction for terminal unowned runs after +the compact summary is preserved. If the runtime database is busy or candidate +detection fails, serve logs a warning and continues polling. ## Static Site diff --git a/apps/decodex/src/maintenance.rs b/apps/decodex/src/maintenance.rs index 5b2296e59..922b2e844 100644 --- a/apps/decodex/src/maintenance.rs +++ b/apps/decodex/src/maintenance.rs @@ -18,9 +18,9 @@ use crate::{ }; const DEFAULT_LOG_ROTATE_BYTES: u64 = 10 * 1_024 * 1_024; -const DEFAULT_LOG_RETENTION_DAYS: u64 = 30; +const DEFAULT_LOG_RETENTION_DAYS: u64 = 14; const DEFAULT_EVIDENCE_ROTATE_BYTES: u64 = 10 * 1_024 * 1_024; -const DEFAULT_EVIDENCE_RETENTION_DAYS: u64 = 30; +const DEFAULT_EVIDENCE_RETENTION_DAYS: u64 = 14; const DEFAULT_PROTOCOL_EVENT_RETENTION_DAYS: i64 = 14; const DEFAULT_BACKUP_KEEP_RECENT: usize = 3; const DEFAULT_BACKUP_RETENTION_DAYS: u64 = 7; @@ -276,7 +276,9 @@ fn maintain_logs( let metadata = entry.metadata()?; let size = metadata.len(); - if file_is_older_than(&metadata, system_now, policy.log_retention) { + if is_rotated_log_file(&path) + && file_is_older_than(&metadata, system_now, policy.log_retention) + { report.delete_candidates += 1; report.delete_bytes = report.delete_bytes.saturating_add(size); @@ -387,6 +389,7 @@ fn maintain_agent_evidence( }; if !path.is_file() + || file_name == "events.jsonl" || !file_name.starts_with("events.") || path.extension().and_then(|extension| extension.to_str()) != Some("jsonl") { @@ -817,6 +820,13 @@ fn file_is_older_than(metadata: &Metadata, system_now: SystemTime, retention: Du .is_some_and(|age| age > retention) } +fn is_rotated_log_file(path: &Path) -> bool { + path.file_stem() + .and_then(|stem| stem.to_str()) + .and_then(|stem| stem.rsplit_once('.').map(|(_, timestamp)| timestamp)) + .is_some_and(|timestamp| timestamp.parse::().is_ok()) +} + fn collect_backup_candidates( root: &Path, groups: &mut BTreeMap>, @@ -913,7 +923,11 @@ fn print_prune_report(report: &MaintenanceReport) -> Result<()> { #[cfg(test)] mod tests { - use std::fs; + use std::{ + fs::{self, FileTimes, OpenOptions}, + path::Path, + time::{Duration, SystemTime}, + }; use rusqlite::OptionalExtension as _; use tempfile::TempDir; @@ -1224,6 +1238,63 @@ mod tests { ); } + #[test] + fn prune_deletes_only_rotated_logs_and_agent_evidence_after_fourteen_days() { + let temp_dir = TempDir::new().expect("temp dir should create"); + let _home_guard = + TestEnvVarGuard::set("HOME", temp_dir.path().to_str().expect("home should be utf-8")); + let log_dir = temp_dir.path().join(".codex/decodex/logs"); + let evidence_dir = temp_dir.path().join(".codex/decodex/agent-evidence/decodex"); + let current_log_path = log_dir.join("decodex.log"); + let old_log_path = log_dir.join("decodex.1.log"); + let fresh_log_path = log_dir.join("decodex.2.log"); + let current_events_path = evidence_dir.join("events.jsonl"); + let old_events_path = evidence_dir.join("events.1.jsonl"); + let fresh_events_path = evidence_dir.join("events.2.jsonl"); + let old_time = SystemTime::now() - Duration::from_secs(15 * 24 * 60 * 60); + let fresh_time = SystemTime::now() - Duration::from_secs(2 * 24 * 60 * 60); + + fs::create_dir_all(&log_dir).expect("log dir should create"); + fs::create_dir_all(&evidence_dir).expect("evidence dir should create"); + + for path in [ + ¤t_log_path, + &old_log_path, + &fresh_log_path, + ¤t_events_path, + &old_events_path, + &fresh_events_path, + ] { + fs::write(path, b"event\n").expect("maintenance fixture should write"); + } + + set_file_modified(¤t_log_path, old_time); + set_file_modified(&old_log_path, old_time); + set_file_modified(&fresh_log_path, fresh_time); + set_file_modified(¤t_events_path, old_time); + set_file_modified(&old_events_path, old_time); + set_file_modified(&fresh_events_path, fresh_time); + + let report = maintenance::run_prune_with_policy( + MaintenancePruneRequest { + mode: MaintenanceMode::Apply, + scope: MaintenanceScope::AutoSafe, + json: false, + }, + MaintenancePolicy::default(), + ) + .expect("maintenance should run"); + + assert_eq!(report.logs.deleted_files, 1); + assert_eq!(report.agent_evidence.deleted_files, 1); + assert!(current_log_path.exists()); + assert!(!old_log_path.exists()); + assert!(fresh_log_path.exists()); + assert!(current_events_path.exists()); + assert!(!old_events_path.exists()); + assert!(fresh_events_path.exists()); + } + fn insert_attempt(connection: &Connection, run_id: &str, issue_id: &str, status: &str) { connection .execute( @@ -1235,6 +1306,15 @@ mod tests { .expect("attempt should insert"); } + fn set_file_modified(path: &Path, modified: SystemTime) { + OpenOptions::new() + .write(true) + .open(path) + .expect("file should open for timestamp update") + .set_times(FileTimes::new().set_modified(modified)) + .expect("file modified time should update"); + } + fn bootstrap_test_runtime_db(temp_dir: &TempDir) -> Connection { let decodex_home = temp_dir.path().join(".codex/decodex"); diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index e604ae642..f31a88bff 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -646,14 +646,17 @@ After a process restart, recent-run history, active lease ownership, retained po local cleanup candidates without applying retention changes. The `--apply` mode owns state-aware protocol-event compaction, old backup pruning, local log and agent-evidence event-stream rotation, + deletion of rotated local logs and agent-evidence event streams older than 14 days, and SQLite WAL checkpointing. Operators must not delete `runtime.sqlite3-wal` directly. - `decodex serve` runs the auto-safe maintenance subset at startup and periodically while polling. That subset may rotate oversized local files, prune old backups, - compact only safe terminal protocol-event rows behind the 14-day boundary, and run a - passive WAL checkpoint. If SQLite is busy or protocol-event candidate detection - fails, the auto-safe path must record a warning and continue without blocking - scheduler health. + delete rotated local logs and agent-evidence event streams older than 14 days, + compact only safe terminal protocol-event rows behind the 14-day boundary, and run + a passive WAL checkpoint. Current local log files and current `events.jsonl` streams + are rotation inputs, not age-deletion candidates. If SQLite is busy or + protocol-event candidate detection fails, the auto-safe path must record a warning + and continue without blocking scheduler health. - Worktrees: retain while the issue is non-terminal, and also retain terminal owned lanes while authoritative post-merge closeout or deterministic cleanup is still incomplete. - Worktree mappings must carry durable local provenance. New runtime-recorded mappings use `provenance_source = "runtime_recorded"` with created and updated Unix