use std::path::Path;
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::session_manager::worktree_ownership::AgentDelegationState;
use crate::session_manager::worktree_reclaim::{LiveClaims, PrIndex, ReclaimSurvey};
use crate::session_manager::worktree_reclaim_sweep::{SurveyBudget, survey_with_index};
const SURVEY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
const SURVEY_TIMEOUT_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
use crate::disk::human_bytes;
fn build_worktree_disk_check(survey: &ReclaimSurvey) -> DoctorCheck {
let total = survey.candidates.len();
if total == 0 {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Ok,
"no git-registered worktrees under the managed workspace root",
);
}
let (measured, undercount) = if survey.unmeasured > 0 {
(
format!(
"total disk use UNKNOWN — at least {} measured across {} of {total} worktree(s)",
human_bytes(survey.total_bytes),
total.saturating_sub(survey.unmeasured)
),
format!(
" — an UNDERCOUNT: {} worktree(s) went unmeasured inside this probe's {}s \
budget, so this run is PARTIAL and establishes no total; \
`tm session prune-worktrees --merged-prs` surveys with no deadline for \
exact figures",
survey.unmeasured,
SURVEY_TIMEOUT.as_secs()
),
)
} else {
(
format!(
"{} across {total} worktree(s)",
human_bytes(survey.total_bytes)
),
String::new(),
)
};
let lookup_unknown = survey.pr_state_unknown.saturating_sub(survey.not_inspected);
let mut clauses = String::new();
if survey.not_inspected > 0 {
clauses.push_str(&format!(
"; {} worktree(s) were NOT INSPECTED before this probe's {}s classify budget \
expired — that is a budget limit, not a `gh` problem",
survey.not_inspected,
SURVEY_TIMEOUT.as_secs()
));
}
if lookup_unknown > 0 {
clauses.push_str(&format!(
"; {lookup_unknown} worktree(s) were inspected but their pull-request state did \
not resolve — a detached HEAD, a repository this `gh` account cannot see, or a \
branch older than the most recent {} pull requests this probe reads; \
`tm session prune-worktrees --merged-prs` resolves that last case \
branch-by-branch",
crate::session_manager::worktree_reclaim::PR_INDEX_LIMIT
));
}
if survey.lookup_failed > 0 {
clauses.push_str(&format!(
"; the pull-request lookup FAILED for {} worktree(s) — {}",
survey.lookup_failed,
survey
.lookup_failure
.as_deref()
.unwrap_or("no reason given")
));
}
let window = clauses;
if survey.pr_state_unknown + survey.lookup_failed == total {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Unknown,
format!(
"{measured}{undercount}{window}; pull-request state resolved for NONE of \
them, so nothing could be classified reclaimable — check that `gh` is \
installed and authenticated for the DAEMON's environment, which \
inherits neither `GH_TOKEN` nor `GH_CONFIG_DIR` (#2919, #6561)"
),
);
}
if survey.reclaimable > 0 {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Warn,
format!(
"{measured}{undercount}; {} across {} of {} worktree(s) measured sits on \
branches whose pull request already merged and holds no uncommitted or \
unpushed work — reclaim with `tm session prune-worktrees \
--merged-prs`{window} (#2919)",
human_bytes(survey.reclaimable_bytes),
survey.reclaimable_measured,
survey.reclaimable
),
);
}
if survey.unmeasured > 0 {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Unknown,
format!(
"{measured}{undercount}{window}; total disk use is therefore UNDETERMINED (#2919)"
),
);
}
DoctorCheck::new(
"worktree_disk",
CheckStatus::Ok,
format!("{measured}{undercount}{window}; none currently reclaimable"),
)
}
pub(super) async fn check_worktree_disk(
repos_root: Option<&Path>,
active: &LiveClaims,
) -> DoctorCheck {
check_worktree_disk_with_index(
repos_root,
active,
PrIndex::from_gh,
&crate::project::default_adopted_anchors(),
)
.await
}
async fn check_worktree_disk_with_index<F>(
repos_root: Option<&Path>,
active: &LiveClaims,
index_for: F,
adopted: &[std::path::PathBuf],
) -> DoctorCheck
where
F: Fn(&Path) -> PrIndex + Send + 'static,
{
let Some(root) = repos_root else {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Ok,
"no managed workspace root configured — worktree disk accounting skipped",
);
};
if !root.exists() {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Ok,
format!(
"{} does not exist — no worktrees to account for",
root.display()
),
);
}
let root = root.to_path_buf();
let active = active.clone();
let adopted = adopted.to_vec();
let deadline = std::time::Instant::now() + SURVEY_TIMEOUT;
let budget = SurveyBudget {
measure: Some(SURVEY_TIMEOUT),
classify: Some(deadline),
};
let joined = tokio::time::timeout(
SURVEY_TIMEOUT + SURVEY_TIMEOUT + SURVEY_TIMEOUT_GRACE,
tokio::task::spawn_blocking(move || {
let keep_list = crate::core::trusty_tools_config::load_disk_keep_list();
survey_with_index(
&root,
&active,
&index_for,
&|_| AgentDelegationState::Unknown,
budget,
false,
&keep_list,
&adopted,
)
}),
)
.await;
match joined {
Ok(Ok(result)) => build_worktree_disk_check(&result),
Ok(Err(e)) => {
tracing::error!("doctor: worktree disk survey panicked: {e}");
DoctorCheck::new(
"worktree_disk",
CheckStatus::Unknown,
format!("worktree disk survey panicked ({e}) — disk state undetermined"),
)
}
Err(_) => DoctorCheck::new(
"worktree_disk",
CheckStatus::Unknown,
format!(
"worktree disk survey exceeded its {}s ceiling ({}s classify + {}s \
measure + {}s subprocess grace) — disk state undetermined (#2919)",
(SURVEY_TIMEOUT + SURVEY_TIMEOUT + SURVEY_TIMEOUT_GRACE).as_secs(),
SURVEY_TIMEOUT.as_secs(),
SURVEY_TIMEOUT.as_secs(),
SURVEY_TIMEOUT_GRACE.as_secs()
),
),
}
}
#[cfg(test)]
#[path = "doctor_worktree_disk_tests.rs"]
mod doctor_worktree_disk_tests;