use std::sync::Arc;
use serde::{Deserialize, Serialize};
use super::delegation_records::owner_label;
use super::delegation_repair_probe::{LiveEvidence, probe_live_evidence};
use crate::core::agent::{Delegation, DelegationId, DelegationRepair};
use crate::core::session::{SessionId, SessionStatus};
use crate::daemon::state::DaemonState;
use crate::daemon::state::sessions::RUNNING_STALE_AFTER_SECS;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OwnerLiveness {
Live,
Gone,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum RepairOutcome {
NoRecord,
AlreadyEnded {
records: usize,
},
Refused {
reason: String,
},
Ended {
records: usize,
},
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct RepairDelegationRequest {
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepairCaller {
Session(SessionId),
Unestablished(String),
}
impl RepairCaller {
fn session(&self) -> Option<SessionId> {
match self {
Self::Session(s) => Some(*s),
Self::Unestablished(_) => None,
}
}
}
pub const OWNER_ATTESTED: &str = "owner-attested finished (#8257)";
pub(crate) fn decide(
open: usize,
terminal: usize,
owner: OwnerLiveness,
force: bool,
) -> RepairOutcome {
if open == 0 && terminal == 0 {
return RepairOutcome::NoRecord;
}
if open == 0 {
return RepairOutcome::AlreadyEnded { records: terminal };
}
match owner {
OwnerLiveness::Live => RepairOutcome::Refused {
reason: "the session that dispatched this agent is still Active, no stop has arrived \
for the agent, and the record is younger than the 6 h stale threshold — so \
the agent may still be running, and ending its delegation would readmit a \
second writer onto a working tree it may still hold. It becomes repairable \
once that session stops, when that session clears it itself, or at 6 h \
(#7602, #8257)"
.to_string(),
},
OwnerLiveness::Unknown if !force => RepairOutcome::Refused {
reason: "the daemon holds no record of the session that dispatched this agent, so it \
cannot confirm the owner is gone — the delegation map is rebuilt empty at \
every daemon start, and silence after a restart is undeterminable, not \
absent (ADR-0045). Re-run with --force to assert the owner is gone (#7602)"
.to_string(),
},
OwnerLiveness::Unknown | OwnerLiveness::Gone => RepairOutcome::Ended { records: open },
}
}
pub(crate) fn owner_liveness(state: &Arc<DaemonState>, session: SessionId) -> OwnerLiveness {
let Some(record) = state.session(session) else {
return OwnerLiveness::Unknown;
};
if record.status == SessionStatus::Stopped {
return OwnerLiveness::Gone;
}
match record.pid {
Some(pid) if !crate::core::process::is_process_alive(pid) => OwnerLiveness::Gone,
_ => OwnerLiveness::Live,
}
}
pub fn repair_delegation(state: &Arc<DaemonState>, agent_id: &str, force: bool) -> RepairOutcome {
repair_delegation_as(state, agent_id, force, &no_caller())
}
pub fn repair_delegation_as(
state: &Arc<DaemonState>,
agent_id: &str,
force: bool,
caller: &RepairCaller,
) -> RepairOutcome {
repair_matching(
state,
|d| d.agent_id.as_deref() == Some(agent_id),
force,
caller,
probe_live_evidence,
)
}
pub fn repair_delegation_by_id(
state: &Arc<DaemonState>,
id: DelegationId,
force: bool,
caller: &RepairCaller,
) -> RepairOutcome {
repair_matching(state, |d| d.id == id, force, caller, probe_live_evidence)
}
fn no_caller() -> RepairCaller {
RepairCaller::Unestablished("no caller session was supplied to this repair".to_string())
}
pub(crate) fn record_liveness(
owner: OwnerLiveness,
d: &Delegation,
now: chrono::DateTime<chrono::Utc>,
) -> OwnerLiveness {
let aged_out = super::delegation_records::record_age_secs(d, now) >= RUNNING_STALE_AFTER_SECS;
if aged_out { OwnerLiveness::Gone } else { owner }
}
fn owner_attests(d: &Delegation, caller: &RepairCaller, owner: &str) -> Result<(), String> {
let stop = if d.stale_by_agent_type {
"the only stop that arrived was matched by agent type and may belong to a sibling"
} else {
"no stop has arrived"
};
let head = format!(
"{} record {} is owned by {owner}, which is still Active; {stop} and the record is \
under the 6 h stale threshold, so the agent may still be running",
d.agent, d.id.0
);
match caller {
RepairCaller::Session(s) if *s == d.session => Ok(()),
RepairCaller::Session(s) => Err(format!(
"{head}. Only the owning session can clear it before its agent's own stop arrives \
or the record reaches 6 h; this repair came from session {} (#8257)",
s.0
)),
RepairCaller::Unestablished(why) => Err(format!(
"{head}. Only the owning session can clear it before its agent's own stop \
arrives or the record reaches 6 h, and the calling session could not be \
established: {why} (#8257)"
)),
}
}
pub(crate) fn repair_matching(
state: &Arc<DaemonState>,
matches: impl Fn(&Delegation) -> bool,
force: bool,
caller: &RepairCaller,
probe: impl Fn(&Delegation) -> LiveEvidence,
) -> RepairOutcome {
let now = chrono::Utc::now();
let mut open: Vec<(Delegation, &str)> = Vec::new();
let mut terminal = 0usize;
let mut owner = None;
for d in state.all_delegations() {
if !matches(&d) {
continue;
}
if d.status.is_terminal() {
terminal += 1;
continue;
}
let (liveness, basis) = match record_liveness(owner_liveness(state, d.session), &d, now) {
OwnerLiveness::Live => {
match owner_attests(&d, caller, &owner_label(state, d.session)) {
Ok(()) => (OwnerLiveness::Gone, OWNER_ATTESTED),
Err(reason) => {
tracing::warn!(
delegation_id = %d.id.0,
agent = %d.agent,
owner_session = %d.session.0,
caller = caller.session().map_or_else(|| "unestablished".to_string(), |s| s.0.to_string()),
"delegation: repair refused — only the owning session may clear a live record (#8257)"
);
return RepairOutcome::Refused { reason };
}
}
}
OwnerLiveness::Gone => (
OwnerLiveness::Gone,
"operator-ended: owner gone, agent stopped, or past 6 h (#7602, #8257)",
),
OwnerLiveness::Unknown => (
OwnerLiveness::Unknown,
"operator-forced: owner undeterminable (#7602)",
),
};
owner = Some(match (owner, liveness) {
(Some(OwnerLiveness::Unknown), _) | (_, OwnerLiveness::Unknown) => {
OwnerLiveness::Unknown
}
_ => OwnerLiveness::Gone,
});
open.push((d, basis));
}
let outcome = decide(
open.len(),
terminal,
owner.unwrap_or(OwnerLiveness::Unknown),
force,
);
if !matches!(outcome, RepairOutcome::Ended { .. }) {
return outcome;
}
for (d, _) in &open {
let reason = match probe(d) {
LiveEvidence::Clear => continue,
LiveEvidence::Held(why) => {
format!("its tree could not be shown free of live processes: {why}")
}
LiveEvidence::Undeterminable(why) => format!(
"the live-agent probe could not answer, and an unanswered probe is not proof \
the agent is gone (ADR-0045): {why}"
),
};
return RepairOutcome::Refused {
reason: format!(
"{} record {} (agent id {}): {reason} (#8257)",
d.agent,
d.id.0,
d.agent_id.as_deref().unwrap_or("none")
),
};
}
let by_session = caller.session();
for (d, basis) in &open {
let mut cleared = false;
state.mutate_delegation(d.id, |rec| {
if rec.status.is_terminal() {
return;
}
rec.status = crate::core::agent::DelegationStatus::Cancelled;
rec.ended_at = Some(now);
rec.repair = Some(DelegationRepair {
by_session,
reason: (*basis).to_string(),
at: now,
});
cleared = true;
});
if cleared {
tracing::warn!(
delegation_id = %d.id.0,
agent = %d.agent,
agent_id = d.agent_id.as_deref().unwrap_or("none"),
owner_session = %d.session.0,
by_session = by_session.map_or_else(|| "unestablished".to_string(), |s| s.0.to_string()),
basis = *basis,
forced = force,
"delegation: repair cleared a record — status Cancelled (#7602, #8257)"
);
}
}
outcome
}
#[cfg(test)]
#[path = "delegation_repair_tests.rs"]
mod delegation_repair_tests;