From d37a5214cc6ba9240fe6bd45157e458aa2badff4 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:57:55 -0800 Subject: [PATCH 1/8] fix(doctor): name safe last-resort project reclaim for overages Refs #4616 --- .../src/retention/footprint.rs | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-maintenance/src/retention/footprint.rs b/crates/tracedecay-maintenance/src/retention/footprint.rs index 7c6d4ead0c0..43fdd6aef74 100644 --- a/crates/tracedecay-maintenance/src/retention/footprint.rs +++ b/crates/tracedecay-maintenance/src/retention/footprint.rs @@ -576,6 +576,25 @@ fn floor_note(unavailable_entry_count: usize, truncated: bool) -> String { } } +/// Only an enrolled project has a first-party destructive reclaim path. +/// An unregistered directory or unrelated profile family must never be sent +/// through `projects forget`; it only accepts registered identities. +fn registered_project_reclaim_hint(entry: &ProfileFootprintEntryV1) -> Option { + if entry.family != ProfileFamilyV1::ProjectStore || entry.bytes <= entry.bound_bytes { + return None; + } + entry.project.as_ref()?.registered_root.as_ref()?; + let id = entry.key.strip_prefix("projects/").filter(|id| { + id.starts_with("proj_") + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + })?; + Some(format!( + "; if retention stalls, LAST RESORT (DELETES project data): `tracedecay --dry-run projects forget {id}` then `tracedecay --yes projects forget {id}`" + )) +} + fn entry_statement(entry: &ProfileFootprintEntryV1) -> String { let label = match entry.family { ProfileFamilyV1::ProjectStore => "project store", @@ -609,8 +628,11 @@ fn entry_statement(entry: &ProfileFootprintEntryV1) -> String { format_bytes(kinds.graph_replay_pool) ) }); + // Place the recovery command before the potentially long registered root + // so Doctor's bounded statement never truncates the actionable command. + let reclaim = registered_project_reclaim_hint(entry).unwrap_or_default(); format!( - "{label} {}{root}{breakdown}{}", + "{label} {}{reclaim}{root}{breakdown}{}", entry.key, floor_note(entry.unavailable_entry_count, false) ) @@ -997,6 +1019,53 @@ mod tests { ); } + #[test] + fn overbound_project_doctor_names_a_supported_destructive_reclaim_only_for_registered_stores() { + let fixture = profile_fixture(); + let overrides = BTreeMap::from([ + ("projects/proj_live".to_owned(), 1024), + ("projects/proj_stray".to_owned(), 1), + ]); + let over = census_profile_footprint(&fixture.profile, &fixture.registered, &overrides); + let over_findings = findings(&over); + let live = &over_findings["projects/proj_live"]; + assert_eq!(live.finding().state(), DoctorEvidenceStateV1::Degraded); + let statement = live.finding().coverage().statement(); + assert!(statement.contains("tracedecay --dry-run projects forget proj_live"), "{statement}"); + assert!(statement.contains("tracedecay --yes projects forget proj_live"), "{statement}"); + assert!(statement.contains("DELETES project data"), "{statement}"); + + let unregistered = &over_findings["projects/proj_stray"]; + assert_eq!(unregistered.finding().state(), DoctorEvidenceStateV1::Degraded); + assert!( + !unregistered.finding().coverage().statement().contains("projects forget"), + "an unregistered directory has no safe project selector" + ); + assert!( + !over_findings["profile"] + .finding() + .coverage() + .statement() + .contains("projects forget"), + "a profile total does not identify a reclaimable project" + ); + + let normal = census_profile_footprint(&fixture.profile, &fixture.registered, &BTreeMap::new()); + let healthy = findings(&normal); + assert_eq!( + healthy["projects/proj_live"].finding().state(), + DoctorEvidenceStateV1::HealthyCompleteCoverage + ); + assert!( + !healthy["projects/proj_live"] + .finding() + .coverage() + .statement() + .contains("projects forget"), + "within-budget stores must not be offered destructive recovery" + ); + } + #[test] fn an_owner_override_replaces_the_default_bound() { let fixture = profile_fixture(); From 553d6592160a107149eb1e698135da2f907d932a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:58:30 -0800 Subject: [PATCH 2/8] style(doctor): format reclaim regression assertions Refs #4616 --- .../src/retention/footprint.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-maintenance/src/retention/footprint.rs b/crates/tracedecay-maintenance/src/retention/footprint.rs index 43fdd6aef74..3537cb324ee 100644 --- a/crates/tracedecay-maintenance/src/retention/footprint.rs +++ b/crates/tracedecay-maintenance/src/retention/footprint.rs @@ -1031,8 +1031,14 @@ mod tests { let live = &over_findings["projects/proj_live"]; assert_eq!(live.finding().state(), DoctorEvidenceStateV1::Degraded); let statement = live.finding().coverage().statement(); - assert!(statement.contains("tracedecay --dry-run projects forget proj_live"), "{statement}"); - assert!(statement.contains("tracedecay --yes projects forget proj_live"), "{statement}"); + assert!( + statement.contains("tracedecay --dry-run projects forget proj_live"), + "{statement}" + ); + assert!( + statement.contains("tracedecay --yes projects forget proj_live"), + "{statement}" + ); assert!(statement.contains("DELETES project data"), "{statement}"); let unregistered = &over_findings["projects/proj_stray"]; @@ -1050,7 +1056,8 @@ mod tests { "a profile total does not identify a reclaimable project" ); - let normal = census_profile_footprint(&fixture.profile, &fixture.registered, &BTreeMap::new()); + let normal = + census_profile_footprint(&fixture.profile, &fixture.registered, &BTreeMap::new()); let healthy = findings(&normal); assert_eq!( healthy["projects/proj_live"].finding().state(), From bd4c6d6f642fc204276708b63325b5aa93d1f9d2 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:00:29 -0800 Subject: [PATCH 3/8] fix(doctor): keep overage and reclaim visible for long roots Refs #4616 --- .../src/retention/footprint.rs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-maintenance/src/retention/footprint.rs b/crates/tracedecay-maintenance/src/retention/footprint.rs index 3537cb324ee..73830b26dd2 100644 --- a/crates/tracedecay-maintenance/src/retention/footprint.rs +++ b/crates/tracedecay-maintenance/src/retention/footprint.rs @@ -631,6 +631,13 @@ fn entry_statement(entry: &ProfileFootprintEntryV1) -> String { // Place the recovery command before the potentially long registered root // so Doctor's bounded statement never truncates the actionable command. let reclaim = registered_project_reclaim_hint(entry).unwrap_or_default(); + // The measured bytes and bound follow this subject. A very long physical + // path must not crowd those numbers out of Doctor's bounded statement. + let root = if !reclaim.is_empty() && root.len() > 96 { + " (registered root omitted from bounded diagnostic)".to_owned() + } else { + root + }; format!( "{label} {}{reclaim}{root}{breakdown}{}", entry.key, @@ -1026,7 +1033,7 @@ mod tests { ("projects/proj_live".to_owned(), 1024), ("projects/proj_stray".to_owned(), 1), ]); - let over = census_profile_footprint(&fixture.profile, &fixture.registered, &overrides); + let mut over = census_profile_footprint(&fixture.profile, &fixture.registered, &overrides); let over_findings = findings(&over); let live = &over_findings["projects/proj_live"]; assert_eq!(live.finding().state(), DoctorEvidenceStateV1::Degraded); @@ -1056,6 +1063,29 @@ mod tests { "a profile total does not identify a reclaimable project" ); + let long_root = "nested-repository/".repeat(70); + let registered = over + .entries + .iter_mut() + .find(|entry| entry.key == "projects/proj_live") + .expect("registered footprint"); + registered + .project + .as_mut() + .expect("registered project") + .registered_root = Some(long_root); + let bounded = findings(&over); + let bounded_statement = bounded["projects/proj_live"].finding().coverage().statement(); + assert!( + bounded_statement.contains("tracedecay --dry-run projects forget proj_live"), + "{bounded_statement}" + ); + assert!( + bounded_statement.contains(" of 1.0 KB bound"), + "over-budget bytes and limit must survive a long repository path: {bounded_statement}" + ); + assert!(bounded_statement.contains("registered root omitted")); + let normal = census_profile_footprint(&fixture.profile, &fixture.registered, &BTreeMap::new()); let healthy = findings(&normal); From d7c2d3a0d8ae20e5fcfc7ed2403b4d1cc44e5ad1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:28:37 -0800 Subject: [PATCH 4/8] style(doctor): format last-resort recovery tests --- .../src/retention/footprint.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-maintenance/src/retention/footprint.rs b/crates/tracedecay-maintenance/src/retention/footprint.rs index 73830b26dd2..95f32899876 100644 --- a/crates/tracedecay-maintenance/src/retention/footprint.rs +++ b/crates/tracedecay-maintenance/src/retention/footprint.rs @@ -1049,9 +1049,16 @@ mod tests { assert!(statement.contains("DELETES project data"), "{statement}"); let unregistered = &over_findings["projects/proj_stray"]; - assert_eq!(unregistered.finding().state(), DoctorEvidenceStateV1::Degraded); + assert_eq!( + unregistered.finding().state(), + DoctorEvidenceStateV1::Degraded + ); assert!( - !unregistered.finding().coverage().statement().contains("projects forget"), + !unregistered + .finding() + .coverage() + .statement() + .contains("projects forget"), "an unregistered directory has no safe project selector" ); assert!( @@ -1075,7 +1082,10 @@ mod tests { .expect("registered project") .registered_root = Some(long_root); let bounded = findings(&over); - let bounded_statement = bounded["projects/proj_live"].finding().coverage().statement(); + let bounded_statement = bounded["projects/proj_live"] + .finding() + .coverage() + .statement(); assert!( bounded_statement.contains("tracedecay --dry-run projects forget proj_live"), "{bounded_statement}" From f0c834d0a09dd30b1f1b340141262ce5e0b8d6cb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:38:20 -0800 Subject: [PATCH 5/8] fix(doctor): never truncate destructive reclaim guidance Reject empty project selectors and budget the entire finding, including both commands, deletion warning and measured overage. Omit actions that cannot fit intact. Add filesystem-backed malformed/long-selector regressions and sparse 15 GiB reporting coverage with a canonical identity and long Unicode root. Preserve concurrent formatting commit d7c2d3a0d8ae20e5fcfc7ed2403b4d1cc44e5ad1. Native Bazel validation remains required; no Cargo commands or CI changes. Refs #4616 --- .../src/retention/footprint.rs | 123 +++++++++++++++--- 1 file changed, 104 insertions(+), 19 deletions(-) diff --git a/crates/tracedecay-maintenance/src/retention/footprint.rs b/crates/tracedecay-maintenance/src/retention/footprint.rs index 95f32899876..baf6c5b6801 100644 --- a/crates/tracedecay-maintenance/src/retention/footprint.rs +++ b/crates/tracedecay-maintenance/src/retention/footprint.rs @@ -586,9 +586,8 @@ fn registered_project_reclaim_hint(entry: &ProfileFootprintEntryV1) -> Option "proj_".len() + && id.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') })?; Some(format!( "; if retention stalls, LAST RESORT (DELETES project data): `tracedecay --dry-run projects forget {id}` then `tracedecay --yes projects forget {id}`" @@ -628,20 +627,40 @@ fn entry_statement(entry: &ProfileFootprintEntryV1) -> String { format_bytes(kinds.graph_replay_pool) ) }); - // Place the recovery command before the potentially long registered root - // so Doctor's bounded statement never truncates the actionable command. let reclaim = registered_project_reclaim_hint(entry).unwrap_or_default(); - // The measured bytes and bound follow this subject. A very long physical - // path must not crowd those numbers out of Doctor's bounded statement. - let root = if !reclaim.is_empty() && root.len() > 96 { - " (registered root omitted from bounded diagnostic)".to_owned() - } else { - root + let floor = floor_note(entry.unavailable_entry_count, false); + let render = |root: &str, reclaim: &str| { + format!("{label} {}{reclaim}{root}{breakdown}{floor}", entry.key) + }; + let fits = |subject: &str| { + let statement = footprint_statement(subject, entry.bytes, entry.bound_bytes); + super::diagnostics::bounded_statement(&statement) == statement }; + let detailed = render(&root, &reclaim); + if reclaim.is_empty() || fits(&detailed) { + return detailed; + } + // Budget the complete rendered finding, including both commands, the + // deletion warning, and the measured overage. A root-length heuristic + // does not account for selector length, UTF-8, or the other evidence. + let compact = render( + " (registered root omitted from bounded diagnostic)", + &reclaim, + ); + if fits(&compact) { + compact + } else { + // Never turn a long identity into a truncated destructive command. + // Keep the pressure evidence but omit an action that cannot fit whole. + render("", "") + } +} + +fn footprint_statement(subject: &str, bytes: u64, bound_bytes: u64) -> String { format!( - "{label} {}{reclaim}{root}{breakdown}{}", - entry.key, - floor_note(entry.unavailable_entry_count, false) + "{subject}: {} of {} bound", + format_bytes(bytes), + format_bytes(bound_bytes) ) } @@ -668,11 +687,7 @@ fn bounded_finding( DoctorCoverageCompletenessV1::Complete, ) }; - let statement = format!( - "{subject}: {} of {} bound", - format_bytes(bytes), - format_bytes(bound_bytes) - ); + let statement = footprint_statement(subject, bytes, bound_bytes); let reference = DoctorEvidenceReferenceV1::new(key.to_owned()).ok()?; let evidence = DoctorEvidenceRefV1::new(DoctorFindingFamilyV1::Storage, reference); let coverage = DoctorCoverageStatementV1::new( @@ -1113,6 +1128,76 @@ mod tests { ); } + #[test] + fn reclaim_guidance_omits_unusable_project_selectors() { + let fixture = profile_fixture(); + for id in [ + "proj_".to_owned(), + "proj_bad;echo".to_owned(), + "proj_bad`id".to_owned(), + format!("proj_{}", "a".repeat(160)), + ] { + let key = format!("projects/{id}"); + let store = fixture.profile.join(&key).join("tracedecay.db"); + sized(&store, 2048); + let registered = BTreeMap::from([(id, fixture.live_root.clone())]); + let overrides = BTreeMap::from([(key.clone(), 1)]); + let footprint = census_profile_footprint(&fixture.profile, ®istered, &overrides); + let observed = findings(&footprint); + let finding = &observed[&key]; + assert_eq!(finding.finding().state(), DoctorEvidenceStateV1::Degraded); + let statement = finding.finding().coverage().statement(); + assert!( + !statement.contains("projects forget"), + "unsafe or incomplete actions must be omitted: {statement}" + ); + assert!(statement.contains(" of 1 B bound"), "{statement}"); + assert_eq!( + std::fs::metadata(&store).expect("unchanged store").len(), + 2048 + ); + } + } + + #[test] + fn reclaim_guidance_preserves_real_identity_commands_and_large_store_evidence() { + let temporary = tempfile::tempdir().expect("large footprint fixture"); + let profile = temporary.path().join("profile"); + let id = "proj_0123456789abcdef"; + let key = format!("projects/{id}"); + let store = profile.join(&key).join("tracedecay.db"); + // Sparse metadata-only input: no allocation or read of the 15 GiB body. + sized(&store, 15 * GIB); + let registered = BTreeMap::from([(id.to_owned(), "界/".repeat(100))]); + let footprint = census_profile_footprint(&profile, ®istered, &BTreeMap::new()); + let observed = findings(&footprint); + let finding = &observed[&key]; + assert_eq!(finding.finding().state(), DoctorEvidenceStateV1::Degraded); + let statement = finding.finding().coverage().statement(); + assert!( + statement.contains(&format!("`tracedecay --dry-run projects forget {id}`")), + "{statement}" + ); + assert!( + statement.contains(&format!("`tracedecay --yes projects forget {id}`")), + "{statement}" + ); + assert!(statement.contains("DELETES project data"), "{statement}"); + assert!(statement.contains("registered root omitted"), "{statement}"); + assert!( + statement.ends_with(&format!( + "{} of {} bound", + format_bytes(15 * GIB), + format_bytes(default_bound_bytes(ProfileFamilyV1::ProjectStore)) + )), + "{statement}" + ); + assert_eq!( + std::fs::metadata(&store).expect("unchanged store").len(), + 15 * GIB + ); + } + #[test] fn an_owner_override_replaces_the_default_bound() { let fixture = profile_fixture(); From 6184c52201f47d7753751a8fad1a3048f0eecb07 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:08:08 -0800 Subject: [PATCH 6/8] style(doctor): satisfy Bazel rustfmt for reclaim selector Refs #4616 --- crates/tracedecay-maintenance/src/retention/footprint.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-maintenance/src/retention/footprint.rs b/crates/tracedecay-maintenance/src/retention/footprint.rs index baf6c5b6801..6d586d36219 100644 --- a/crates/tracedecay-maintenance/src/retention/footprint.rs +++ b/crates/tracedecay-maintenance/src/retention/footprint.rs @@ -587,7 +587,9 @@ fn registered_project_reclaim_hint(entry: &ProfileFootprintEntryV1) -> Option "proj_".len() - && id.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') })?; Some(format!( "; if retention stalls, LAST RESORT (DELETES project data): `tracedecay --dry-run projects forget {id}` then `tracedecay --yes projects forget {id}`" From 245bdacca884e1993ca15e4363c32daa76ad0419 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:52:34 -0800 Subject: [PATCH 7/8] test(automation): bind scheduler fixtures to backend identities Repair three hosted automation_runner_test failures by supplying the configured executable identity required by the production prefilter. Use the same identity for the prefilter and retained runner, preserving manual-schedule, exact-ledger, live-lock and lock-race assertions. Add a production-prefilter regression for unconfigured refusal, deduplication and same-occurrence recovery after binding, with zero backend calls and no effectful terminal. Do not relax backend admission, change timeouts, or alter the graph publication changes. Refs #4532 --- .../tests/automation_runner_test/jobs.rs | 124 ++++++++++++++++-- 1 file changed, 110 insertions(+), 14 deletions(-) diff --git a/crates/tracedecay/tests/automation_runner_test/jobs.rs b/crates/tracedecay/tests/automation_runner_test/jobs.rs index 740283d56e8..7342786381b 100644 --- a/crates/tracedecay/tests/automation_runner_test/jobs.rs +++ b/crates/tracedecay/tests/automation_runner_test/jobs.rs @@ -86,6 +86,7 @@ fn enabled_job_config() -> AutomationSettingsV1 { struct ContentBackend { calls: AtomicUsize, content: &'static str, + executable: Option, } impl ContentBackend { @@ -93,9 +94,15 @@ impl ContentBackend { Self { calls: AtomicUsize::new(0), content, + executable: None, } } + fn with_executable(mut self, executable: &Path) -> Self { + self.executable = Some(executable.to_path_buf()); + self + } + fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } @@ -126,7 +133,7 @@ impl AgentTaskBackend for ContentBackend { } fn executable(&self) -> Option<&std::path::Path> { - None + self.executable.as_deref() } } @@ -988,6 +995,10 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let mut job = sample_job("sched-job"); job.schedule = Some("manual".to_string()); let config = enabled_job_config(); + // Reach the schedule gate with a bound identity, not the earlier + // backend-unconfigured refusal. This gate never invokes the backend. + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); // Manual schedule: every scheduler tick of the same occurrence reaches // the prefilter, which records the skip once. @@ -996,7 +1007,7 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let run = evaluate_and_record_scheduler_skip( &dashboard_root, &config, - None, + backend.executable(), &job, "sched-occurrence", None, @@ -1019,7 +1030,7 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let repeated = evaluate_and_record_scheduler_skip( &dashboard_root, &config, - None, + backend.executable(), &job, "sched-occurrence", None, @@ -1031,6 +1042,76 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { repeated.ledger_record, records[0], "an immediate repeat skip must return the exact durable prior row" ); + assert_eq!(backend.calls(), 0); +} + +#[tokio::test] +async fn scheduler_prefilter_rejects_unconfigured_backend_and_recovers_without_effect() { + let temp = tempdir().unwrap(); + let dashboard_root = temp.path().join("dashboard"); + let job = sample_job("backend-recovery"); + let config = enabled_job_config(); + let occurrence = "backend-recovery-occurrence"; + let backend = ContentBackend::new("unused"); + + let first = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .expect("a due job still requires a configured backend"); + assert_eq!(first.report["reason"], json!("backend_unconfigured")); + assert_eq!(first.ledger_record.status, AutomationRunStatus::Skipped); + assert_ne!(first.run_id, occurrence); + let repeated = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(repeated.ledger_record, first.ledger_record); + + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = backend.with_executable(executable.path()); + assert!( + evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .is_none(), + "binding the backend must admit the same due occurrence without cooldown" + ); + assert_eq!(backend.calls(), 0); + assert_eq!( + load_run_records(&dashboard_root, 10).await.unwrap(), + vec![first.ledger_record], + "advisory admission must not append an effectful terminal" + ); + assert!( + tracedecay_automation_runtime::automation::run_ledger::find_run_record_exact_bounded( + &dashboard_root, + occurrence, + ) + .await + .unwrap() + .is_none() + ); } #[tokio::test] @@ -1206,15 +1287,22 @@ async fn scheduler_prefilter_live_lock_wins_over_not_due_summary() { let config = enabled_job_config(); let occurrence = "locked-prefilter-occurrence"; - let prefilter = - evaluate_and_record_scheduler_skip(&dashboard_root, &config, None, &job, occurrence, None) - .await - .unwrap() - .expect("the live job lock must produce a diagnostic"); + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); + let prefilter = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .expect("the live job lock must produce a diagnostic"); assert_eq!(prefilter.report["reason"], json!("scheduler_lock_active")); assert_ne!(prefilter.run_id, occurrence); - let backend = ContentBackend::new("unused"); let admitted_job = tracedecay_automation_runtime::automation::jobs::admit_job(&job).unwrap(); let retained = run_user_job_with_backend_for_retained_settlement( &dashboard_root, @@ -1262,12 +1350,21 @@ async fn retained_scheduler_runner_reacquires_after_due_prefilter() { job.schedule = Some("every 1h".to_owned()); let config = enabled_job_config(); let occurrence = "prefilter-gap-occurrence"; + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); assert!( - evaluate_and_record_scheduler_skip(&dashboard_root, &config, None, &job, occurrence, None) - .await - .unwrap() - .is_none(), + evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .is_none(), "a new interval job is advisory-due before effect admission" ); let _raced_in = @@ -1276,7 +1373,6 @@ async fn retained_scheduler_runner_reacquires_after_due_prefilter() { .unwrap() .expect("the advisory prefilter must release its reservation lock"); - let backend = ContentBackend::new("unused"); let admitted_job = tracedecay_automation_runtime::automation::jobs::admit_job(&job).unwrap(); let retained = run_user_job_with_backend_for_retained_settlement( &dashboard_root, From 621940d19ed0c4c45b841dfda1bd653a2bbf1afa Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:04:09 -0800 Subject: [PATCH 8/8] fix(sessions): retain the cause of cold Git sync failures --- .../cli_non_interactive_test.rs | 24 +++++++++++++++---- .../src/session_sync/git_topology.rs | 14 +++++++---- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/crates/tracedecay-cli/tests/core_cli_suite/cli_non_interactive_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/cli_non_interactive_test.rs index 3391fe3c5d6..c33f3c452e2 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/cli_non_interactive_test.rs @@ -664,12 +664,18 @@ fn git_sync_failure(message: &str, output: &Output) -> String { ) } -fn assert_mounted_git_sync(output: &Output, dry_run: bool) { +fn assert_mounted_git_sync(output: &Output, dry_run: bool, daemon_log: &Path) { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), - "sessions git-sync should succeed\nstdout:\n{stdout}\nstderr:\n{stderr}" + "sessions git-sync should succeed\nstdout:\n{stdout}\nstderr:\n{stderr}\ntopology diagnostics:\n{}", + std::fs::read_to_string(daemon_log) + .expect("read isolated daemon log") + .lines() + .filter(|line| line.contains("git_topology_publication_unavailable")) + .collect::>() + .join("\n") ); assert!( stdout.starts_with("session git sync completed (session-sync."), @@ -723,10 +729,13 @@ fn sessions_git_sync_on_a_cold_daemon_waits_for_the_project_mount() { let hold = canonical_temp_path(home.path()).join("hold-after-core-publish"); std::fs::write(&hold, b"hold").unwrap(); let entered = PathBuf::from(format!("{}.entered", hold.display())); - let _daemon = crate::common::spawn_tracedecay_daemon_with(home.path(), { + let daemon_log = home.path().join("git-sync-daemon.log"); + let _daemon = crate::common::spawn_tracedecay_daemon_logged(home.path(), &daemon_log, { let hold = hold.clone(); move |command| { - command.env("TRACEDECAY_TEST_HOLD_AFTER_CORE_PUBLISH", &hold); + command + .env("TRACEDECAY_TEST_HOLD_AFTER_CORE_PUBLISH", &hold) + .env("RUST_LOG", "warn"); } }); @@ -775,11 +784,16 @@ fn sessions_git_sync_on_a_cold_daemon_waits_for_the_project_mount() { assert_mounted_git_sync( &wait_for_git_sync(&mut child, &mut stdout, &mut stderr), true, + &daemon_log, ); let mut command = tracedecay_command_without_daemon(home.path(), &project_root); command.args(["sessions", "git-sync"]); - assert_mounted_git_sync(&run_with_timeout(command, cli_timeout()), false); + assert_mounted_git_sync( + &run_with_timeout(command, cli_timeout()), + false, + &daemon_log, + ); } fn dashboard_session_authority(capabilities_url: &str) -> String { diff --git a/crates/tracedecay-session-runtime/src/session_sync/git_topology.rs b/crates/tracedecay-session-runtime/src/session_sync/git_topology.rs index 27ec0dd156a..dbf53305d3b 100644 --- a/crates/tracedecay-session-runtime/src/session_sync/git_topology.rs +++ b/crates/tracedecay-session-runtime/src/session_sync/git_topology.rs @@ -57,8 +57,10 @@ impl SessionSyncProjectContext { &self.project_id, ) { Ok(scope) => scope, - Err(_) => { - return GitTopologySyncOutcome::Finished(Err(GitTopologySyncFailure::Unavailable)); + Err(error) => { + return GitTopologySyncOutcome::Finished(Err( + unavailable("resolve project scope")(error), + )); } }; let Some(runtime) = project_sessions.project_graph_runtime() else { @@ -72,8 +74,10 @@ impl SessionSyncProjectContext { let runtime: Arc = Arc::new(runtime.clone()); let scope_sets = match project_sessions.authorized_scope_set_storage() { Ok(scope_sets) => scope_sets, - Err(_) => { - return GitTopologySyncOutcome::Finished(Err(GitTopologySyncFailure::Unavailable)); + Err(error) => { + return GitTopologySyncOutcome::Finished(Err(unavailable( + "open scope-set storage", + )(error))); } }; let project_root = self.project_root.clone(); @@ -97,7 +101,7 @@ impl SessionSyncProjectContext { result = &mut worker => { GitTopologySyncOutcome::Finished(match result { Ok(result) => result, - Err(_) => Err(GitTopologySyncFailure::Unavailable), + Err(error) => Err(unavailable("join topology publisher")(error)), }) } interruption = service.wait_for_interruption(request) => {