Skip to content

Commit e32fb10

Browse files
fix(cleanup): give runner-downloads an ownership, age, and liveness predicate (#10584)
* fix(cleanup): give runner-downloads an ownership, age, and liveness predicate `runs_service::cleanup_runner_downloads` was an unconditional `fs::remove_dir_all` of `<artifact-root>/runner`. Its only checks were path-containment ones — `--run-id` requires `--runner`, each filter must be a single normal path component, the root must be a real directory — which prove the deletion stays inside the cache and prove nothing about whether the bytes are dead. `runner-downloads` was also swept by a bare `homeboy cleanup --apply`, so artifacts an operator pulled seconds earlier were deleted by an unrelated sweep. That tree has exactly one writer: the default output path of `download_remote_artifact`, `<artifact-root>/runner/<runner-id>/<run-id>/ <file>`. Every caller of it is a fetch someone asked for (`runs artifact get`, `runs artifacts --pull`, `lab apply`, evidence mirroring, the HTTP artifact endpoint), and `runs artifact get` hands that path back as the location of the operator's file. The predicate now requires, per cache directory: - ownership by the canonical `<runner-id>/<run-id>` name shape, with no symlink at either level. The database is deliberately not joined: bytes here are written before, and usually without, any local artifacts row, so row absence is the normal state of a download that is succeeding. - a fixed 24h floor (`cleanup::RUNNER_MIN_AGE_HOURS`) over the *newest* byte anywhere in the subtree, so one fresh pull re-arms the whole directory. Not operator-overridable. - a non-terminal-run veto read from the observation store in the retain direction only. A missing row never authorizes removal. - fail closed on every uncertainty: unreadable or future-dated mtime, unwalkable subtree, out-of-root path, unopenable store, or a truncated running-run scan all retain. Removal is per cache directory rather than whole-root, so a stale cache and a fresh one under the same runner are decided independently; the cache root is never removed and an emptied `<runner-id>` directory is pruned only by a non-recursive `remove_dir`. Sizes stay advisory and move the verdict in neither direction. `--runner`/`--run-id` narrow which candidates are considered and never waive a check. The category is also withheld from the bare sweep. The predicate fixes the acute data-loss case but cannot fix the remaining one: the writer emits the same name shape for an operator's deliberate pull and for an internal auto-fetch, so ownership of *intent* cannot be proven by name. Until the writer tags its output, `--include runner-downloads` is the honest contract. `cleanup retained-storage` still accounts for the bytes, now split into what a sweep would reclaim and what it is holding on to, and still names the reclaim command. The specialist resolves its inspection budget through `resolve_cleanup_policy` and echoes the policy, matching the other delete paths unified in #10562. It carried no `default_value_t` retention literal, so it was not drifting — it was simply not participating. Fixes #10564 * fix(cleanup): derive Debug on CleanupFilters Workspace Tests Compile failed with E0277: assert macros in the new tests format CleanupFilters, which had no Debug impl. The CLI Reference Docs gate failed for the same reason -- it runs cargo test -p homeboy-cli to regenerate, so it could not compile either. The regenerated doc tree is byte-identical to the checked-in one; the docs were never stale. --------- Co-authored-by: chubes-bot <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 80d8142 commit e32fb10

10 files changed

Lines changed: 1596 additions & 182 deletions

File tree

crates/homeboy-cli/src/commands/cleanup.rs

Lines changed: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pub struct CleanupArgs {
3535
pub apply: bool,
3636

3737
/// Include only these cleanup categories. Comma-separated or repeatable.
38+
/// `runner-downloads` is opt-in only: it holds artifacts an operator asked
39+
/// Homeboy to fetch, so a bare sweep never includes it.
3840
#[arg(long, value_enum, value_delimiter = ',')]
3941
pub include: Vec<CleanupCategoryArg>,
4042

@@ -535,25 +537,52 @@ fn artifact_root_records(
535537
});
536538
}
537539

540+
// The whole cache used to be reported as reclaimable, because the category
541+
// deleted all of it unconditionally. It now reports the two halves its
542+
// predicate actually produces (#10564): what a sweep would reclaim, and
543+
// what it is holding on to.
538544
let downloads = runs_service::cleanup_runner_downloads(RunnerDownloadCleanupOptions {
539545
apply: false,
540546
runner: None,
541547
run_id: None,
548+
limit: policy.scan_limit(),
542549
})?;
543-
if downloads.size_bytes > 0 || downloads.file_count > 0 || downloads.directory_count > 0 {
550+
let downloads_root = downloads.root.display().to_string();
551+
if downloads.planned_count > 0 {
544552
records.push(RetainedStorageRecord {
545553
category: "runner_downloads".to_string(),
546554
reason: format!(
547-
"cached runner artifact downloads ({} file(s), {} directory(ies))",
548-
downloads.file_count, downloads.directory_count
555+
"{} cached runner download(s) past the fixed {}s age floor with no non-terminal owning run ({} file(s), {} directory(ies))",
556+
downloads.planned_count,
557+
downloads.min_age_seconds,
558+
downloads.file_count,
559+
downloads.directory_count
549560
),
550561
owner: "homeboy".to_string(),
551562
run_id: None,
552563
liveness: LIVENESS_RECLAIMABLE.to_string(),
564+
age: age_bucket(downloads.min_age_seconds),
565+
age_seconds: Some(downloads.min_age_seconds),
566+
size_bytes: downloads.planned_size_bytes,
567+
reference: downloads_root.clone(),
568+
});
569+
}
570+
if downloads.skipped_count > 0 {
571+
records.push(RetainedStorageRecord {
572+
category: "runner_downloads".to_string(),
573+
reason: format!(
574+
"{} cached runner download(s) retained: younger than the age floor, claimed by a non-terminal run, or not the canonical <runner>/<run> shape; bytes not measured",
575+
downloads.skipped_count
576+
),
577+
owner: "homeboy".to_string(),
578+
run_id: None,
579+
liveness: "lifecycle_pinned".to_string(),
553580
age: "unknown".to_string(),
554581
age_seconds: None,
555-
size_bytes: downloads.size_bytes,
556-
reference: downloads.root.display().to_string(),
582+
// Advisory-signal rule: retained entries are deliberately not
583+
// measured, so a zero here is "not measured", never "empty".
584+
size_bytes: 0,
585+
reference: format!("{downloads_root} (retained runner downloads)"),
557586
});
558587
}
559588

@@ -1009,15 +1038,16 @@ fn cleanup_inventory(args: CleanupArgs) -> homeboy::core::Result<Value> {
10091038
apply,
10101039
runner: None,
10111040
run_id: None,
1041+
limit: policy.scan_limit(),
10121042
})?;
10131043
categories.push(category_from_output(
10141044
RUNNER_DOWNLOADS_METADATA,
10151045
apply,
1016-
output.file_count + output.directory_count,
1017-
usize::from(output.removed),
1018-
0,
1019-
output.size_bytes,
1020-
if output.removed { output.size_bytes } else { 0 },
1046+
output.inspected_count,
1047+
output.removed_count,
1048+
output.skipped_count,
1049+
output.planned_size_bytes,
1050+
output.removed_size_bytes,
10211051
output,
10221052
)?);
10231053
}
@@ -1180,14 +1210,37 @@ struct CleanupCategorySelection {
11801210
exclude: Vec<CleanupCategoryArg>,
11811211
}
11821212

1213+
/// Categories a bare `homeboy cleanup` deliberately does not sweep.
1214+
///
1215+
/// Everything else in the aggregate reclaims bytes Homeboy produced as a
1216+
/// byproduct of its own work — scratch, build targets, temp trees, crash
1217+
/// residue, remote workspaces. `runner-downloads` is different in kind: every
1218+
/// byte under `<artifact-root>/runner` is the result of a fetch an operator
1219+
/// asked for, and `homeboy runs artifact get` hands that exact path back to
1220+
/// them as the location of their file. The predicate in
1221+
/// [`homeboy::core::observation::runs_service::cleanup_runner_downloads`] proves
1222+
/// the bytes are old and unclaimed, but it cannot prove the operator is *done*
1223+
/// with them, because the single writer emits the same name shape for an
1224+
/// operator pull and for an internal auto-fetch (#10564).
1225+
///
1226+
/// Until the writer tags its output, an explicit `--include runner-downloads`
1227+
/// is the honest contract: being absent from a default sweep is cheap and
1228+
/// reversible, and a wrong delete is neither. The category stays fully visible
1229+
/// in `homeboy cleanup retained-storage`, which names the reclaim command.
1230+
const OPT_IN_ONLY_CATEGORIES: &[CleanupCategoryArg] = &[CleanupCategoryArg::RunnerDownloads];
1231+
11831232
impl CleanupCategorySelection {
11841233
fn new(include: Vec<CleanupCategoryArg>, exclude: Vec<CleanupCategoryArg>) -> Self {
11851234
Self { include, exclude }
11861235
}
11871236

11881237
fn includes(&self, category: CleanupCategoryArg) -> bool {
1189-
(self.include.is_empty() || self.include.contains(&category))
1190-
&& !self.exclude.contains(&category)
1238+
let selected = if self.include.is_empty() {
1239+
!OPT_IN_ONLY_CATEGORIES.contains(&category)
1240+
} else {
1241+
self.include.contains(&category)
1242+
};
1243+
selected && !self.exclude.contains(&category)
11911244
}
11921245
}
11931246

@@ -2261,6 +2314,22 @@ mod tests {
22612314
CleanupCategoryArg::RuntimeTmp,
22622315
false,
22632316
),
2317+
// #10564: opt-in-only categories are absent from a bare sweep,
2318+
// reachable by an explicit `--include`, and still suppressible by
2319+
// `--exclude`.
2320+
(vec![], vec![], CleanupCategoryArg::RunnerDownloads, false),
2321+
(
2322+
vec![CleanupCategoryArg::RunnerDownloads],
2323+
vec![],
2324+
CleanupCategoryArg::RunnerDownloads,
2325+
true,
2326+
),
2327+
(
2328+
vec![CleanupCategoryArg::RunnerDownloads],
2329+
vec![CleanupCategoryArg::RunnerDownloads],
2330+
CleanupCategoryArg::RunnerDownloads,
2331+
false,
2332+
),
22642333
];
22652334

22662335
for (include, exclude, category, expected) in cases {
@@ -2271,6 +2340,40 @@ mod tests {
22712340
}
22722341
}
22732342

2343+
#[test]
2344+
fn only_runner_downloads_is_withheld_from_the_bare_sweep() {
2345+
// A bare `homeboy cleanup --apply` must keep sweeping everything that
2346+
// reclaims Homeboy's own byproducts. Only the operator-owned download
2347+
// cache is withheld, and the withheld set is asserted exactly so a
2348+
// future category cannot be quietly dropped from the default (#10564).
2349+
assert_eq!(
2350+
OPT_IN_ONLY_CATEGORIES.to_vec(),
2351+
vec![CleanupCategoryArg::RunnerDownloads]
2352+
);
2353+
2354+
let bare = CleanupCategorySelection::new(Vec::new(), Vec::new());
2355+
for category in [
2356+
CleanupCategoryArg::RepoArtifacts,
2357+
CleanupCategoryArg::TaskWorktrees,
2358+
CleanupCategoryArg::WorktreeProviders,
2359+
CleanupCategoryArg::TerminalRuns,
2360+
CleanupCategoryArg::PersistedRunArtifacts,
2361+
CleanupCategoryArg::OrphanedArtifactBytes,
2362+
CleanupCategoryArg::RunnerBinaryCaches,
2363+
CleanupCategoryArg::RemoteLabWorkspaces,
2364+
CleanupCategoryArg::RuntimeTmp,
2365+
CleanupCategoryArg::ControllerScratch,
2366+
CleanupCategoryArg::SharedCargoTargets,
2367+
CleanupCategoryArg::ControllerRuntimes,
2368+
] {
2369+
assert!(
2370+
bare.includes(category),
2371+
"bare cleanup must still sweep {category:?}"
2372+
);
2373+
}
2374+
assert!(!bare.includes(CleanupCategoryArg::RunnerDownloads));
2375+
}
2376+
22742377
#[test]
22752378
fn aggregate_repo_artifact_roots_do_not_depend_on_the_caller_directory() {
22762379
let configured = vec![

crates/homeboy-cli/src/commands/runs/remote_artifact.rs

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -420,10 +420,16 @@ fn directory_contains_html_inner(path: &Path, seen: usize) -> bool {
420420
}
421421

422422
pub fn cleanup_downloads(args: RunsArtifactCleanupDownloadsArgs) -> CmdResult<RunsOutput> {
423+
// Same resolver as `homeboy cleanup --include runner-downloads`. `--runner`
424+
// and `--run-id` are the narrowing filters that keep this specialist alive;
425+
// the inspection budget is not one of them, and the age floor is fixed and
426+
// deliberately unexposed (#10564).
427+
let policy = resolve_cleanup_policy(CleanupPolicyOverrides::default())?;
423428
let outcome = runs_service::cleanup_runner_downloads(RunnerDownloadCleanupOptions {
424429
apply: args.apply,
425430
runner: args.runner,
426431
run_id: args.run_id,
432+
limit: policy.scan_limit(),
427433
})?;
428434

429435
Ok((
@@ -434,12 +440,20 @@ pub fn cleanup_downloads(args: RunsArtifactCleanupDownloadsArgs) -> CmdResult<Ru
434440
.canonical_cleanup_command(args.apply),
435441
specialist_cleanup_command: RUNNER_DOWNLOADS_METADATA.specialist_command(args.apply),
436442
dry_run: outcome.dry_run,
443+
retention: policy,
437444
root: outcome.root.display().to_string(),
438-
removed: outcome.removed,
445+
min_age_seconds: outcome.min_age_seconds,
446+
liveness: outcome.liveness,
447+
inspected_count: outcome.inspected_count,
448+
planned_count: outcome.planned_count,
449+
removed_count: outcome.removed_count,
450+
skipped_count: outcome.skipped_count,
439451
file_count: outcome.file_count,
440452
directory_count: outcome.directory_count,
441-
size_bytes: outcome.size_bytes,
442-
paths: outcome.paths,
453+
planned_size_bytes: outcome.planned_size_bytes,
454+
removed_size_bytes: outcome.removed_size_bytes,
455+
truncated: outcome.truncated,
456+
rows: outcome.rows,
443457
}),
444458
0,
445459
))
@@ -955,12 +969,13 @@ mod tests {
955969
}
956970

957971
#[test]
958-
fn cleanup_downloads_plans_and_removes_runner_cache() {
972+
fn cleanup_downloads_retains_a_freshly_pulled_cache_and_stays_wired_to_the_aggregate() {
959973
let _guard = artifact_root_test_lock();
960974
with_isolated_home(|home| {
961975
let artifact_root = home.path().join("artifacts");
962976
homeboy::core::set_artifact_root_override(Some(artifact_root.clone()));
963977

978+
// Exactly what `homeboy runs artifacts <run> --pull` writes.
964979
let run_dir = artifact_root.join("runner").join("local").join("run-1");
965980
fs::create_dir_all(&run_dir).expect("run dir");
966981
fs::write(run_dir.join("trace.zip"), b"trace").expect("trace");
@@ -987,10 +1002,16 @@ mod tests {
9871002
dry.canonical_cleanup_command,
9881003
RUNNER_DOWNLOADS_METADATA.canonical_cleanup_command(false)
9891004
);
990-
assert!(!dry.removed);
991-
assert_eq!(dry.file_count, 2);
992-
assert_eq!(dry.directory_count, 0);
993-
assert_eq!(dry.size_bytes, 7);
1005+
// #10564: the cache was written seconds ago, so it is a candidate
1006+
// the predicate inspects and retains — not bytes to plan away.
1007+
assert_eq!(dry.inspected_count, 1);
1008+
assert_eq!(dry.planned_count, 0);
1009+
assert_eq!(dry.skipped_count, 1);
1010+
assert_eq!(dry.min_age_seconds, 24 * 60 * 60);
1011+
assert_eq!(
1012+
dry.retention.schema,
1013+
homeboy::core::cleanup::CLEANUP_POLICY_SCHEMA
1014+
);
9941015
assert!(run_dir.exists());
9951016

9961017
let (inventory, _) =
@@ -1016,6 +1037,9 @@ mod tests {
10161037
dry.specialist_cleanup_command
10171038
);
10181039

1040+
// An explicit `--apply` on the freshly pulled cache still removes
1041+
// nothing: the narrowing filter selects a candidate, it does not
1042+
// waive the age floor.
10191043
let applied = cleanup_downloads(RunsArtifactCleanupDownloadsArgs {
10201044
apply: true,
10211045
runner: Some("local".to_string()),
@@ -1031,14 +1055,10 @@ mod tests {
10311055
applied.canonical_cleanup_command,
10321056
RUNNER_DOWNLOADS_METADATA.canonical_cleanup_command(true)
10331057
);
1034-
assert_eq!(
1035-
applied.specialist_cleanup_command,
1036-
RUNNER_DOWNLOADS_METADATA.specialist_command(true)
1037-
);
1038-
assert!(applied.removed);
1039-
assert_eq!(applied.file_count, 2);
1040-
assert_eq!(applied.size_bytes, 7);
1041-
assert!(!run_dir.exists());
1058+
assert_eq!(applied.removed_count, 0);
1059+
assert_eq!(applied.skipped_count, 1);
1060+
assert!(run_dir.join("trace.zip").exists());
1061+
assert!(run_dir.join("report.json").exists());
10421062
});
10431063
}
10441064

crates/homeboy-cli/src/commands/runs/types.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -726,12 +726,26 @@ pub struct RunsArtifactCleanupDownloadsOutput {
726726
pub canonical_cleanup_command: String,
727727
pub specialist_cleanup_command: &'static str,
728728
pub dry_run: bool,
729+
/// The resolved policy this invocation applied.
730+
pub retention: CleanupPolicy,
731+
/// The cache scope inspected, narrowed by `--runner` / `--run-id`.
729732
pub root: String,
730-
pub removed: bool,
733+
/// Fixed age floor. Not overridable: see
734+
/// `runs_service::RUNNER_DOWNLOAD_MIN_AGE`.
735+
pub min_age_seconds: u64,
736+
/// Whether the non-terminal-run veto could be evaluated. `unavailable`
737+
/// means every candidate was retained.
738+
pub liveness: runs_service::RunnerDownloadLiveness,
739+
pub inspected_count: usize,
740+
pub planned_count: usize,
741+
pub removed_count: usize,
742+
pub skipped_count: usize,
731743
pub file_count: usize,
732744
pub directory_count: usize,
733-
pub size_bytes: u64,
734-
pub paths: Vec<String>,
745+
pub planned_size_bytes: u64,
746+
pub removed_size_bytes: u64,
747+
pub truncated: bool,
748+
pub rows: Vec<runs_service::RunnerDownloadCleanupRow>,
735749
}
736750

737751
#[derive(Args, Clone)]

crates/homeboy-core/src/observation/runs_service/mod.rs

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -57,24 +57,6 @@ pub enum ArtifactGetSource {
5757
Remote,
5858
}
5959

60-
#[derive(Debug, Clone, Default)]
61-
pub struct RunnerDownloadCleanupOptions {
62-
pub apply: bool,
63-
pub runner: Option<String>,
64-
pub run_id: Option<String>,
65-
}
66-
67-
#[derive(Debug, Clone, Serialize)]
68-
pub struct RunnerDownloadCleanupOutcome {
69-
pub dry_run: bool,
70-
pub root: PathBuf,
71-
pub removed: bool,
72-
pub file_count: usize,
73-
pub directory_count: usize,
74-
pub size_bytes: u64,
75-
pub paths: Vec<String>,
76-
}
77-
7860
#[derive(Debug, Clone)]
7961
pub struct PersistedArtifactCleanupOptions {
8062
pub apply: bool,
@@ -153,14 +135,6 @@ pub struct TerminalRunLifecycleDirectory {
153135
pub size_bytes: u64,
154136
}
155137

156-
#[derive(Debug, Default)]
157-
struct RunnerDownloadCleanupPreview {
158-
file_count: usize,
159-
directory_count: usize,
160-
size_bytes: u64,
161-
paths: Vec<String>,
162-
}
163-
164138
/// Storage classes recognized by [`classify_artifact_storage`].
165139
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166140
pub enum ArtifactStorage {

0 commit comments

Comments
 (0)