use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::core::trusty_tools_config::{self, TrustyToolsConfig};
use crate::daemon::disk_survey_cache::{Plan, SurveyKey};
use crate::daemon::state::DaemonState;
use crate::disk::size_index::DirSize;
use crate::disk::survey::GroupBy;
use crate::disk::survey_run::{self, DiskProbes, run};
use crate::session_manager::worktree_ownership::AgentWorktreeOwner;
use crate::session_manager::worktree_reclaim::{
BranchPrState, PrIndex, pr_state_for_branch_within,
};
use crate::session_manager::worktree_reclaim_gh::GH_TIMEOUT;
use crate::session_manager::worktree_registry::ScannedWorktree;
use crate::session_manager::worktree_safety::inspect_dirt;
pub const MAX_BUDGET_SECONDS: u64 = 55;
fn clamp_budget(requested: Option<u64>) -> (Option<u64>, bool) {
match requested {
Some(s) if s > MAX_BUDGET_SECONDS => (Some(MAX_BUDGET_SECONDS), true),
other => (other, false),
}
}
fn parse_group_by(group_by: Option<&str>) -> Result<GroupBy, String> {
match group_by {
None => Ok(GroupBy::None),
Some("session") => Ok(GroupBy::Session),
Some(other) => Err(format!(
"disk_survey: unknown `group_by` value `{other}`; the only supported value is `session`"
)),
}
}
pub async fn disk_survey(
state: &Arc<DaemonState>,
project: Option<&str>,
budget_seconds: Option<u64>,
group_by: Option<&str>,
) -> Result<Value, String> {
let group = parse_group_by(group_by)?;
let (budget_seconds, budget_clamped) = clamp_budget(budget_seconds);
let key = SurveyKey {
project: project.map(str::to_string),
group_by: group_by.map(str::to_string),
};
let cache = state.disk_survey_cache();
let plan = match budget_seconds {
Some(_) => cache.plan(&key, Instant::now()),
None => Plan::Live,
};
let mut value = match plan {
Plan::Serve { survey, refresh } => {
if refresh {
spawn_background_pass(state, key, group);
}
survey
}
Plan::Live => {
let started = Instant::now();
let deadline = budget_seconds.map(|s| started + Duration::from_secs(s));
let live = survey_pass(state, project, deadline, group).await?;
let (answer, refresh) = cache.after_live(&key, live, started, Instant::now());
if refresh {
spawn_background_pass(state, key, group);
}
answer
}
};
if let Value::Object(map) = &mut value {
map.insert("budget_clamped".to_string(), Value::Bool(budget_clamped));
}
Ok(value)
}
fn spawn_background_pass(state: &Arc<DaemonState>, key: SurveyKey, group: GroupBy) {
let pass_state = Arc::clone(state);
let project = key.project.clone();
let pass = async move { survey_pass(&pass_state, project.as_deref(), None, group).await };
drop(settle_in_background(state, key, pass));
}
fn settle_in_background<F>(
state: &Arc<DaemonState>,
key: SurveyKey,
pass: F,
) -> tokio::task::JoinHandle<()>
where
F: std::future::Future<Output = Result<Value, String>> + Send + 'static,
{
let state = Arc::clone(state);
tokio::spawn(async move {
let started = Instant::now();
let result = tokio::spawn(pass)
.await
.unwrap_or_else(|e| Err(format!("disk_survey: background pass panicked: {e}")));
state
.disk_survey_cache()
.after_background(&key, result, started);
})
}
async fn survey_pass(
state: &Arc<DaemonState>,
project: Option<&str>,
deadline: Option<Instant>,
group_by: GroupBy,
) -> Result<Value, String> {
let config = TrustyToolsConfig::load();
let repos_root = trusty_tools_config::workspace_root(&config);
let keep_list = trusty_tools_config::load_disk_keep_list();
let manager = state.session_manager().await;
let claims = manager.workspace_claims(None).await;
let index = state.disk_size_index();
let state_for_agents = Arc::clone(state);
let project = project.map(str::to_string);
let adopted = crate::project::adopted_anchors_under(state.framework_root());
tokio::task::spawn_blocking(move || {
let agent_state = |owner: &AgentWorktreeOwner| {
crate::daemon::services::agent_worktree_reap::delegation_state_for_agent(
&state_for_agents,
&owner.agent_id,
)
};
let indexes: RefCell<BTreeMap<PathBuf, PrIndex>> = RefCell::new(BTreeMap::new());
let pr_state = |scanned: &ScannedWorktree, left: Option<Duration>| -> BranchPrState {
pr_for(&indexes, scanned, left)
};
let measure = |path: &Path, budget: Option<Duration>| -> Option<DirSize> {
measure_within_budget(&index, path, budget)
};
let probes = DiskProbes {
pr_state: &pr_state,
claims: &claims,
agent_state: &agent_state,
dirt: &inspect_dirt,
measure: &measure,
now: &survey_run::SYSTEM_CLOCK,
};
let survey = run(
&repos_root,
&keep_list,
&probes,
deadline,
project.as_deref(),
group_by,
&adopted,
);
serde_json::to_value(survey).map_err(|e| format!("disk_survey: serialize error: {e}"))
})
.await
.map_err(|e| format!("disk_survey: the survey pass panicked: {e}"))?
}
fn measure_within_budget(
index: &parking_lot::Mutex<crate::disk::size_index::DirSizeIndex>,
path: &Path,
budget: Option<Duration>,
) -> Option<DirSize> {
let Some(budget) = budget else {
return survey_run::measure(&mut index.lock(), path, None);
};
let asked = Instant::now();
let mut index = index.try_lock_for(budget)?;
let left = budget.saturating_sub(asked.elapsed());
survey_run::measure(&mut index, path, Some(left))
}
fn pr_for(
indexes: &RefCell<BTreeMap<PathBuf, PrIndex>>,
scanned: &ScannedWorktree,
left: Option<Duration>,
) -> BranchPrState {
let until = left.map(|l| Instant::now() + l);
let mut cache = indexes.borrow_mut();
let index = cache
.entry(scanned.registry_root.clone())
.or_insert_with(|| PrIndex::from_gh_within(&scanned.registry_root, gh_budget(until)));
let pr = index.state_for(scanned.branch.as_deref());
if matches!(
pr,
BranchPrState::Unknown | BranchPrState::LookupFailed { .. }
) && !index.is_complete()
&& let Some(branch) = scanned.branch.as_deref()
{
let budget = gh_budget(until);
if !budget.is_zero() {
return pr_state_for_branch_within(&scanned.registry_root, branch, budget);
}
}
pr
}
fn gh_budget(until: Option<Instant>) -> Duration {
match until {
None => GH_TIMEOUT,
Some(d) => d
.checked_duration_since(Instant::now())
.unwrap_or(Duration::ZERO)
.min(GH_TIMEOUT),
}
}
#[cfg(test)]
#[path = "mcp_disk_cache_tests.rs"]
mod mcp_disk_cache_tests;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gh_budget_never_exceeds_the_fixed_ceiling() {
let generous = Instant::now() + GH_TIMEOUT * 10;
assert_eq!(gh_budget(Some(generous)), GH_TIMEOUT);
assert_eq!(gh_budget(None), GH_TIMEOUT);
}
#[test]
fn gh_budget_is_zero_once_the_survey_deadline_has_passed() {
let spent = Instant::now() - Duration::from_secs(1);
assert_eq!(gh_budget(Some(spent)), Duration::ZERO);
}
#[test]
fn a_budget_past_the_bridge_timeout_is_clamped() {
assert_eq!(clamp_budget(Some(120)), (Some(MAX_BUDGET_SECONDS), true));
assert_eq!(
Duration::from_secs(MAX_BUDGET_SECONDS),
Duration::from_secs(55),
"the clamp the schema's `maximum` advertises"
);
}
#[test]
fn a_budget_inside_the_bridge_timeout_is_untouched() {
assert_eq!(clamp_budget(Some(30)), (Some(30), false));
assert_eq!(
clamp_budget(Some(MAX_BUDGET_SECONDS)),
(Some(MAX_BUDGET_SECONDS), false),
"the ceiling itself is not a clamp"
);
assert_eq!(clamp_budget(None), (None, false));
}
#[test]
fn group_by_session_is_the_one_accepted_value() {
assert_eq!(parse_group_by(None), Ok(GroupBy::None));
assert_eq!(parse_group_by(Some("session")), Ok(GroupBy::Session));
}
#[test]
fn an_unknown_group_by_is_rejected() {
let err = parse_group_by(Some("project")).expect_err("unknown values must not be accepted");
assert!(err.contains("project"), "{err}");
assert!(
err.contains("session"),
"the error must name what IS accepted: {err}"
);
}
#[test]
fn a_held_index_costs_a_budgeted_measurement_no_more_than_its_budget() {
let index = parking_lot::Mutex::new(crate::disk::size_index::DirSizeIndex::new());
let dir = tempfile::tempdir().expect("temp dir");
let held = index.lock();
let started = Instant::now();
let got = measure_within_budget(&index, dir.path(), Some(Duration::from_millis(100)));
assert!(got.is_none(), "a held index must yield no figure: {got:?}");
assert!(
started.elapsed() < Duration::from_secs(5),
"waited {:?}",
started.elapsed()
);
drop(held);
let free = measure_within_budget(&index, dir.path(), Some(Duration::from_secs(5)));
assert!(free.is_some(), "a free index measures");
}
#[test]
fn gh_budget_hands_back_the_time_that_is_actually_left() {
let tight = Instant::now() + Duration::from_millis(200);
let budget = gh_budget(Some(tight));
assert!(!budget.is_zero(), "{budget:?}");
assert!(budget <= Duration::from_millis(200), "{budget:?}");
}
}