Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
88 changes: 84 additions & 4 deletions apps/decodex/src/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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")
{
Expand Down Expand Up @@ -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::<i64>().is_ok())
}

fn collect_backup_candidates(
root: &Path,
groups: &mut BTreeMap<String, Vec<BackupCandidate>>,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 [
&current_log_path,
&old_log_path,
&fresh_log_path,
&current_events_path,
&old_events_path,
&fresh_events_path,
] {
fs::write(path, b"event\n").expect("maintenance fixture should write");
}

set_file_modified(&current_log_path, old_time);
set_file_modified(&old_log_path, old_time);
set_file_modified(&fresh_log_path, fresh_time);
set_file_modified(&current_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(
Expand All @@ -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");

Expand Down
11 changes: 7 additions & 4 deletions docs/spec/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down