use std::collections::HashMap;
use std::time::{Duration, SystemTime};
use crate::runs::{RECORD_FILE, RunRecord, RunState, Runs, RunsError};
use super::flow::{Look, look_up_all, mark_gone};
use super::provision::{delete_confirmed, one_line};
use super::{
DeleteReason, DeletedBy, Pod, PodCtx, PodError, PodId, PodRecord, PodState, forget_client_key,
remove,
};
const CELL_CHARS: usize = 64;
fn cell_text(text: &str) -> String {
one_line(text, CELL_CHARS)
}
const STRAY: &str = "stray, not deleted";
const NO_MARKER: &str = "no run marker";
const FAILED_BY_POD_RM: &str = "pod deleted by `pod rm` before its results were retrieved";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RowKind {
InProgress,
Ended,
Kept,
AwaitingRetrieval,
Stray,
StrayBesideKept,
NotInRuns,
Unreadable,
NoMarker,
Gone,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PodRow {
pub run: String,
pub pod_id: String,
pub status: String,
pub gpu: String,
pub rate: Option<f64>,
pub created: String,
pub note: String,
pub kind: RowKind,
}
impl PodRow {
#[must_use]
pub fn is_orphan(&self) -> bool {
matches!(
self.kind,
RowKind::Ended
| RowKind::Stray
| RowKind::StrayBesideKept
| RowKind::NotInRuns
| RowKind::NoMarker
)
}
}
pub async fn pod_rows(ctx: &PodCtx<'_>) -> Result<Vec<PodRow>, PodError> {
let pods = ctx.client.list_pods().await?;
let known = Known::load(ctx.runs)?;
let mut rows = known.listed(&pods);
let unlisted = known.unlisted(&pods);
let ids: Vec<PodId> = unlisted.iter().map(|entry| entry.id.clone()).collect();
let looks = look_up_all(ctx, &ids).await;
for (entry, look) in unlisted.iter().zip(looks) {
if let Some(row) = settle(ctx, &known, entry, look)? {
rows.push(row);
}
}
rows.sort_by(|a, b| (&a.run, &a.pod_id).cmp(&(&b.run, &b.pod_id)));
Ok(rows)
}
pub async fn listed_rows(ctx: &PodCtx<'_>) -> Result<Vec<PodRow>, PodError> {
let pods = ctx.client.list_pods().await?;
let mut rows = Known::load(ctx.runs)?.listed(&pods);
rows.sort_by(|a, b| (&a.run, &a.pod_id).cmp(&(&b.run, &b.pod_id)));
Ok(rows)
}
struct Unlisted {
run_id: String,
id: PodId,
stray: bool,
}
struct Known<'a> {
runs: &'a Runs,
run_records: HashMap<String, RunRecord>,
pod_records: HashMap<String, PodRecord>,
}
impl<'a> Known<'a> {
fn load(runs: &'a Runs) -> Result<Self, PodError> {
let run_records: HashMap<String, RunRecord> = runs
.list()?
.into_iter()
.map(|run| (run.id.clone(), run))
.collect();
let mut pod_records = HashMap::new();
for id in run_records.keys() {
match PodRecord::load(runs, id) {
Ok(Some(record)) => {
pod_records.insert(id.clone(), record);
},
Ok(None) => {},
Err(error) => {
tracing::warn!("skipping the unreadable pod record of run {id}: {error}");
},
}
}
Ok(Self {
runs,
run_records,
pod_records,
})
}
fn listed(&self, pods: &[Pod]) -> Vec<PodRow> {
pods.iter()
.filter(|pod| pod.run_id().is_some() || pod.name.starts_with("overbrainer-"))
.map(|pod| self.row(pod.run_id(), pod))
.collect()
}
fn unlisted(&self, pods: &[Pod]) -> Vec<Unlisted> {
let listed = |id: &PodId| pods.iter().any(|pod| pod.id == *id);
let mut unlisted = Vec::new();
for (run_id, record) in &self.pod_records {
if let Some(id) = &record.pod_id
&& record.state != PodState::Deleted
&& !listed(id)
{
unlisted.push(Unlisted {
run_id: run_id.clone(),
id: id.clone(),
stray: false,
});
}
for id in &record.stray_pods {
if !listed(id) && record.pod_id.as_ref() != Some(id) {
unlisted.push(Unlisted {
run_id: run_id.clone(),
id: id.clone(),
stray: true,
});
}
}
}
unlisted
}
fn is_stray(&self, id: &PodId) -> bool {
self.pod_records
.values()
.any(|record| record.stray_pods.contains(id))
}
fn classify(&self, run: Option<&str>, id: &PodId) -> (RowKind, String) {
let Some((run, dir)) = run.and_then(|run| Some((run, self.runs.run_dir(run).ok()?))) else {
return (RowKind::NoMarker, NO_MARKER.to_string());
};
let Some(record) = self.run_records.get(run) else {
if dir.join(RECORD_FILE).exists() {
return (RowKind::Unreadable, "run record unreadable".to_string());
}
return (
RowKind::NotInRuns,
format!(
"not in this project's runs/; if no other checkout owns it, `overbrainer pod rm {run} --force`"
),
);
};
let pod_record = self.pod_records.get(run);
let pod_state = pod_record.map(|pod| pod.state);
let extra = pod_record
.and_then(|pod| pod.pod_id.as_ref())
.is_some_and(|recorded| recorded != id);
if extra || self.is_stray(id) {
let kept = matches!(
pod_state,
Some(PodState::Kept | PodState::AwaitingRetrieval)
);
let kind = if kept {
RowKind::StrayBesideKept
} else {
RowKind::Stray
};
return (kind, STRAY.to_string());
}
match (pod_state, record.state) {
(Some(PodState::Kept), _) => (RowKind::Kept, "kept".to_string()),
(Some(PodState::AwaitingRetrieval), _) => {
(RowKind::AwaitingRetrieval, "awaiting retrieval".to_string())
},
(_, state @ (RunState::Preparing | RunState::Running)) => {
(RowKind::InProgress, format!("run {}", state.name()))
},
(_, ended) => (RowKind::Ended, format!("run {}, not deleted", ended.name())),
}
}
fn row(&self, run: Option<&str>, pod: &Pod) -> PodRow {
let (kind, note) = self.classify(run, &pod.id);
PodRow {
run: cell_text(run.unwrap_or("-")),
pod_id: pod.id.to_string(),
status: pod.status.name().to_string(),
gpu: cell_text(pod.gpu_type().unwrap_or("-")),
rate: pod.cost,
created: cell_text(pod.created_at.as_deref().unwrap_or_default()),
note,
kind,
}
}
fn recorded_row(&self, entry: &Unlisted, status: &str, kind: RowKind, note: String) -> PodRow {
let record = self.pod_records.get(&entry.run_id).filter(|_| !entry.stray);
PodRow {
run: cell_text(&entry.run_id),
pod_id: entry.id.to_string(),
status: status.to_string(),
gpu: cell_text(
record
.and_then(|record| record.gpu_type.as_deref())
.unwrap_or("-"),
),
rate: None,
created: cell_text(
record
.and_then(|record| record.created_at.as_deref())
.unwrap_or_default(),
),
note,
kind,
}
}
}
fn settle(
ctx: &PodCtx<'_>,
known: &Known<'_>,
entry: &Unlisted,
look: Look,
) -> Result<Option<PodRow>, PodError> {
match look {
Look::Found(pod) => Ok(Some(known.row(Some(&entry.run_id), &pod))),
Look::Gone if entry.stray => {
forget_stray(ctx.runs, &entry.run_id, &entry.id)?;
Ok(None)
},
Look::Gone => {
record_gone(ctx, &entry.run_id, &entry.id)?;
let row = known.recorded_row(entry, "GONE", RowKind::Gone, "gone".to_string());
Ok(Some(row))
},
Look::Failed(error) => {
tracing::warn!(
"cannot look up pod {} of run {}: {error}",
entry.id,
entry.run_id
);
let (kind, note) = known.classify(Some(&entry.run_id), &entry.id);
Ok(Some(known.recorded_row(entry, "UNCHECKED", kind, note)))
},
}
}
fn record_gone(ctx: &PodCtx<'_>, run_id: &str, id: &PodId) -> Result<(), PodError> {
if let Some(mut record) = PodRecord::load(ctx.runs, run_id)?
&& record.pod_id.as_ref() == Some(id)
&& record.state != PodState::Deleted
{
mark_gone(ctx, &mut record, id.clone(), DeletedBy::Unknown)?;
}
Ok(())
}
fn forget_stray(runs: &Runs, run_id: &str, id: &PodId) -> Result<(), PodError> {
edit_record(runs, run_id, |record| {
record.stray_pods.retain(|stray| stray != id);
})?;
tracing::info!("stray pod {id} of run {run_id} is gone");
Ok(())
}
fn edit_record(
runs: &Runs,
run_id: &str,
edit: impl FnOnce(&mut PodRecord),
) -> Result<(), PodError> {
if let Some(mut record) = PodRecord::load(runs, run_id)? {
edit(&mut record);
record.save(runs)?;
}
Ok(())
}
#[must_use]
pub fn table(rows: &[PodRow]) -> Vec<String> {
if rows.is_empty() {
return Vec::new();
}
let mut lines = vec![format!(
"{:<20} {:<14} {:<12} {:<24} {:>5} {:<20} NOTE",
"RUN", "POD", "STATUS", "GPU", "$/H", "CREATED"
)];
for row in rows {
let rate = row
.rate
.map_or_else(|| "-".to_string(), |rate| format!("{rate:.2}"));
lines.push(format!(
"{:<20} {:<14} {:<12} {:<24} {rate:>5} {:<20} {}",
row.run, row.pod_id, row.status, row.gpu, row.created, row.note
));
}
lines
}
#[must_use]
pub fn orphan_warnings(rows: &[PodRow]) -> Vec<String> {
rows.iter()
.filter(|row| row.is_orphan())
.map(|row| {
let rate = row
.rate
.map_or_else(String::new, |rate| format!(" at ${rate:.2}/h"));
match row.kind {
RowKind::NoMarker => format!(
"pod {} is named like an overbrainer pod but has no run marker; if it is yours, delete it from the Runpod console",
row.pod_id
),
RowKind::NotInRuns => {
format!("pod {} is still on Runpod{rate}: {}", row.pod_id, row.note)
},
RowKind::StrayBesideKept => format!(
"pod {} ({}) is still on Runpod{rate}: remove it with `overbrainer pod rm {}` (this also deletes the run's kept pod)",
row.pod_id, row.note, row.run
),
_ => format!(
"pod {} ({}) is still on Runpod{rate}: remove it with `overbrainer pod rm {}`",
row.pod_id, row.note, row.run
),
}
})
.collect()
}
#[derive(Debug, Clone, PartialEq)]
pub struct Removed {
pub pod_id: PodId,
pub uptime: Option<Duration>,
pub estimated_spend: Option<f64>,
}
impl Removed {
fn unrecorded(pod_id: PodId) -> Self {
Self {
pod_id,
uptime: None,
estimated_spend: None,
}
}
}
#[derive(Debug)]
pub struct Removal {
pub removed: Vec<Removed>,
pub result: Result<(), PodError>,
}
#[must_use]
pub async fn remove_run_pods(ctx: &PodCtx<'_>, run_id: &str, force: bool) -> Removal {
let mut removed = Vec::new();
let result = remove_checked(ctx, run_id, force, &mut removed).await;
Removal { removed, result }
}
async fn remove_checked(
ctx: &PodCtx<'_>,
run_id: &str,
force: bool,
removed: &mut Vec<Removed>,
) -> Result<(), PodError> {
ctx.runs.run_dir(run_id)?;
let state = match ctx.runs.load(run_id) {
Ok(run) => Some(run.state),
Err(RunsError::NotFound(_)) if force => None,
Err(RunsError::NotFound(_)) => return Err(PodError::NotInRuns(run_id.to_string())),
Err(error) => return Err(error.into()),
};
let in_progress = matches!(state, Some(RunState::Preparing | RunState::Running));
let record = PodRecord::load(ctx.runs, run_id)?;
if !force && starting(state, record.as_ref()) {
return Err(PodError::StillStarting(run_id.to_string()));
}
let keep_training = in_progress && !force;
let training = record
.as_ref()
.filter(|record| record.state != PodState::Deleted)
.and_then(|record| record.pod_id.clone());
if keep_training && training.is_none() {
return Err(PodError::PodNotRecorded(run_id.to_string()));
}
let mut failed = None;
if !keep_training
&& let Some(mut record) = record
&& let Err(error) = remove_recorded(ctx, &mut record, removed).await
{
failed.get_or_insert(error);
}
let mut tried: Vec<PodId> = training.iter().cloned().collect();
remove_strays(ctx, run_id, removed, &mut tried, &mut failed).await;
remove_marked(ctx, run_id, removed, &tried, &mut failed).await;
if in_progress
&& !keep_training
&& !removed.is_empty()
&& let Err(error) = fail_run_by_pod_rm(ctx.runs, run_id)
{
failed.get_or_insert(error);
}
if let Some(error) = failed {
return Err(error);
}
if keep_training {
return Err(PodError::RunStillRunning {
run_id: run_id.to_string(),
kept: kept_words(training.as_ref(), removed),
});
}
forget_client_key(ctx.runs, run_id);
Ok(())
}
fn starting(state: Option<RunState>, record: Option<&PodRecord>) -> bool {
let unanswered = record
.and_then(|record| record.attempts.last())
.is_some_and(|attempt| attempt.pod_id.is_none());
match state {
Some(RunState::Preparing) => true,
Some(RunState::Running) => unanswered,
_ => false,
}
}
async fn remove_recorded(
ctx: &PodCtx<'_>,
record: &mut PodRecord,
removed: &mut Vec<Removed>,
) -> Result<(), PodError> {
let Some(id) = record
.pod_id
.clone()
.filter(|_| record.state != PodState::Deleted)
else {
return Ok(());
};
let uptime = record.uptime(SystemTime::now());
remove(ctx, record, DeleteReason::Requested, DeletedBy::PodRm).await?;
removed.push(Removed {
pod_id: id,
uptime,
estimated_spend: record.estimated_spend,
});
Ok(())
}
async fn remove_strays(
ctx: &PodCtx<'_>,
run_id: &str,
removed: &mut Vec<Removed>,
tried: &mut Vec<PodId>,
failed: &mut Option<PodError>,
) {
let strays = match PodRecord::load(ctx.runs, run_id) {
Ok(record) => record.map(|record| record.stray_pods).unwrap_or_default(),
Err(error) => {
failed.get_or_insert(error.into());
return;
},
};
for id in strays {
tried.push(id.clone());
match delete_confirmed(ctx, &id).await {
Ok(_) => {
if let Err(error) = forget_stray(ctx.runs, run_id, &id) {
failed.get_or_insert(error);
}
removed.push(Removed::unrecorded(id));
},
Err(error) => {
tracing::warn!("cannot confirm the deletion of stray pod {id}: {error}");
failed.get_or_insert(error);
},
}
}
}
async fn remove_marked(
ctx: &PodCtx<'_>,
run_id: &str,
removed: &mut Vec<Removed>,
tried: &[PodId],
failed: &mut Option<PodError>,
) {
let pods = match ctx.client.list_pods().await {
Ok(pods) => pods,
Err(error) => {
failed.get_or_insert(error.into());
return;
},
};
for pod in pods {
let done = tried.contains(&pod.id) || removed.iter().any(|done| done.pod_id == pod.id);
if pod.run_id() != Some(run_id) || done {
continue;
}
match delete_confirmed(ctx, &pod.id).await {
Ok(_) => removed.push(Removed::unrecorded(pod.id)),
Err(error) => {
record_stray(ctx.runs, run_id, &pod.id, &error);
failed.get_or_insert(error);
},
}
}
}
fn record_stray(runs: &Runs, run_id: &str, id: &PodId, error: &PodError) {
let stray = id.clone();
let recorded = match edit_record(runs, run_id, |record| record.note_stray(stray)) {
Ok(()) => String::new(),
Err(record_error) => format!(" (and cannot record it as stray: {record_error})"),
};
tracing::warn!("cannot confirm the deletion of pod {id}: {error}{recorded}");
}
fn fail_run_by_pod_rm(runs: &Runs, run_id: &str) -> Result<(), PodError> {
let mut run = runs.load(run_id)?;
run.state = RunState::Failed;
run.message = Some(FAILED_BY_POD_RM.to_string());
runs.save(&run)?;
Ok(())
}
fn kept_words(training: Option<&PodId>, removed: &[Removed]) -> String {
let others: Vec<String> = removed.iter().map(|pod| pod.pod_id.to_string()).collect();
let deleted = if others.is_empty() {
String::new()
} else {
format!("its other pods {} were deleted", others.join(", "))
};
match (training, deleted.is_empty()) {
(Some(id), true) => format!(": its training pod {id} was kept"),
(Some(id), false) => format!(": its training pod {id} was kept, and {deleted}"),
(None, false) => format!(": {deleted}"),
(None, true) => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(run: &str, kind: RowKind, note: &str) -> PodRow {
PodRow {
run: run.to_string(),
pod_id: "k3x9abc".to_string(),
status: "RUNNING".to_string(),
gpu: "NVIDIA A40".to_string(),
rate: Some(0.49),
created: "2026-09-21T09:00:03Z".to_string(),
note: note.to_string(),
kind,
}
}
#[test]
fn only_pods_nothing_will_delete_are_orphans_and_each_gets_a_safe_hint() {
let rows = [
row("r1", RowKind::InProgress, "run running"),
row("r2", RowKind::Ended, "run succeeded, not deleted"),
row(
"r3",
RowKind::NotInRuns,
"not in this project's runs/; if no other checkout owns it, `overbrainer pod rm r3 --force`",
),
row("r4", RowKind::Kept, "kept"),
row("r5", RowKind::AwaitingRetrieval, "awaiting retrieval"),
row("r6", RowKind::Stray, STRAY),
row("-", RowKind::NoMarker, NO_MARKER),
row("r8", RowKind::Unreadable, "run record unreadable"),
row("r9", RowKind::Gone, "gone"),
];
assert_eq!(
orphan_warnings(&rows),
vec![
"pod k3x9abc (run succeeded, not deleted) is still on Runpod at $0.49/h: remove it with `overbrainer pod rm r2`".to_string(),
"pod k3x9abc is still on Runpod at $0.49/h: not in this project's runs/; if no other checkout owns it, `overbrainer pod rm r3 --force`".to_string(),
"pod k3x9abc (stray, not deleted) is still on Runpod at $0.49/h: remove it with `overbrainer pod rm r6`".to_string(),
"pod k3x9abc is named like an overbrainer pod but has no run marker; if it is yours, delete it from the Runpod console".to_string(),
]
);
}
#[test]
fn the_table_aligns_its_columns() {
assert!(table(&[]).is_empty());
let lines = table(&[row(
"20260921-090000-ffff",
RowKind::Ended,
"run succeeded, not deleted",
)]);
assert_eq!(
lines,
vec![
"RUN POD STATUS GPU $/H CREATED NOTE".to_string(),
"20260921-090000-ffff k3x9abc RUNNING NVIDIA A40 0.49 2026-09-21T09:00:03Z run succeeded, not deleted".to_string(),
]
);
}
#[test]
fn a_refused_pod_rm_says_what_it_kept_and_deleted() -> Result<(), crate::runpod::InvalidPodId> {
let training = PodId::new("p1")?;
let others = [
Removed::unrecorded(PodId::new("s1")?),
Removed::unrecorded(PodId::new("x2")?),
];
assert_eq!(
kept_words(Some(&training), &others),
": its training pod p1 was kept, and its other pods s1, x2 were deleted"
);
assert_eq!(
kept_words(Some(&training), &[]),
": its training pod p1 was kept"
);
assert_eq!(kept_words(None, &[]), "");
Ok(())
}
}