use crate::config::load_config;
use crate::error::{ArtifactRetrievalWarning, DaemonError, TransferError};
use crate::state::primitives::atomic_write;
use crate::status_types::format_bytes;
use crate::toolchain::{detect_toolchain, parse_channel_string};
use crate::transfer::{
SyncResult, TransferPipeline, WorkerPlatform, clean_overlay_include_patterns,
compute_project_hash_with_dependency_roots_and_policy, configure_clean_git_command,
default_bun_artifact_patterns, default_c_cpp_artifact_patterns, default_rust_artifact_patterns,
default_rust_test_artifact_patterns, default_zigbuild_artifact_patterns, project_id_from_path,
};
use crate::ui::console::RchConsole;
use anyhow::Context;
use rch_common::errors::catalog::ErrorCode;
use rch_common::job_identity::{DurableJobLease, JobIdentity, default_job_lease_directory};
use rch_common::repo_updater_contract::{
REPO_UPDATER_ALLOW_OVERRIDE_ENV, REPO_UPDATER_ALLOWED_HOSTS_ENV, REPO_UPDATER_ALLOWLIST_ENV,
REPO_UPDATER_AUTH_CREDENTIAL_ID_ENV, REPO_UPDATER_AUTH_EXPIRES_AT_MS_ENV,
REPO_UPDATER_AUTH_ISSUED_AT_MS_ENV, REPO_UPDATER_AUTH_MODE_ENV, REPO_UPDATER_AUTH_REVOKED_ENV,
REPO_UPDATER_AUTH_SCOPES_ENV, REPO_UPDATER_AUTH_SOURCE_ENV,
REPO_UPDATER_AUTH_VERIFIED_HOSTS_ENV, REPO_UPDATER_OVERRIDE_APPROVED_AT_MS_ENV,
REPO_UPDATER_OVERRIDE_AUDIT_EVENT_ID_ENV, REPO_UPDATER_OVERRIDE_JUSTIFICATION_ENV,
REPO_UPDATER_OVERRIDE_OPERATOR_ID_ENV, REPO_UPDATER_OVERRIDE_TICKET_REF_ENV,
REPO_UPDATER_REQUIRE_HOST_IDENTITY_ENV, REPO_UPDATER_REQUIRED_SCOPES_ENV,
REPO_UPDATER_ROTATION_MAX_AGE_SECS_ENV, REPO_UPDATER_TRUSTED_HOST_IDENTITIES_ENV,
RepoUpdaterAuthContext, RepoUpdaterAuthMode, RepoUpdaterCredentialSource,
RepoUpdaterOperatorOverride, RepoUpdaterTrustedHostIdentity, RepoUpdaterVerifiedHostIdentity,
};
use rch_common::{
BuildHeartbeatPhase, BuildHeartbeatRequest, Classification, ColorMode, CommandPriority,
CommandTimingBreakdown, CompilationKind, ControlState, DependencyClosurePlan, HookInput,
HookOutput, IncidentEvent, IncidentEventType, IncidentLedger, IncidentLedgerConfig,
IncidentReasonCode, IncidentSource, OutputVisibility, REPO_UPDATER_CANONICAL_PROJECTS_ROOT,
RepoUpdaterAdapterCommand, RepoUpdaterAdapterContract, RepoUpdaterAdapterRequest,
RepoUpdaterOutputFormat, RequestedWorkerFacts, RequestedWorkerOutcome, RequestedWorkerStatus,
RequiredRuntime, SelectedMode, SelectedWorker, SelectionDiagnostics, SelectionReason,
SelectionResponse, SelfHealingConfig, ToolchainInfo, TransferConfig, WorkerConfig, WorkerId,
build_dependency_closure_plan_with_policy, build_invocation, classify_command,
declined_compilation_due_to_structure, default_socket_path, evaluate_requested_worker, mock,
normalize_project_path_with_policy,
path_topology::PathTopologyPolicy,
redaction::{redact_path, redact_secrets},
ui::{
ArtifactSummary, CelebrationSummary, CompilationProgress, CompletionCelebration, Icons,
OutputContext, RchTheme, TransferProgress,
},
};
use rch_telemetry::protocol::{
PIGGYBACK_MARKER, TelemetrySource, TestRunRecord, WorkerTelemetry,
extract_piggybacked_telemetry,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs::OpenOptions;
use std::io::{self, Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::Component;
use std::path::{Path, PathBuf};
use std::process::{Output, Stdio};
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::process::Command;
use tokio::sync::oneshot;
use tokio::time::{sleep, timeout};
use tracing::{debug, info, warn};
use which::which;
#[cfg(all(feature = "rich-ui", unix))]
use rich_rust::renderables::Panel;
#[allow(dead_code)]
const EXIT_SUCCESS: i32 = 0;
const EXIT_BUILD_ERROR: i32 = 1;
#[allow(dead_code)] const EXIT_TEST_FAILURES: i32 = 101;
#[allow(dead_code)] const EXIT_SIGNAL_BASE: i32 = 128;
const EXIT_ARTIFACT_TRANSFER_FAILED: i32 = 102;
const EXIT_REMOTE_REQUIRED_REFUSED: i32 = 103;
const RCH_CARGO_WRAPPER_BYPASS_ENV: &str = "RCH_CARGO_WRAPPER_BYPASS";
const RCH_REQUIRE_REMOTE_ENV: &str = "RCH_REQUIRE_REMOTE";
const RCH_DISABLE_TARGET_REUSE_ENV: &str = "RCH_DISABLE_TARGET_REUSE";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct CleanOverlaySpec {
base_commit: String,
tree_object: String,
overlay_paths: Vec<PathBuf>,
overlay_fingerprint: String,
dependencies: Vec<(PathBuf, CleanOverlaySpec)>,
primary_directory: Option<PathBuf>,
}
impl CleanOverlaySpec {
pub(super) fn base_commit(&self) -> &str {
&self.base_commit
}
pub(super) fn overlay_paths(&self) -> &[PathBuf] {
&self.overlay_paths
}
pub(super) fn is_base_only(&self) -> bool {
self.overlay_paths.is_empty()
}
pub(super) fn overlay_fingerprint(&self) -> &str {
&self.overlay_fingerprint
}
pub(super) fn execution_receipt(&self) -> String {
let mut receipt = format!(
"[RCH] clean-overlay receipt: base={} overlay-fingerprint={} tree={}",
self.base_commit, self.overlay_fingerprint, self.tree_object
);
for (root, spec) in &self.dependencies {
receipt.push_str(&format!(
" dependency-root={} commit={} tree={} overlay-fingerprint={}",
root.display(),
spec.base_commit,
spec.tree_object,
spec.overlay_fingerprint
));
}
receipt
}
pub(super) async fn verify_archive_attributes(
&self,
project_root: &Path,
) -> anyhow::Result<()> {
validate_clean_overlay_archive_attributes(project_root, &self.base_commit).await
}
pub(super) fn verify_overlay_unchanged(&self, project_root: &Path) -> anyhow::Result<()> {
let current = clean_overlay_fingerprint(project_root, &self.overlay_paths)?;
if current != self.overlay_fingerprint {
anyhow::bail!(
"clean-overlay input changed after admission (expected {}, found {}); refusing remote execution",
self.overlay_fingerprint,
current
);
}
Ok(())
}
}
fn selection_project_for_execution(
project: &str,
clean_overlay: Option<&CleanOverlaySpec>,
job_nonce: uuid::Uuid,
) -> String {
let Some(spec) = clean_overlay else {
return project.to_string();
};
let mut hasher = blake3::Hasher::new();
hasher.update(b"rch-clean-overlay-selection-project-v1\0");
hasher.update(project.as_bytes());
hasher.update(b"\0");
hasher.update(spec.base_commit().as_bytes());
hasher.update(b"\0");
hasher.update(spec.overlay_fingerprint().as_bytes());
for (root, dependency) in &spec.dependencies {
hasher.update(b"\0dependency\0");
hasher.update(root.as_os_str().as_encoded_bytes());
hasher.update(b"\0");
hasher.update(dependency.base_commit.as_bytes());
}
hasher.update(b"\0");
hasher.update(job_nonce.as_bytes());
let suffix = hasher.finalize().to_hex();
format!("{project}::clean-overlay::{}", &suffix[..16])
}
static HOOK_MODE_PANIC_FAIL_OPEN: AtomicBool = AtomicBool::new(false);
static AUTOSTART_LOCK_SEQUENCE: AtomicU64 = AtomicU64::new(0);
use rch_common::util::mask_sensitive_command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RemotePipelineFailurePolicy {
AllowLocalFallback,
FailClosedNoLocalFallback,
}
fn classify_remote_pipeline_failure(error: &anyhow::Error) -> RemotePipelineFailurePolicy {
if is_remote_execution_unconfirmed(error) || is_ssh_command_timeout_error(error) {
RemotePipelineFailurePolicy::FailClosedNoLocalFallback
} else {
RemotePipelineFailurePolicy::AllowLocalFallback
}
}
fn is_remote_execution_unconfirmed(error: &anyhow::Error) -> bool {
error
.downcast_ref::<crate::transfer::RemoteExecutionUnconfirmed>()
.is_some()
}
fn is_ssh_command_timeout_error(error: &anyhow::Error) -> bool {
error.chain().any(|cause| {
let message = cause.to_string();
message.contains("SSH command timed out after")
|| message.contains("Command timed out after")
})
}
fn remote_pipeline_failure_summary(worker_id: &WorkerId, error: &anyhow::Error) -> String {
if is_remote_execution_unconfirmed(error) {
return format!(
"[RCH] remote {} completion unconfirmed; ownership retained for recovery (no local fallback)",
worker_id
);
}
format!(
"[RCH] remote {} failed [{}] SSH command timed out (no local fallback)",
worker_id,
ErrorCode::SshTimeout.code_string()
)
}
fn ssh_timeout_with_unverified_cleanup(
error: &anyhow::Error,
) -> Option<&crate::transfer::SshCommandTimedOut> {
error
.chain()
.find_map(|cause| cause.downcast_ref::<crate::transfer::SshCommandTimedOut>())
.filter(|timeout| timeout.cleanup == crate::transfer::RemoteTimeoutCleanup::Unverified)
}
async fn quarantine_worker_on_unverified_timeout_cleanup(
error: &anyhow::Error,
socket_path: &str,
worker_id: &WorkerId,
reporter: &HookReporter,
) {
let Some(timeout_err) = ssh_timeout_with_unverified_cleanup(error) else {
return;
};
warn!(
"E104 timeout on {} left an unverified remote process group ({}); quarantining worker",
worker_id, timeout_err.detail
);
reporter.summary_critical(&format!(
"[RCH] worker {} flagged for quarantine [{}]: E104 cleanup unverified ({}) — possible orphan holding the project's Cargo target lock",
worker_id,
ErrorCode::SshTimeout.code_string(),
timeout_err.detail
));
if let Err(error) = disable_worker_for_fault(
socket_path,
worker_id,
"e104-timeout-orphan-unverified: remote build process group not verified dead after client timeout; possible orphan holding the project's Cargo target lock",
)
.await
{
warn!(
"Failed to quarantine worker {} after unverified E104 cleanup: {}",
worker_id, error
);
}
}
pub async fn run_hook() -> anyhow::Result<()> {
let mut stdout = io::stdout();
let mut input = String::new();
{
use tokio::io::{AsyncReadExt, stdin};
if let Err(e) = stdin()
.take(10 * 1024 * 1024)
.read_to_string(&mut input)
.await
{
warn!(target: "rch::hook", error = %e, "stdin read failed; allowing command (fail-open)");
return Ok(());
}
}
let input = input.trim();
if input.is_empty() {
return Ok(());
}
let hook_input: HookInput = match serde_json::from_str(input) {
Ok(hi) => hi,
Err(e) => {
warn!("Failed to parse hook input: {}", e);
return Ok(());
}
};
let output = process_hook(hook_input).await;
match &output {
HookOutput::Deny(_) | HookOutput::AllowWithModifiedCommand(_) => {
match serde_json::to_string(&output) {
Ok(json) => {
if let Err(e) = writeln!(stdout, "{}", json) {
warn!(target: "rch::hook", error = %e, "stdout write failed; falling open");
return Ok(());
}
if let Err(e) = stdout.flush() {
warn!(target: "rch::hook", error = %e, "stdout flush failed; falling open");
return Ok(());
}
}
Err(e) => {
warn!(target: "rch::hook", error = %e, "JSON serialization failed; falling open");
return Ok(());
}
}
}
HookOutput::Allow(_) => {
}
}
Ok(())
}
pub fn install_hook_mode_panic_handler() {
enable_hook_mode_panic_fail_open();
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if hook_mode_panic_fail_open_enabled() {
eprintln!("[rch] hook panicked; falling open. (set RUST_BACKTRACE=1 to see)");
if std::env::var("RUST_BACKTRACE")
.ok()
.as_deref()
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("full"))
{
original(info);
}
std::process::exit(0);
} else {
original(info);
}
}));
});
}
fn enable_hook_mode_panic_fail_open() {
HOOK_MODE_PANIC_FAIL_OPEN.store(true, Ordering::Release);
}
fn hook_mode_panic_fail_open_enabled() -> bool {
HOOK_MODE_PANIC_FAIL_OPEN.load(Ordering::Acquire)
|| std::env::var("RCH_HOOK_MODE")
.ok()
.as_deref()
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
fn join_exec_command(command_parts: &[String]) -> String {
let normalized_parts = normalize_exec_command_parts(command_parts);
shell_words::join(normalized_parts)
}
fn normalize_exec_command_parts(command_parts: &[String]) -> Vec<String> {
if command_parts.len() == 1 {
match shell_words::split(&command_parts[0]) {
Ok(parts) if parts.len() > 1 => return parts,
_ => {}
}
}
command_parts.to_vec()
}
fn shell_wrapped_cargo_command(command_parts: &[String]) -> bool {
let parts = normalize_exec_command_parts(command_parts);
let Some(shell) = parts.first() else {
return false;
};
let shell_name = Path::new(shell).file_name().and_then(|name| name.to_str());
if !matches!(shell_name, Some("sh" | "bash" | "dash" | "zsh")) {
return false;
}
let script = parts.iter().skip(1).enumerate().find_map(|(index, arg)| {
let short_option_with_command = arg.strip_prefix('-').is_some_and(|options| {
!options.starts_with('-')
&& options.chars().all(char::is_alphabetic)
&& options.contains('c')
});
if arg == "-c" || short_option_with_command {
parts.get(index + 2).map(String::as_str)
} else {
None
}
});
script.is_some_and(|script| classify_command(script).is_compilation)
}
fn env_flag_enabled(value: &str) -> bool {
matches!(
value.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
fn rustup_toolchain_from_command_tokens(tokens: &[String]) -> Option<String> {
let mut prev_is_cargo = false;
for token in tokens {
let token = token.trim_matches(['\'', '"']);
if let Some(value) = token.strip_prefix("RUSTUP_TOOLCHAIN=") {
let value = value.trim_matches(['\'', '"']);
if !value.is_empty() {
return Some(value.to_string());
}
}
if prev_is_cargo
&& let Some(value) = token.strip_prefix('+')
&& !value.is_empty()
{
return Some(value.to_string());
}
prev_is_cargo = token == "cargo"
|| token
.rsplit_once('/')
.is_some_and(|(_, name)| name == "cargo");
}
None
}
fn exec_requires_remote() -> bool {
std::env::var(RCH_REQUIRE_REMOTE_ENV).is_ok_and(|value| env_flag_enabled(&value))
}
fn role_requires_remote(role: rch_common::BoxRole) -> bool {
role == rch_common::BoxRole::Dispatcher
&& std::env::var(RCH_REQUIRE_REMOTE_ENV).is_err()
&& std::env::var("RCH_FORCE_REMOTE").is_err()
}
fn requested_worker_outcome(
requested: &WorkerId,
diagnostics: Option<&SelectionDiagnostics>,
) -> RequestedWorkerOutcome {
let Some(diagnostics) = diagnostics else {
return RequestedWorkerOutcome::requested();
};
let Some(worker) = diagnostics
.workers
.iter()
.find(|worker| worker.worker_id == *requested)
else {
return evaluate_requested_worker(&RequestedWorkerFacts {
requested: Some(requested.to_string()),
exists: false,
..RequestedWorkerFacts::none()
});
};
evaluate_requested_worker(&RequestedWorkerFacts::from_diagnostic(
requested.to_string(),
worker,
))
}
struct RequestedWorkerRefusal {
summary: String,
reason_code: IncidentReasonCode,
}
fn requested_worker_refusal(
requested_workers: &[WorkerId],
response: &SelectionResponse,
) -> Option<RequestedWorkerRefusal> {
if requested_workers.is_empty() || response.worker.is_some() {
return None;
}
let outcomes = requested_workers
.iter()
.map(|worker| {
(
worker,
requested_worker_outcome(worker, response.diagnostics.as_ref()),
)
})
.collect::<Vec<_>>();
let preferred_status = match response.reason {
SelectionReason::AllWorkersBusy => Some(RequestedWorkerStatus::NoFreeSlots),
SelectionReason::NoWorkersWithRuntime(_) => Some(RequestedWorkerStatus::MissingRuntime),
SelectionReason::AllCircuitsOpen => Some(RequestedWorkerStatus::TemporarilyBypassed),
_ => None,
};
let selected = preferred_status
.and_then(|status| {
outcomes
.iter()
.find(|(_, outcome)| outcome.status == status)
})
.or_else(|| {
outcomes
.iter()
.find(|(_, outcome)| outcome.status.is_refusal())
});
let requested = requested_workers
.iter()
.map(WorkerId::as_str)
.collect::<Vec<_>>()
.join(",");
let (code, summary) = if let Some((_, outcome)) = selected {
let code = outcome.reason_code.as_deref().unwrap_or("RCH-I001");
let next_action = outcome
.next_action
.as_deref()
.unwrap_or("run `rch diagnose -- <command>` and request an admissible worker");
(
code.to_string(),
format!(
"[{code}] requested worker set [{requested}] refused ({}); {next_action}",
outcome.status.as_str()
),
)
} else {
(
"RCH-I001".to_string(),
format!(
"[RCH-I001] requested worker set [{requested}] refused ({}); run `rch diagnose -- <command>` and request an admissible worker",
response.reason
),
)
};
let reason_code =
IncidentReasonCode::from_code_str(&code).unwrap_or(IncidentReasonCode::NoAdmissibleWorkers);
Some(RequestedWorkerRefusal {
summary,
reason_code,
})
}
#[cfg(test)]
fn requested_worker_refusal_summary(
requested_workers: &[WorkerId],
response: &SelectionResponse,
) -> Option<String> {
requested_worker_refusal(requested_workers, response).map(|refusal| refusal.summary)
}
fn build_requested_worker_refusal_incident(
reason_code: IncidentReasonCode,
requested_workers: &[WorkerId],
project: &str,
command_fingerprint: &str,
strict_remote: bool,
now_ms: u64,
) -> IncidentEvent {
let requested = requested_workers
.iter()
.map(WorkerId::as_str)
.collect::<Vec<_>>()
.join(",");
IncidentEvent::new(
IncidentEventType::Selection,
reason_code,
IncidentSource::Hook,
project,
command_fingerprint,
SelectedMode::Local,
!strict_remote,
now_ms,
)
.with_detail("requested_worker_set", requested)
.with_detail("refusal", "requested_worker")
.with_control(ControlState {
strict_remote_policy: strict_remote,
..ControlState::default()
})
}
fn selected_worker_is_requested(worker: &WorkerId, requested_workers: &[WorkerId]) -> bool {
requested_workers.is_empty() || requested_workers.contains(worker)
}
fn local_fallback_command(command: &str) -> std::process::Command {
let mut child = std::process::Command::new("sh");
child
.env(RCH_CARGO_WRAPPER_BYPASS_ENV, "1")
.arg("-c")
.arg(command);
child
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LocalFallbackRefusal {
RemoteRequired,
}
fn local_fallback_command_for_policy(
command: &str,
require_remote: bool,
) -> Result<std::process::Command, LocalFallbackRefusal> {
if require_remote {
Err(LocalFallbackRefusal::RemoteRequired)
} else {
Ok(local_fallback_command(command))
}
}
fn remote_required_refusal_is_retryable(reason: &str) -> bool {
!matches!(reason, "non-compilation command" | "config unavailable")
&& !ConfigLocalPolicy::is_policy_reason(reason)
}
fn remote_required_refusal_summary(reason: &str) -> String {
if reason == "non-compilation command" {
format!(
"[RCH] remote required; refusing local fallback [{}] ({reason})",
ErrorCode::BuildUnknownCommand.code_string()
)
} else if remote_required_refusal_is_retryable(reason) {
format!("[RCH] remote required; refusing local fallback ({reason}) — retryable")
} else {
format!("[RCH] remote required; refusing local fallback ({reason})")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConfigLocalPolicy {
Disabled,
ForceLocal,
ConflictingForceFlags,
NotAllowlisted(&'static str),
}
impl ConfigLocalPolicy {
const DISABLED_REASON: &'static str = "rch disabled (general.enabled=false)";
const FORCE_LOCAL_REASON: &'static str = "force_local";
const CONFLICT_REASON: &'static str = "invalid config: force_local+force_remote";
const NOT_ALLOWLISTED_SUFFIX: &'static str = "not in execution.allowlist";
pub(crate) fn reason(self) -> String {
match self {
Self::Disabled => Self::DISABLED_REASON.to_string(),
Self::ForceLocal => Self::FORCE_LOCAL_REASON.to_string(),
Self::ConflictingForceFlags => Self::CONFLICT_REASON.to_string(),
Self::NotAllowlisted(base) => {
format!("command '{base}' {}", Self::NOT_ALLOWLISTED_SUFFIX)
}
}
}
pub(crate) fn overrides_require_remote(self) -> bool {
!matches!(self, Self::NotAllowlisted(_))
}
fn is_policy_reason(reason: &str) -> bool {
matches!(
reason,
Self::DISABLED_REASON | Self::FORCE_LOCAL_REASON | Self::CONFLICT_REASON
) || (reason.starts_with("command '") && reason.ends_with(Self::NOT_ALLOWLISTED_SUFFIX))
}
}
pub(crate) fn config_local_policy(
config: &rch_common::RchConfig,
kind: Option<CompilationKind>,
) -> Option<ConfigLocalPolicy> {
if !config.general.enabled {
return Some(ConfigLocalPolicy::Disabled);
}
if config.general.force_local && config.general.force_remote {
return Some(ConfigLocalPolicy::ConflictingForceFlags);
}
if config.general.force_local {
return Some(ConfigLocalPolicy::ForceLocal);
}
match kind {
Some(CompilationKind::Job) | None => None,
Some(kind) => {
let base = kind.command_base();
(!config.execution.is_allowed(base)).then_some(ConfigLocalPolicy::NotAllowlisted(base))
}
}
}
fn exit_with_local_fallback(
command: &str,
reporter: &HookReporter,
reason: &str,
require_remote: bool,
) -> ! {
record_hook_incident(&build_local_fallback_incident(
command,
reason,
require_remote,
now_unix_ms(),
));
let mut child = match local_fallback_command_for_policy(command, require_remote) {
Ok(child) => child,
Err(LocalFallbackRefusal::RemoteRequired) => {
reporter.summary_critical(&remote_required_refusal_summary(reason));
emit_exec_envelope(&ExecResultEnvelope {
api_version: "1.0",
command,
outcome: "refused",
location: "local",
fallback_reason: Some(reason),
worker_id: None,
remote_exit_code: None,
duration_ms: None,
timing: None,
result_dirs: None,
error_code: None,
});
if remote_required_refusal_is_retryable(reason) {
std::process::exit(EXIT_REMOTE_REQUIRED_REFUSED);
}
std::process::exit(EXIT_BUILD_ERROR);
}
};
match child.status() {
Ok(status) => {
emit_exec_envelope(&ExecResultEnvelope {
api_version: "1.0",
command,
outcome: "completed",
location: "local",
fallback_reason: Some(reason),
worker_id: None,
remote_exit_code: status.code(),
duration_ms: None,
timing: None,
result_dirs: None,
error_code: None,
});
std::process::exit(status.code().unwrap_or(1))
}
Err(error) => {
reporter.summary(&format!("[RCH] local fallback failed: {error}"));
emit_exec_envelope(&ExecResultEnvelope {
api_version: "1.0",
command,
outcome: "transport_error",
location: "local",
fallback_reason: Some(reason),
worker_id: None,
remote_exit_code: None,
duration_ms: None,
timing: None,
result_dirs: None,
error_code: None,
});
std::process::exit(EXIT_BUILD_ERROR);
}
}
}
const RCH_MAX_REMOTE_ATTEMPTS_ENV: &str = "RCH_MAX_REMOTE_ATTEMPTS";
const DEFAULT_MAX_REMOTE_ATTEMPTS: u32 = 3;
fn max_remote_attempts() -> u32 {
std::env::var(RCH_MAX_REMOTE_ATTEMPTS_ENV)
.ok()
.and_then(|raw| raw.trim().parse::<u32>().ok())
.map(|value| value.max(1))
.unwrap_or(DEFAULT_MAX_REMOTE_ATTEMPTS)
}
fn strict_remote_for_exhausted_admission(require_remote: bool, job_admission: bool) -> bool {
require_remote || job_admission
}
fn exit_with_gated_local_fallback(
command: &str,
reporter: &HookReporter,
reason: &str,
require_remote: bool,
allow_local_fallback: bool,
) -> ! {
if require_remote {
exit_with_local_fallback(command, reporter, reason, true);
}
if !allow_local_fallback {
warn!(
"Local fallback disabled (compilation.allow_local_fallback=false); refusing to run '{}' on the orchestrator after remote failure: {reason}",
mask_sensitive_command(command)
);
reporter.summary_critical(&format!(
"[RCH] refusing local fallback (compilation.allow_local_fallback=false; {reason})"
));
std::process::exit(EXIT_BUILD_ERROR);
}
reporter.summary(&format!("[RCH] local ({reason})"));
exit_with_local_fallback(command, reporter, reason, false)
}
struct RetryableRemoteFault {
log_reason: String,
failed_worker: WorkerId,
on_exhaust: RemoteFaultExhaustAction,
}
enum RemoteFaultExhaustAction {
ExitWithCode { code: i32, summary: String },
ExitWithEnvRemediation {
code: i32,
summary: String,
remediation: String,
},
GatedLocalFallback { reason: String },
}
#[allow(clippy::too_many_arguments)]
async fn try_retry_on_bigger_worker(
socket_path: &str,
project: &str,
estimated_cores: u32,
remote_command: &str,
toolchain: Option<&ToolchainInfo>,
required_runtime: RequiredRuntime,
command_priority: CommandPriority,
tried_workers: &[WorkerId],
worker_pin: &[WorkerId],
local_wrapper_id: Option<&str>,
reporter: &HookReporter,
) -> Option<(SelectionResponse, WorkerId)> {
let status = match crate::status_display::query_daemon_full_status().await {
Ok(status) => status,
Err(e) => {
reporter.verbose(&format!(
"[RCH] retry: could not fetch worker status ({e}); no bigger worker to try"
));
return None;
}
};
let snapshots = build_capacity_snapshots(&status);
let chosen = pick_bigger_worker(snapshots.as_slice(), tried_workers, worker_pin)?;
let preferred = vec![chosen.clone()];
match query_daemon(
socket_path,
project,
estimated_cores,
remote_command,
toolchain,
required_runtime,
command_priority,
0,
Some(std::process::id()),
local_wrapper_id,
false, &preferred,
false, &[], )
.await
{
Ok(response) if response.worker.is_some() => Some((response, chosen)),
Ok(_) => {
reporter.verbose(&format!(
"[RCH] retry: bigger worker {chosen} is not currently admissible; ending retries"
));
None
}
Err(e) => {
reporter.verbose(&format!(
"[RCH] retry: re-query for {chosen} failed ({e}); ending retries"
));
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SocketFailureKind {
Missing,
Refused,
Stale,
Other,
}
impl SocketFailureKind {
fn as_str(self) -> &'static str {
match self {
SocketFailureKind::Missing => "missing",
SocketFailureKind::Refused => "refused",
SocketFailureKind::Stale => "stale",
SocketFailureKind::Other => "other",
}
}
}
fn classify_socket_failure(err: &anyhow::Error, socket_exists: bool) -> SocketFailureKind {
if let Some(daemon_err) = err.downcast_ref::<DaemonError>() {
match daemon_err {
DaemonError::SocketNotFound { .. } | DaemonError::NotRunning => {
return SocketFailureKind::Missing;
}
DaemonError::ConnectionFailed { .. } | DaemonError::SocketPermissionDenied { .. } => {
return SocketFailureKind::Refused;
}
_ => {}
}
}
for cause in err.chain() {
if let Some(io_err) = cause.downcast_ref::<std::io::Error>() {
return match io_err.kind() {
std::io::ErrorKind::NotFound => SocketFailureKind::Missing,
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::PermissionDenied => {
SocketFailureKind::Refused
}
std::io::ErrorKind::TimedOut => SocketFailureKind::Stale,
_ if socket_exists => SocketFailureKind::Stale,
_ => SocketFailureKind::Other,
};
}
}
if err.to_string().contains("timed out") {
return SocketFailureKind::Stale;
}
if socket_exists {
SocketFailureKind::Stale
} else {
SocketFailureKind::Missing
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SocketPathMismatch {
configured: String,
canonical: String,
canonical_exists: bool,
}
fn socket_paths_equivalent(a: &str, b: &str) -> bool {
a.trim() == b.trim()
}
fn detect_socket_path_mismatch(
configured: &str,
canonical: &str,
canonical_exists: bool,
) -> Option<SocketPathMismatch> {
if socket_paths_equivalent(configured, canonical) {
return None;
}
Some(SocketPathMismatch {
configured: configured.to_string(),
canonical: canonical.to_string(),
canonical_exists,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DaemonRecoveryAction {
ProceedRemote,
LocalFallback,
Refuse,
}
fn decide_recovery_action(retry_succeeded: bool, strict_remote: bool) -> DaemonRecoveryAction {
if retry_succeeded {
DaemonRecoveryAction::ProceedRemote
} else if strict_remote {
DaemonRecoveryAction::Refuse
} else {
DaemonRecoveryAction::LocalFallback
}
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}
#[derive(Clone)]
pub(crate) struct DurableLeaseWriter {
path: PathBuf,
lease: Arc<Mutex<DurableJobLease>>,
}
impl DurableLeaseWriter {
pub(crate) fn load(wrapper_id: &str) -> anyhow::Result<Self> {
anyhow::ensure!(
wrapper_id.starts_with("rchw-") && uuid::Uuid::parse_str(&wrapper_id[5..]).is_ok(),
"invalid wrapper identity"
);
let path = durable_lease_path(wrapper_id);
let lease: DurableJobLease = serde_json::from_slice(&std::fs::read(&path)?)?;
anyhow::ensure!(
lease.schema_version == 1 && lease.identity.local_wrapper_id == wrapper_id,
"durable lease identity or schema mismatch"
);
Ok(Self {
path,
lease: Arc::new(Mutex::new(lease)),
})
}
pub(crate) fn snapshot(&self) -> DurableJobLease {
self.lease.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
pub(crate) fn set_recovery(&self, recovery: serde_json::Value) -> anyhow::Result<()> {
self.lease
.lock()
.unwrap_or_else(|p| p.into_inner())
.recovery = Some(recovery);
self.persist()
}
pub(crate) fn record_exit(&self, exit_code: i32) -> anyhow::Result<()> {
self.lease
.lock()
.unwrap_or_else(|p| p.into_inner())
.exit_code = Some(exit_code);
self.persist()
}
fn create(
command: &str,
strict_remote: bool,
self_healing_enabled: bool,
) -> anyhow::Result<Self> {
let identity = JobIdentity::new_local();
let path = durable_lease_path(&identity.local_wrapper_id);
let command_fingerprint = format!("blake3:{}", blake3::hash(command.as_bytes()).to_hex());
let lease = DurableJobLease::new(
identity,
std::process::id(),
current_process_start_ticks(),
current_boot_id(),
now_unix_ms(),
strict_remote,
self_healing_enabled,
command_fingerprint,
);
let writer = Self {
path,
lease: Arc::new(Mutex::new(lease)),
};
writer.persist()?;
Ok(writer)
}
fn wrapper_id(&self) -> String {
self.lease
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.identity
.local_wrapper_id
.clone()
}
fn admit(&self, remote_build_id: u64, worker_id: &WorkerId) -> anyhow::Result<()> {
{
let mut lease = self
.lease
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(recovery) = lease.recovery.as_ref() {
anyhow::ensure!(
recovery.get("retired").and_then(serde_json::Value::as_bool) == Some(true),
"previous remote source ownership is unresolved; recover this wrapper before another admission"
);
}
lease.recovery = None;
lease.exit_code = None;
lease.terminal_acknowledged = false;
lease.admit(
remote_build_id,
worker_id.as_str().to_string(),
now_unix_ms(),
);
}
self.persist()
}
fn ensure_released_for_retry(&self) -> anyhow::Result<()> {
let lease = self.snapshot();
if let Some(recovery) = lease.recovery.as_ref() {
anyhow::ensure!(
recovery.get("retired").and_then(serde_json::Value::as_bool) == Some(true),
"remote source ownership is unresolved; run rch jobs recover {} before retrying",
lease.identity.local_wrapper_id
);
}
Ok(())
}
pub(crate) fn heartbeat(&self, phase: &str) -> anyhow::Result<()> {
{
let mut lease = self
.lease
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
lease.heartbeat(phase, now_unix_ms());
}
self.persist()
}
pub(crate) fn acknowledge_terminal(&self) -> anyhow::Result<()> {
{
let mut lease = self
.lease
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(recovery) = lease.recovery.as_ref()
&& (recovery
.get("returned")
.and_then(serde_json::Value::as_i64)
.is_none()
|| recovery.get("retired").and_then(serde_json::Value::as_bool) != Some(true))
{
anyhow::bail!(
"durable retrieval or source release evidence is incomplete; terminal acknowledgement refused (state stays recoverable)"
);
}
lease.acknowledge_terminal(now_unix_ms());
}
self.persist()
}
fn persist(&self) -> anyhow::Result<()> {
self.persist_snapshot(atomic_write)
}
fn persist_snapshot(
&self,
publish: impl FnOnce(&Path, &[u8]) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
let lease = self
.lease
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let bytes = serde_json::to_vec_pretty(&*lease)?;
publish(&self.path, &bytes)
}
}
fn selection_error_for_recovery(
error: anyhow::Error,
lease: &DurableLeaseWriter,
) -> anyhow::Result<anyhow::Error> {
if error
.downcast_ref::<daemon_ipc::SelectionOutcomeUnconfirmed>()
.is_none()
{
return Ok(error);
}
if let Err(persist_error) = lease.heartbeat("selection_unconfirmed") {
return Err(error.context(format!(
"selection remains unconfirmed; could not persist lease phase: {persist_error}"
)));
}
Err(error.context(format!(
"selection remains unconfirmed for {}; lease retained for inspection or same-identity cancellation; command will not be replayed",
lease.wrapper_id()
)))
}
fn durable_lease_path(local_wrapper_id: &str) -> PathBuf {
default_job_lease_directory().join(format!("{local_wrapper_id}.json"))
}
fn current_process_start_ticks() -> Option<u64> {
let stat = std::fs::read_to_string("/proc/self/stat").ok()?;
let after_comm = stat.rsplit_once(") ")?.1;
after_comm.split_whitespace().nth(19)?.parse().ok()
}
fn current_boot_id() -> Option<String> {
std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn build_socket_failure_incident(
kind: SocketFailureKind,
mismatch: Option<&SocketPathMismatch>,
project: &str,
command_fingerprint: &str,
strict_remote: bool,
now_ms: u64,
) -> IncidentEvent {
let mut event = IncidentEvent::new(
IncidentEventType::Selection,
IncidentReasonCode::DaemonSocketRefused,
IncidentSource::Hook,
project,
command_fingerprint,
SelectedMode::Local,
!strict_remote,
now_ms,
)
.with_detail("socket_failure", kind.as_str())
.with_control(ControlState {
strict_remote_policy: strict_remote,
..ControlState::default()
});
if let Some(m) = mismatch {
event = event
.with_detail("socket_path_mismatch", "true")
.with_detail("configured_socket", redact_path(&m.configured))
.with_detail("canonical_socket", redact_path(&m.canonical))
.with_detail("canonical_socket_exists", m.canonical_exists.to_string());
}
event
}
fn build_recovery_terminal_incident(
strict_remote: bool,
project: &str,
command_fingerprint: &str,
detail_reason: &str,
now_ms: u64,
) -> IncidentEvent {
let (reason_code, event_type) = if strict_remote {
(IncidentReasonCode::ProofRefusal, IncidentEventType::Proof)
} else {
(
IncidentReasonCode::LocalFallback,
IncidentEventType::Fallback,
)
};
IncidentEvent::new(
event_type,
reason_code,
IncidentSource::Hook,
project,
command_fingerprint,
SelectedMode::Local,
!strict_remote,
now_ms,
)
.with_detail("reason", detail_reason.to_string())
.with_control(ControlState {
strict_remote_policy: strict_remote,
..ControlState::default()
})
}
fn build_local_fallback_incident(
command: &str,
reason: &str,
refused: bool,
now_ms: u64,
) -> IncidentEvent {
build_recovery_terminal_incident(
refused,
&extract_project_name(),
&redact_secrets(command),
reason,
now_ms,
)
}
fn record_hook_incident(event: &IncidentEvent) {
if event.reason_code == IncidentReasonCode::LocalFallback {
debug!(
target: "rch::hook::incident",
reason_code = %event.reason_code,
selected_mode = ?event.selected_mode,
"hook incident recorded",
);
} else {
warn!(
target: "rch::hook::incident",
reason_code = %event.reason_code,
failure_class = event.reason_code.failure_class(),
selected_mode = ?event.selected_mode,
local_fallback_allowed = event.local_fallback_allowed,
"hook incident recorded",
);
}
let ledger = IncidentLedger::new(IncidentLedgerConfig::default());
if let Err(e) = ledger.append(event) {
warn!(
target: "rch::hook::incident",
error = %e,
"failed to append incident to ledger (continuing)",
);
}
}
pub(crate) fn normalize_repository_relative_path(
label: &str,
path: &Path,
) -> anyhow::Result<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(part) => normalized.push(part),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
anyhow::bail!(
"{label} must be repository-relative without '..': {}",
path.display()
);
}
}
}
if normalized.as_os_str().is_empty() {
anyhow::bail!("{label} must not be empty or the repository root");
}
let normalized_label = normalized
.to_str()
.ok_or_else(|| anyhow::anyhow!("{label} must be valid UTF-8: {}", path.display()))?;
if !normalized_label.is_ascii() {
anyhow::bail!(
"{label} must contain only ASCII characters to avoid cross-platform filesystem aliases: {}",
path.display()
);
}
if normalized_label.contains('\\') || normalized_label.chars().any(char::is_control) {
anyhow::bail!(
"{label} may not contain backslashes or control characters: {}",
path.display()
);
}
Ok(normalized)
}
fn normalize_clean_overlay_path(path: &Path) -> anyhow::Result<PathBuf> {
normalize_repository_relative_path("clean-overlay path", path)
}
fn validate_job_result_dirs(dirs: Vec<PathBuf>) -> anyhow::Result<Vec<PathBuf>> {
let mut seen = std::collections::BTreeSet::new();
let mut validated = Vec::with_capacity(dirs.len());
for dir in dirs {
let normalized = normalize_repository_relative_path("--result-dir", &dir)?;
if seen.insert(normalized.clone()) {
validated.push(normalized);
}
}
Ok(validated)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CleanBaseTreeEntry {
mode: u32,
object_id: String,
path: PathBuf,
}
fn parse_clean_base_tree(output: &[u8]) -> anyhow::Result<Vec<CleanBaseTreeEntry>> {
let mut entries = Vec::new();
for record in output
.split(|byte| *byte == 0)
.filter(|record| !record.is_empty())
{
let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
anyhow::bail!("clean-overlay base tree contains a malformed ls-tree record");
};
let metadata = std::str::from_utf8(&record[..tab])
.context("clean-overlay base tree metadata is not UTF-8")?;
let mut fields = metadata.split_ascii_whitespace();
let mode = fields
.next()
.ok_or_else(|| anyhow::anyhow!("clean-overlay base tree record has no mode"))?;
let object_type = fields
.next()
.ok_or_else(|| anyhow::anyhow!("clean-overlay base tree record has no object type"))?;
let object_id = fields
.next()
.ok_or_else(|| anyhow::anyhow!("clean-overlay base tree record has no object ID"))?;
if fields.next().is_some() {
anyhow::bail!("clean-overlay base tree contains unexpected metadata fields");
}
if object_id.is_empty() || !object_id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
anyhow::bail!("clean-overlay base tree contains an invalid object ID");
}
let mode = u32::from_str_radix(mode, 8).context("parse clean-overlay Git tree mode")?;
if object_type != "blob" && !(mode == 0o160000 && object_type == "commit") {
anyhow::bail!("clean-overlay base tree contains unsupported object type {object_type}");
}
let path = std::str::from_utf8(&record[tab + 1..])
.context("clean-overlay base contains a non-UTF-8 Git path")?;
entries.push(CleanBaseTreeEntry {
mode,
object_id: object_id.to_owned(),
path: PathBuf::from(path),
});
}
Ok(entries)
}
fn clean_overlay_path_has_exact_spelling(
project_root: &Path,
relative: &Path,
) -> anyhow::Result<bool> {
let mut parent = project_root.to_path_buf();
for component in relative.components() {
let requested = component.as_os_str();
let mut found = false;
for entry in std::fs::read_dir(&parent)
.with_context(|| format!("read clean-overlay path parent {}", parent.display()))?
{
if entry?.file_name() == requested {
found = true;
break;
}
}
if !found {
return Ok(false);
}
parent.push(requested);
}
Ok(true)
}
fn validate_clean_overlay_live_path(project_root: &Path, relative: &Path) -> anyhow::Result<()> {
if !clean_overlay_path_has_exact_spelling(project_root, relative)? {
anyhow::bail!(
"clean-overlay path spelling does not exactly match the filesystem entry: {}",
relative.display()
);
}
let mut absolute = project_root.to_path_buf();
for component in relative.components() {
absolute.push(component.as_os_str());
let metadata = std::fs::symlink_metadata(&absolute).with_context(|| {
format!(
"clean-overlay path does not exist and deletions are unsupported: {}",
relative.display()
)
})?;
if metadata.file_type().is_symlink() {
anyhow::bail!(
"clean-overlay overlays do not support symlinks: {}",
relative.display()
);
}
}
Ok(())
}
fn validate_clean_overlay_against_base(
project_root: &Path,
overlay_paths: &[PathBuf],
base_entries: &[CleanBaseTreeEntry],
) -> anyhow::Result<()> {
for overlay in overlay_paths {
let overlay_label = overlay
.to_str()
.ok_or_else(|| anyhow::anyhow!("clean-overlay path must be valid UTF-8"))?;
let overlay_components = overlay_label.split('/').collect::<Vec<_>>();
for entry in base_entries {
let base_label = entry
.path
.to_str()
.ok_or_else(|| anyhow::anyhow!("clean-overlay base path must be valid UTF-8"))?;
let base_components = base_label.split('/').collect::<Vec<_>>();
for (base, selected) in base_components.iter().zip(&overlay_components) {
if base == selected {
continue;
}
if base.eq_ignore_ascii_case(selected) {
anyhow::bail!(
"clean-overlay path {} aliases differently spelled base path {}; case-only overlays are unsupported",
overlay.display(),
entry.path.display()
);
}
break;
}
}
let overlay_prefix = format!("{overlay_label}/");
let metadata = std::fs::symlink_metadata(project_root.join(overlay))
.with_context(|| format!("inspect clean-overlay path {}", overlay.display()))?;
let exact = base_entries
.iter()
.find(|entry| entry.path == overlay.as_path());
let descendants = base_entries
.iter()
.filter(|entry| {
entry
.path
.to_str()
.is_some_and(|path| path.starts_with(&overlay_prefix))
})
.collect::<Vec<_>>();
if metadata.is_dir() {
if exact.is_some() {
anyhow::bail!(
"clean-overlay path changes a base file into a directory, which is unsupported: {}",
overlay.display()
);
}
for entry in descendants {
if !clean_overlay_path_has_exact_spelling(project_root, &entry.path)? {
anyhow::bail!(
"clean-overlay directory omits or renames base path {}; selected deletions are unsupported",
entry.path.display()
);
}
let local = std::fs::symlink_metadata(project_root.join(&entry.path))
.with_context(|| {
format!("inspect base path under overlay {}", entry.path.display())
})?;
let base_is_symlink = entry.mode == 0o120000;
if base_is_symlink != local.file_type().is_symlink()
|| (!base_is_symlink && !local.is_file())
{
anyhow::bail!(
"clean-overlay directory changes the type of base path {}, which is unsupported",
entry.path.display()
);
}
}
} else {
if !descendants.is_empty() {
anyhow::bail!(
"clean-overlay path changes a base directory into a file, which is unsupported: {}",
overlay.display()
);
}
if let Some(entry) = exact {
let base_is_symlink = entry.mode == 0o120000;
if base_is_symlink != metadata.file_type().is_symlink()
|| (!base_is_symlink && !metadata.is_file())
{
anyhow::bail!(
"clean-overlay path changes the type of a base path, which is unsupported: {}",
overlay.display()
);
}
}
}
}
Ok(())
}
fn hash_clean_overlay_path(
project_root: &Path,
relative: &Path,
hasher: &mut blake3::Hasher,
) -> anyhow::Result<()> {
let relative_label = relative.to_str().ok_or_else(|| {
anyhow::anyhow!(
"clean-overlay directory contains a non-UTF-8 path: {}",
relative.display()
)
})?;
if !relative_label.is_ascii() {
anyhow::bail!(
"clean-overlay paths must contain only ASCII characters to avoid cross-platform filesystem aliases: {}",
relative.display()
);
}
if relative_label.contains('\\') || relative_label.chars().any(char::is_control) {
anyhow::bail!(
"clean-overlay paths may not contain backslashes or control characters: {}",
relative.display()
);
}
if relative.components().any(|component| {
component
.as_os_str()
.to_str()
.is_some_and(|name| name.eq_ignore_ascii_case(".git"))
}) {
anyhow::bail!(
"clean-overlay paths may not contain Git metadata: {}",
relative.display()
);
}
let absolute = project_root.join(relative);
let metadata = std::fs::symlink_metadata(&absolute)
.with_context(|| format!("inspect clean-overlay input {}", relative.display()))?;
hasher.update(&(relative_label.len() as u64).to_le_bytes());
hasher.update(relative_label.as_bytes());
hasher.update(&(metadata.permissions().mode() & 0o7777).to_le_bytes());
if metadata.file_type().is_symlink() {
anyhow::bail!(
"clean-overlay overlays do not support symlinks: {}",
relative.display()
);
} else if metadata.is_dir() {
hasher.update(b"directory\0");
let mut children = std::fs::read_dir(&absolute)
.with_context(|| format!("read clean-overlay directory {}", relative.display()))?
.map(|entry| entry.map(|entry| relative.join(entry.file_name())))
.collect::<Result<Vec<_>, _>>()?;
children.sort();
if children.is_empty() {
anyhow::bail!(
"clean-overlay overlays do not support empty directories: {}",
relative.display()
);
}
for child in children {
hash_clean_overlay_path(project_root, &child, hasher)?;
}
} else if metadata.is_file() {
hasher.update(b"file\0");
hasher.update(&metadata.len().to_le_bytes());
let mut file = std::fs::File::open(&absolute)
.with_context(|| format!("open clean-overlay input {}", relative.display()))?;
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.with_context(|| format!("read clean-overlay input {}", relative.display()))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
} else {
anyhow::bail!(
"clean-overlay path has unsupported file type: {}",
relative.display()
);
}
Ok(())
}
fn clean_overlay_fingerprint(
project_root: &Path,
overlay_paths: &[PathBuf],
) -> anyhow::Result<String> {
let mut hasher = blake3::Hasher::new();
hasher.update(b"rch-clean-overlay-input-v1\0");
for path in overlay_paths {
validate_clean_overlay_live_path(project_root, path)?;
hash_clean_overlay_path(project_root, path, &mut hasher)?;
validate_clean_overlay_live_path(project_root, path)?;
}
Ok(hasher.finalize().to_hex().to_string())
}
async fn git_output_bytes(project_root: &Path, args: &[&str]) -> anyhow::Result<Vec<u8>> {
let mut command = Command::new("git");
configure_clean_git_command(&mut command);
let output = command
.current_dir(project_root)
.args(args)
.output()
.await
.with_context(|| format!("failed to run git in {}", project_root.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git {} failed: {}", args.join(" "), stderr.trim());
}
Ok(output.stdout)
}
async fn git_output(project_root: &Path, args: &[&str]) -> anyhow::Result<String> {
Ok(
String::from_utf8(git_output_bytes(project_root, args).await?)
.context("git returned non-UTF-8 output")?
.trim()
.to_string(),
)
}
fn contains_git_archive_transform_attribute(contents: &str) -> bool {
contents.lines().any(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return false;
}
line.split_ascii_whitespace().skip(1).any(|token| {
let name = token
.trim_start_matches(['-', '!'])
.split_once('=')
.map_or(token.trim_start_matches(['-', '!']), |(name, _)| name);
matches!(name, "export-ignore" | "export-subst")
})
})
}
async fn validate_clean_overlay_archive_attributes(
project_root: &Path,
commit: &str,
) -> anyhow::Result<()> {
let paths = git_output_bytes(
project_root,
&["ls-tree", "-rz", "--full-tree", "--name-only", commit],
)
.await?;
for path in paths
.split(|byte| *byte == 0)
.filter(|path| !path.is_empty())
{
let path = std::str::from_utf8(path)
.context("clean-overlay base contains a non-UTF-8 Git path")?;
if path != ".gitattributes" && !path.ends_with("/.gitattributes") {
continue;
}
let contents = git_show_optional(project_root, commit, path)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"failed to read known .gitattributes path {path} from clean-overlay base {commit}"
)
})?;
if contains_git_archive_transform_attribute(&contents) {
anyhow::bail!(
"clean-overlay base uses export-ignore/export-subst in {path}; refusing a non-checkout-equivalent Git archive"
);
}
}
let info_attributes = git_output(
project_root,
&["rev-parse", "--git-path", "info/attributes"],
)
.await?;
let info_attributes = if Path::new(&info_attributes).is_absolute() {
PathBuf::from(info_attributes)
} else {
project_root.join(info_attributes)
};
match std::fs::read_to_string(&info_attributes) {
Ok(contents) if contains_git_archive_transform_attribute(&contents) => {
anyhow::bail!(
"clean-overlay repository info/attributes uses export-ignore/export-subst; refusing a non-checkout-equivalent Git archive"
);
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).with_context(|| {
format!(
"read clean-overlay repository attributes {}",
info_attributes.display()
)
});
}
}
Ok(())
}
async fn git_show_optional(
project_root: &Path,
commit: &str,
relative: &str,
) -> anyhow::Result<Option<String>> {
let object = format!("{commit}:{relative}");
let mut command = Command::new("git");
configure_clean_git_command(&mut command);
let output = command
.current_dir(project_root)
.args(["show", "--no-ext-diff", &object])
.output()
.await
.with_context(|| format!("read {relative} from clean-overlay base {commit}"))?;
if !output.status.success() {
return Ok(None);
}
Ok(Some(String::from_utf8(output.stdout).with_context(
|| format!("{relative} in clean-overlay base is not UTF-8"),
)?))
}
fn is_selected_cargo_metadata(path: &Path) -> bool {
path.file_name().is_some_and(|name| name == "Cargo.toml")
|| (path
.parent()
.and_then(Path::file_name)
.is_some_and(|name| name == ".cargo")
&& path
.file_name()
.is_some_and(|name| name == "config" || name == "config.toml"))
}
async fn selected_clean_overlay_cargo_tree(
project_root: &Path,
spec: &CleanOverlaySpec,
) -> anyhow::Result<
std::collections::BTreeMap<PathBuf, rch_common::cargo_path_deps::SelectedCargoEntry>,
> {
use rch_common::cargo_path_deps::{SelectedCargoEntry, validate_selected_cargo_path};
use std::collections::BTreeMap;
fn add_overlay(
root: &Path,
relative: &Path,
entries: &mut BTreeMap<PathBuf, SelectedCargoEntry>,
) -> anyhow::Result<()> {
let metadata = std::fs::symlink_metadata(root.join(relative))?;
if metadata.file_type().is_symlink() {
anyhow::bail!(
"clean-overlay selected path became a symlink: {}",
relative.display()
);
}
if metadata.is_dir() {
for child in std::fs::read_dir(root.join(relative))? {
add_overlay(root, &relative.join(child?.file_name()), entries)?;
}
} else if metadata.is_file() {
entries.insert(relative.to_path_buf(), SelectedCargoEntry::File(None));
} else {
anyhow::bail!(
"unsupported clean-overlay source entry: {}",
relative.display()
);
}
Ok(())
}
spec.verify_overlay_unchanged(project_root)?;
let tree = git_output_bytes(
project_root,
&["ls-tree", "-rz", "--full-tree", spec.base_commit()],
)
.await?;
let base = parse_clean_base_tree(&tree)?;
let mut entries = BTreeMap::new();
for entry in &base {
let selected = match entry.mode {
0o100644 | 0o100755 => SelectedCargoEntry::File(None),
0o120000 => SelectedCargoEntry::Symlink(
String::from_utf8(
git_output_bytes(project_root, &["cat-file", "blob", &entry.object_id]).await?,
)
.with_context(|| format!("non-UTF-8 selected symlink {}", entry.path.display()))?,
),
_ => anyhow::bail!("unsupported selected Git mode for {}", entry.path.display()),
};
entries.insert(entry.path.clone(), selected);
}
for overlay in spec.overlay_paths() {
add_overlay(project_root, overlay, &mut entries)?;
}
let mut metadata_paths: Vec<_> = entries
.keys()
.filter(|path| is_selected_cargo_metadata(path))
.cloned()
.collect();
for (path, entry) in &entries {
if path.file_name().is_some_and(|name| name == ".cargo")
&& matches!(entry, SelectedCargoEntry::Symlink(_))
{
for name in ["config", "config.toml"] {
let candidate = path.join(name);
if validate_selected_cargo_path(&entries, &candidate).is_ok() {
metadata_paths.push(candidate);
}
}
}
}
for path in metadata_paths {
let target = validate_selected_cargo_path(&entries, &path)?;
let bytes = if spec
.overlay_paths()
.iter()
.any(|overlay| target.starts_with(overlay))
{
std::fs::read(project_root.join(&target))
.with_context(|| format!("read selected Cargo metadata {}", target.display()))?
} else {
let entry = base
.iter()
.find(|entry| entry.path == target)
.ok_or_else(|| {
anyhow::anyhow!(
"selected Cargo metadata has no base object: {}",
target.display()
)
})?;
git_output_bytes(project_root, &["cat-file", "blob", &entry.object_id]).await?
};
entries.insert(
target.clone(),
SelectedCargoEntry::File(Some(String::from_utf8(bytes).with_context(|| {
format!("selected Cargo metadata is not UTF-8: {}", target.display())
})?)),
);
}
spec.verify_overlay_unchanged(project_root)?;
Ok(entries)
}
async fn validate_clean_overlay_cargo_sources(
project_root: &Path,
spec: &CleanOverlaySpec,
command: &str,
) -> anyhow::Result<()> {
use rch_common::cargo_path_deps::{
SelectedCargoEntry, validate_selected_cargo_config_at, validate_selected_cargo_path,
validate_selected_cargo_tree_at,
};
let primary_directory = spec.primary_directory.as_deref().unwrap_or(Path::new(""));
let mut entries = selected_clean_overlay_cargo_tree(project_root, spec)
.await?
.into_iter()
.map(|(path, entry)| (primary_directory.join(path), entry))
.collect::<std::collections::BTreeMap<_, _>>();
for (root, selected) in &spec.dependencies {
let directory = root
.file_name()
.ok_or_else(|| anyhow::anyhow!("missing dependency directory"))?;
for (path, entry) in selected_clean_overlay_cargo_tree(root, selected).await? {
anyhow::ensure!(
entries
.insert(Path::new(directory).join(path), entry)
.is_none(),
"overlapping selected dependency inventory"
);
}
}
let primary_manifest = primary_directory.join("Cargo.toml");
validate_selected_cargo_tree_at(&entries, &primary_manifest)?;
let (tokens, cargo_index) = cargo_target_dir::managed_clean_overlay_cargo_tokens(command)?;
for token in &tokens[..cargo_index] {
if token == "-C" || token == "--chdir" || token.starts_with("--chdir=") {
anyhow::bail!("clean-overlay Cargo source validation does not support wrapper chdir");
}
}
let mut index = cargo_index + 1;
while let Some(token) = tokens.get(index) {
if token == "--" {
break;
}
if token.starts_with("-C") {
anyhow::bail!("clean-overlay Cargo source validation does not support Cargo -C");
}
let value = if token == "--manifest-path" || token == "--config" {
index += 1;
Some(
tokens
.get(index)
.ok_or_else(|| anyhow::anyhow!("missing {token} value"))?
.as_str(),
)
} else {
token
.strip_prefix("--manifest-path=")
.or_else(|| token.strip_prefix("--config="))
};
if let Some(value) = value {
if token.starts_with("--manifest-path") {
anyhow::ensure!(
Path::new(value)
.file_name()
.is_some_and(|name| name == "Cargo.toml"),
"clean-overlay --manifest-path must select a Cargo.toml manifest"
);
anyhow::ensure!(
!Path::new(value).is_absolute(),
"absolute manifest path is not selected"
);
let path = validate_selected_cargo_path(&entries, &primary_directory.join(value))?;
anyhow::ensure!(
matches!(entries.get(&path), Some(SelectedCargoEntry::File(Some(_)))),
"Cargo manifest argument is not selected Cargo metadata: {}",
path.display()
);
} else {
let _: toml::Value = toml::from_str(value).context(
"clean-overlay supports inline TOML --config only; config files are not admitted",
)?;
validate_selected_cargo_config_at(
&entries,
&primary_manifest,
primary_directory,
value,
)?;
}
}
index += 1;
}
spec.verify_overlay_unchanged(project_root)?;
Ok(())
}
async fn detect_clean_overlay_toolchain(
project_root: &Path,
spec: &CleanOverlaySpec,
) -> anyhow::Result<Option<ToolchainInfo>> {
let toml_path = Path::new("rust-toolchain.toml");
let legacy_path = Path::new("rust-toolchain");
let toml_contents = if spec.overlay_paths().iter().any(|path| path == toml_path) {
Some(
std::fs::read_to_string(project_root.join(toml_path))
.context("read overlaid rust-toolchain.toml")?,
)
} else {
git_show_optional(project_root, spec.base_commit(), "rust-toolchain.toml").await?
};
if let Some(contents) = toml_contents {
let document = toml::from_str::<toml::Value>(&contents)
.context("parse rust-toolchain.toml from clean-overlay base")?;
let channel = document
.get("toolchain")
.and_then(|toolchain| toolchain.get("channel"))
.and_then(toml::Value::as_str)
.ok_or_else(|| {
anyhow::anyhow!(
"rust-toolchain.toml in clean-overlay base has no toolchain.channel"
)
})?;
return Ok(Some(parse_channel_string(channel)?));
}
let legacy_contents = if spec.overlay_paths().iter().any(|path| path == legacy_path) {
Some(
std::fs::read_to_string(project_root.join(legacy_path))
.context("read overlaid rust-toolchain")?,
)
} else {
git_show_optional(project_root, spec.base_commit(), "rust-toolchain").await?
};
if let Some(contents) = legacy_contents {
if let Ok(document) = toml::from_str::<toml::Value>(&contents)
&& let Some(channel) = document
.get("toolchain")
.and_then(|toolchain| toolchain.get("channel"))
.and_then(toml::Value::as_str)
{
return Ok(Some(parse_channel_string(channel)?));
}
let channel = contents.trim();
if channel.is_empty() {
anyhow::bail!("rust-toolchain in clean-overlay base is empty");
}
return Ok(Some(parse_channel_string(channel)?));
}
Ok(None)
}
async fn prepare_clean_overlay_spec(
project_root: &Path,
base: Option<String>,
clean_overlay: bool,
overlay_paths: Vec<PathBuf>,
no_overlay: bool,
) -> anyhow::Result<Option<CleanOverlaySpec>> {
if !clean_overlay {
if base.is_some() || !overlay_paths.is_empty() || no_overlay {
anyhow::bail!("--base, --overlay-path, and --no-overlay require --clean-overlay");
}
return Ok(None);
}
let base = base.ok_or_else(|| anyhow::anyhow!("--clean-overlay requires --base <COMMIT>"))?;
if base.trim().is_empty() || base.chars().any(char::is_control) {
anyhow::bail!("--base must be a non-empty Git revision without control characters");
}
if no_overlay == !overlay_paths.is_empty() {
anyhow::bail!(
"--clean-overlay requires exactly one of --no-overlay or at least one --overlay-path"
);
}
let git_toplevel = git_output(project_root, &["rev-parse", "--show-toplevel"]).await?;
let git_toplevel = std::fs::canonicalize(&git_toplevel)
.with_context(|| format!("canonicalize Git toplevel {git_toplevel}"))?;
let canonical_project_root = std::fs::canonicalize(project_root)
.with_context(|| format!("canonicalize project root {}", project_root.display()))?;
if git_toplevel != canonical_project_root {
anyhow::bail!(
"clean-overlay exec must run from the Git toplevel (current: {}, toplevel: {})",
canonical_project_root.display(),
git_toplevel.display()
);
}
let commit_expression = format!("{base}^{{commit}}");
let base_commit = git_output(
&canonical_project_root,
&[
"rev-parse",
"--verify",
"--end-of-options",
&commit_expression,
],
)
.await?;
if base_commit.is_empty() || !base_commit.chars().all(|ch| ch.is_ascii_hexdigit()) {
anyhow::bail!("--base did not resolve to a hexadecimal commit object ID");
}
validate_clean_overlay_archive_attributes(&canonical_project_root, &base_commit).await?;
let tree_object = git_output(
&canonical_project_root,
&["rev-parse", "--verify", &format!("{base_commit}^{{tree}}")],
)
.await?;
let base_tree = git_output_bytes(
&canonical_project_root,
&["ls-tree", "-rz", "--full-tree", &base_commit],
)
.await?;
let base_entries = parse_clean_base_tree(&base_tree)?;
if let Some(gitlink) = base_entries.iter().find(|entry| entry.mode == 0o160000) {
anyhow::bail!(
"clean-overlay does not support Git submodules yet (found {})",
gitlink.path.display()
);
}
let mut normalized_paths = BTreeSet::new();
for path in overlay_paths {
let normalized = normalize_clean_overlay_path(&path)?;
if normalized.components().any(|component| {
component
.as_os_str()
.to_str()
.is_some_and(|name| name.eq_ignore_ascii_case(".git"))
}) {
anyhow::bail!(
"clean-overlay paths may not contain Git metadata: {}",
normalized.display()
);
}
validate_clean_overlay_live_path(&canonical_project_root, &normalized)?;
normalized_paths.insert(normalized);
}
let overlay_paths = normalized_paths.into_iter().collect::<Vec<_>>();
validate_clean_overlay_against_base(&canonical_project_root, &overlay_paths, &base_entries)?;
let overlay_fingerprint = clean_overlay_fingerprint(&canonical_project_root, &overlay_paths)?;
Ok(Some(CleanOverlaySpec {
base_commit,
tree_object,
overlay_paths,
overlay_fingerprint,
dependencies: Vec::new(),
primary_directory: None,
}))
}
async fn bind_clean_overlay_dependencies(
primary: &Path,
spec: &mut CleanOverlaySpec,
bindings: &[String],
) -> anyhow::Result<()> {
if bindings.is_empty() {
return Ok(());
}
let primary = std::fs::canonicalize(primary)?;
let parent = primary
.parent()
.ok_or_else(|| anyhow::anyhow!("missing primary parent"))?;
let primary_name = normalize_clean_overlay_path(Path::new(
primary
.file_name()
.ok_or_else(|| anyhow::anyhow!("missing primary directory name"))?,
))?;
let mut roots = BTreeSet::new();
let mut names = BTreeSet::from([primary_name.to_string_lossy().to_ascii_lowercase()]);
for binding in bindings {
let (path, revision) = binding
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("--dependency-base requires PATH=REV"))?;
anyhow::ensure!(
!path.is_empty() && !revision.is_empty(),
"empty dependency path or revision"
);
let root = std::fs::canonicalize(primary.join(path))
.with_context(|| format!("resolve selected dependency root {path}"))?;
anyhow::ensure!(
root != primary && root.parent() == Some(parent),
"--dependency-base currently requires sibling Git roots: {}",
root.display()
);
let name = normalize_clean_overlay_path(Path::new(
root.file_name()
.ok_or_else(|| anyhow::anyhow!("missing dependency directory name"))?,
))?;
anyhow::ensure!(
names.insert(name.to_string_lossy().to_ascii_lowercase()),
"ambiguous dependency directory"
);
anyhow::ensure!(
roots.insert(root.clone()),
"duplicate dependency binding: {}",
root.display()
);
let selected = prepare_clean_overlay_spec(&root, Some(revision.into()), true, vec![], true)
.await?
.ok_or_else(|| anyhow::anyhow!("missing dependency selection"))?;
spec.dependencies.push((root, selected));
}
spec.dependencies.sort_by(|a, b| a.0.cmp(&b.0));
spec.primary_directory = Some(primary_name);
Ok(())
}
fn is_clean_overlay_cargo_fmt_check(command_parts: &[String]) -> bool {
let mut parts = command_parts.iter();
let Some(program) = parts.next() else {
return false;
};
if Path::new(program)
.file_name()
.and_then(|name| name.to_str())
!= Some("cargo")
{
return false;
}
let Some(mut subcommand) = parts.next() else {
return false;
};
if subcommand.starts_with('+') {
let Some(next) = parts.next() else {
return false;
};
subcommand = next;
}
subcommand == "fmt" && parts.any(|part| part == "--check")
}
#[allow(clippy::too_many_arguments)] pub async fn run_exec(
base: Option<String>,
dependency_bases: Vec<String>,
clean_overlay: bool,
overlay_paths: Vec<PathBuf>,
no_overlay: bool,
source_content_receipt: bool,
job: bool,
result_dirs: Vec<PathBuf>,
required_tools: Vec<String>,
command_parts: Vec<String>,
out_ctx: &crate::ui::context::OutputContext,
) -> anyhow::Result<()> {
let exec_start = std::time::Instant::now();
set_machine_output(out_ctx.is_json());
let command = join_exec_command(&command_parts);
if command.is_empty() {
anyhow::bail!("No command provided to exec");
}
if shell_wrapped_cargo_command(&command_parts) {
let reporter = HookReporter::new(OutputVisibility::Summary);
reporter.summary_critical(
"[RCH-E301] refusing shell-wrapped cargo command: RCH cannot safely synchronize its artifacts; invoke `rch exec -- cargo ...` directly",
);
emit_exec_envelope(&ExecResultEnvelope {
api_version: "1.0",
command: &command,
outcome: "refused",
location: "local",
fallback_reason: Some("shell-wrapped cargo command"),
worker_id: None,
remote_exit_code: None,
duration_ms: Some(exec_start.elapsed().as_millis() as u64),
timing: None,
result_dirs: None,
error_code: Some("RCH-E301"),
});
std::process::exit(EXIT_BUILD_ERROR);
}
let require_remote = exec_requires_remote() || clean_overlay || source_content_receipt;
let result_dirs = match validate_job_result_dirs(result_dirs) {
Ok(validated) => validated,
Err(e) => {
let reporter = HookReporter::new(OutputVisibility::Summary);
reporter.summary_critical(&format!("[RCH-E001] {e}"));
emit_exec_envelope(&ExecResultEnvelope {
api_version: "1.0",
command: &command,
outcome: "collection_error",
location: "local",
fallback_reason: None,
worker_id: None,
remote_exit_code: None,
duration_ms: Some(exec_start.elapsed().as_millis() as u64),
timing: None,
result_dirs: None,
error_code: Some("RCH-E001"),
});
std::process::exit(2);
}
};
let classification = if job {
Classification::compilation(
CompilationKind::Job,
1.0,
"explicit `rch exec --job` admission",
)
} else {
classify_command(&command)
};
let clean_overlay_fmt_check = clean_overlay && is_clean_overlay_cargo_fmt_check(&command_parts);
if !classification.is_compilation && !clean_overlay_fmt_check {
warn!("exec called with non-compilation command: {}", command);
let reporter = HookReporter::new(OutputVisibility::Summary);
exit_with_local_fallback(
&command,
&reporter,
"non-compilation command",
require_remote,
);
}
let config = match load_config() {
Ok(cfg) => cfg,
Err(e) => {
warn!("Failed to load config: {}, running locally", e);
let reporter = HookReporter::new(OutputVisibility::Summary);
exit_with_local_fallback(&command, &reporter, "config unavailable", require_remote);
}
};
let required_tools = {
let mut tools = required_tools;
if job {
for tool in &config.jobs.required_tools {
let tool = tool.trim();
if !tool.is_empty() && !tools.iter().any(|existing| existing == tool) {
tools.push(tool.to_string());
}
}
}
tools
};
if let Some(policy) = config_local_policy(&config, classification.kind) {
let reporter = HookReporter::new(config.output.visibility);
let reason = policy.reason();
let refuse_local = if policy.overrides_require_remote() {
clean_overlay || source_content_receipt
} else {
require_remote || role_requires_remote(config.general.role)
};
if policy.overrides_require_remote() && require_remote && !refuse_local {
warn!("RCH_REQUIRE_REMOTE is set but config says {reason}; running locally per config");
}
if !refuse_local {
reporter.summary(&format!("[RCH] local ({reason})"));
}
exit_with_local_fallback(&command, &reporter, &reason, refuse_local);
}
let require_remote = require_remote || role_requires_remote(config.general.role);
if role_requires_remote(config.general.role) {
info!("role=dispatcher: offloadable build defaults to fail-closed + queue");
}
let reporter = HookReporter::new(config.output.visibility);
let topology_policy = config.path_topology.to_policy();
if let Ok(cwd) = std::env::current_dir()
&& let Some(reason) = project_topology_local_reason(&topology_policy, &cwd)
{
let refuse_local = require_remote || clean_overlay || source_content_receipt;
if !refuse_local {
reporter.summary(&format!("[RCH] local ({reason})"));
}
exit_with_local_fallback(&command, &reporter, &reason, refuse_local);
}
let project = extract_project_name_with_policy(&topology_policy);
let layer0_env: Vec<(String, String)> = config
.layer0
.active_env()
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let estimated_cores =
estimate_cores_for_command(classification.kind, &command, &config.compilation);
let project_root = std::env::current_dir().ok();
let mut clean_overlay_spec = prepare_clean_overlay_spec(
project_root.as_deref().unwrap_or_else(|| Path::new(".")),
base,
clean_overlay,
overlay_paths,
no_overlay,
)
.await?;
if !dependency_bases.is_empty() {
anyhow::ensure!(
clean_overlay_fmt_check
|| classify_command(&command)
.kind
.is_some_and(|kind| kind.command_base() == "cargo"),
"--dependency-base requires a directly classified Cargo command"
);
let spec = clean_overlay_spec
.as_mut()
.ok_or_else(|| anyhow::anyhow!("--dependency-base requires --clean-overlay"))?;
bind_clean_overlay_dependencies(
project_root.as_deref().unwrap_or_else(|| Path::new(".")),
spec,
&dependency_bases,
)
.await?;
}
let selection_project = selection_project_for_execution(
&project,
clean_overlay_spec.as_ref(),
uuid::Uuid::new_v4(),
);
let job_needs_rust_toolchain = job
&& matches!(
required_runtime_for_kind(classify_command(&command).kind),
RequiredRuntime::Rust
);
let needs_rust_toolchain = clean_overlay_fmt_check
|| matches!(
required_runtime_for_kind(classification.kind),
RequiredRuntime::Rust
)
|| job_needs_rust_toolchain;
let toolchain = if needs_rust_toolchain {
if let (Some(root), Some(spec)) = (project_root.as_deref(), clean_overlay_spec.as_ref()) {
detect_clean_overlay_toolchain(root, spec).await?
} else if let Some(name) = rustup_toolchain_from_command_tokens(&command_parts) {
parse_channel_string(&name).ok()
} else {
project_root
.as_ref()
.and_then(|root| detect_toolchain(root).ok())
}
} else {
None
};
let forwarded_cargo_target_dir = resolve_forwarded_cargo_target_dir(
classification.kind,
project_root.as_deref().unwrap_or_else(|| Path::new(".")),
&reporter,
Some(&command_parts),
);
let remote_command = rewrite_cargo_target_dir_command_for_remote(
&command,
Some(&command_parts),
forwarded_cargo_target_dir.as_ref(),
&reporter,
);
let required_runtime = if clean_overlay_fmt_check {
RequiredRuntime::Rust
} else {
required_runtime_for_kind(classification.kind)
};
let command_priority = command_priority_from_env(&reporter);
let wait_for_worker = queue_when_busy_enabled();
let preferred_workers = command_parsing::preferred_workers();
let durable_lease = DurableLeaseWriter::create(
&command,
require_remote,
config.self_healing.hook_starts_daemon,
)?;
let wrapper_id = durable_lease.wrapper_id();
if restart_admission_is_closed(&config.general.socket_path)
.await
.unwrap_or(false)
{
durable_lease.heartbeat("restart_admission_blocked")?;
return Err(anyhow::anyhow!(
"remote build admission is paused while daemon restart remediation is active"
));
}
let response = match query_daemon(
&config.general.socket_path,
&selection_project,
estimated_cores,
&remote_command,
toolchain.as_ref(),
required_runtime,
command_priority,
0, Some(std::process::id()),
Some(&wrapper_id),
wait_for_worker,
&preferred_workers,
classification.kind == Some(CompilationKind::Job),
&required_tools,
)
.await
{
Ok(resp) => resp,
Err(e) => {
let e = selection_error_for_recovery(e, &durable_lease)?;
warn!("Failed to query daemon: {}, attempting recovery", e);
let socket_path = config.general.socket_path.clone();
let socket_exists = Path::new(&socket_path).exists();
let failure_kind = classify_socket_failure(&e, socket_exists);
let strict_remote = require_remote;
let canonical_socket = default_socket_path();
let canonical_exists = Path::new(&canonical_socket).exists();
let mismatch =
detect_socket_path_mismatch(&socket_path, &canonical_socket, canonical_exists);
let command_fingerprint = redact_secrets(&command);
record_hook_incident(&build_socket_failure_incident(
failure_kind,
mismatch.as_ref(),
&project,
&command_fingerprint,
strict_remote,
now_unix_ms(),
));
let retry =
if auto_start::try_auto_start_daemon(&config.self_healing, Path::new(&socket_path))
.await
.is_ok()
{
match query_daemon(
&socket_path,
&selection_project,
estimated_cores,
&remote_command,
toolchain.as_ref(),
required_runtime,
command_priority,
0,
Some(std::process::id()),
Some(&wrapper_id),
wait_for_worker,
&preferred_workers,
classification.kind == Some(CompilationKind::Job),
&required_tools,
)
.await
{
Ok(response) => Some(response),
Err(error) => {
let _ = selection_error_for_recovery(error, &durable_lease)?;
None
}
}
} else {
None
};
match decide_recovery_action(retry.is_some(), strict_remote) {
DaemonRecoveryAction::ProceedRemote => retry.unwrap_or_else(|| {
reporter.summary("[RCH] local (daemon unavailable)");
exit_with_local_fallback(
&command,
&reporter,
"daemon unavailable",
require_remote,
);
}),
DaemonRecoveryAction::LocalFallback => {
reporter.summary("[RCH] local (daemon unavailable)");
exit_with_local_fallback(
&command,
&reporter,
"daemon unavailable",
require_remote,
);
}
DaemonRecoveryAction::Refuse => {
exit_with_local_fallback(
&command,
&reporter,
"daemon unavailable",
require_remote,
);
}
}
}
};
let allow_local_fallback = config.compilation.allow_local_fallback;
let max_attempts = if source_content_receipt {
1
} else {
max_remote_attempts()
};
let mut tried_workers: Vec<WorkerId> = Vec::new();
let mut current_query_preferred = preferred_workers.clone();
let mut attempt: u32 = 1;
let mut response = response;
loop {
if selection_cancelled_before_start(&response) {
durable_lease.record_exit(130)?;
durable_lease.acknowledge_terminal()?;
reporter.summary("[RCH] cancelled before remote admission");
std::process::exit(130);
}
let Some(worker) = response.worker.clone() else {
let requested_refusal = requested_worker_refusal(&preferred_workers, &response);
if let Some(refusal) = requested_refusal.as_ref() {
record_hook_incident(&build_requested_worker_refusal_incident(
refusal.reason_code,
&preferred_workers,
&project,
&redact_secrets(&command),
require_remote,
now_unix_ms(),
));
}
let mut reason = requested_refusal
.map(|refusal| refusal.summary)
.unwrap_or_else(|| response.reason.to_string());
if job && !require_remote {
reason = format!("job admission exhausted: {reason}");
}
exit_with_gated_local_fallback(
&command,
&reporter,
&reason,
strict_remote_for_exhausted_admission(require_remote, job),
allow_local_fallback,
);
};
let Some(remote_build_id) = response.build_id else {
let release = release_worker(
&config.general.socket_path,
&worker.id,
estimated_cores,
None,
Some(EXIT_BUILD_ERROR),
None,
None,
None,
Some(&wrapper_id),
)
.await;
if let Err(error) = release {
warn!(
"Worker {} selected without build id and release was not acknowledged: {}",
worker.id, error
);
}
anyhow::bail!(
"daemon selected worker {} without a durable build id; refusing untrackable execution",
worker.id
);
};
if let Err(error) = durable_lease.admit(remote_build_id, &worker.id) {
let warning = rch_common::job_recovery::diagnose_stuck_wrapper(
&durable_lease.snapshot().identity,
&rch_common::job_recovery::WrapperState::waiting(
rch_common::job_identity::JobLifecycleState::Queued,
)
.reservation_failed(),
);
eprintln!("{}", serde_json::to_string(&warning)?);
release_worker(&config.general.socket_path, &worker.id, estimated_cores,
Some(remote_build_id), Some(EXIT_BUILD_ERROR), None, None, None, Some(&wrapper_id)).await
.context("reservation release failed after durable admission failure; command was not executed")?;
exit_with_gated_local_fallback(
&command,
&reporter,
&format!("durable reservation persistence failed: {error}"),
require_remote || job,
allow_local_fallback,
);
}
if !selected_worker_is_requested(&worker.id, ¤t_query_preferred) {
let requested = current_query_preferred
.iter()
.map(WorkerId::as_str)
.collect::<Vec<_>>()
.join(",");
let reason = format!(
"[RCH-I001] daemon selected unrequested worker '{}' for requested set [{}]; upgrade and restart rchd to the same version as rch, or request another worker",
worker.id, requested
);
let release_error = release_worker(
&config.general.socket_path,
&worker.id,
estimated_cores,
response.build_id,
Some(EXIT_BUILD_ERROR),
None,
None,
None,
Some(&wrapper_id),
)
.await
.err();
if let Some(error) = release_error.as_ref() {
warn!(
"Failed to release unrequested worker {} after selection refusal: {}",
worker.id, error
);
} else if let Err(error) = durable_lease.acknowledge_terminal() {
warn!(
"Failed to persist acknowledged durable lease terminal state: {}",
error
);
}
let reason = if let Some(error) = release_error {
format!(
"{reason}; reservation release was not acknowledged ({error}); restart rchd and verify capacity before retrying"
)
} else {
reason
};
reporter.summary(&format!("[RCH] local ({reason})"));
exit_with_local_fallback(&command, &reporter, &reason, require_remote);
}
info!(
"Selected worker: {} at {}@{} ({} slots remaining after reservation, speed {:.1}){}",
worker.id,
worker.user,
worker.host,
worker.slots_available,
worker.speed_score,
if attempt > 1 {
format!(" [remote retry {attempt}/{max_attempts}]")
} else {
String::new()
}
);
tried_workers.push(worker.id.clone());
let remote_start = Instant::now();
let result = execute_remote_compilation(
&worker,
&remote_command,
config.transfer.clone(),
config.environment.allowlist.clone(),
forwarded_cargo_target_dir.clone(),
&config.compilation,
toolchain.as_ref(),
classification.kind,
&reporter,
&config.general.socket_path,
config.output.color_mode,
Some(remote_build_id),
Some(&wrapper_id),
Some(&durable_lease),
&topology_policy,
clean_overlay_spec.as_ref(),
source_content_receipt,
&result_dirs,
&layer0_env,
config.remediation.pooled_target.reaper_pooled_idle_hours,
config.remediation.pooled_target.store_base.as_deref(),
)
.await;
let remote_elapsed = remote_start.elapsed();
let exec_timing = result.as_ref().ok().map(|r| r.timing.clone());
let exec_dir_stats = result.as_ref().ok().map(|r| r.result_dirs.clone());
if let Err(error) = durable_lease.heartbeat("finalize") {
warn!(
"Failed to persist durable lease finalize heartbeat: {}",
error
);
}
let retain_unconfirmed_ownership = result
.as_ref()
.err()
.is_some_and(is_remote_execution_unconfirmed);
let release_exit_code = result
.as_ref()
.map(|ok| ok.exit_code)
.unwrap_or(EXIT_BUILD_ERROR);
let release_timing = result.as_ref().ok().map(|ok| {
let mut timing = ok.timing.clone();
timing.total = Some(remote_elapsed);
timing
});
let release_acknowledged = if retain_unconfirmed_ownership {
warn!(
"Remote completion unconfirmed; retaining build {} ownership",
remote_build_id
);
false
} else {
match release_worker(
&config.general.socket_path,
&worker.id,
estimated_cores,
Some(remote_build_id),
Some(release_exit_code),
None,
None,
release_timing.as_ref(),
Some(&wrapper_id),
)
.await
{
Ok(()) => true,
Err(error) => {
warn!("Failed to release worker slots: {}", error);
false
}
}
};
if let Ok(result) = result.as_ref() {
durable_lease.record_exit(result.exit_code)?;
}
let fault: RetryableRemoteFault = match result {
Ok(result) => {
{
let command_for_corpus = command.to_string();
let cwd_for_corpus =
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let kind_for_corpus = classification.kind;
let exit_code = result.exit_code;
let duration_ms = result.duration_ms;
tokio::task::spawn_blocking(move || {
record_invocation(
&command_for_corpus,
&cwd_for_corpus,
kind_for_corpus,
exit_code,
duration_ms,
);
});
}
if result.exit_code == 0 {
reporter.summary(&format!(
"[RCH] remote {} ({})",
worker.id,
format_duration_ms(remote_elapsed)
));
if let Some(spec) = clean_overlay_spec.as_ref() {
reporter.summary_critical(&spec.execution_receipt());
}
let is_test = classification
.kind
.map(|kind| kind.is_test_command())
.unwrap_or(false);
if let Err(e) =
record_build(&config.general.socket_path, &worker.id, &project, is_test)
.await
{
warn!("Failed to record build: {}", e);
}
if release_acknowledged && let Err(error) = durable_lease.acknowledge_terminal()
{
warn!(
"Failed to persist acknowledged durable lease terminal state: {}",
error
);
}
emit_exec_envelope(&ExecResultEnvelope {
api_version: "1.0",
command: &remote_command,
outcome: "completed",
location: "remote",
fallback_reason: None,
worker_id: Some(worker.id.as_str()),
remote_exit_code: Some(result.exit_code),
duration_ms: Some(exec_start.elapsed().as_millis() as u64),
timing: Some(&result.timing),
result_dirs: Some(&result.result_dirs),
error_code: None,
});
std::process::exit(0);
} else if result.deadline_triggered {
reporter.summary(&format!(
"[RCH] remote {} exceeded its configured deadline (exit {}); not retrying",
worker.id, result.exit_code
));
emit_exec_envelope(&ExecResultEnvelope {
api_version: "1.0",
command: &remote_command,
outcome: "deadline_exceeded",
location: "remote",
fallback_reason: None,
worker_id: Some(worker.id.as_str()),
remote_exit_code: Some(result.exit_code),
duration_ms: Some(exec_start.elapsed().as_millis() as u64),
timing: exec_timing.as_ref(),
result_dirs: exec_dir_stats.as_deref(),
error_code: None,
});
if release_acknowledged && let Err(error) = durable_lease.acknowledge_terminal()
{
warn!("Failed to persist acknowledged deadline terminal state: {error}");
}
std::process::exit(result.exit_code);
} else if !job && is_toolchain_failure(&result.stderr, result.exit_code) {
warn!(
"Remote toolchain failure on {}; will retry on another worker if available",
worker.id
);
RetryableRemoteFault {
log_reason: "remote toolchain missing".to_string(),
failed_worker: worker.id.clone(),
on_exhaust: RemoteFaultExhaustAction::GatedLocalFallback {
reason: format!("toolchain missing on {}", worker.id),
},
}
} else if !job
&& let Some(env_failure) =
detect_worker_system_dependency_failure(&result.stderr, result.exit_code)
{
let error = ErrorCode::BuildEnvError;
warn!(
"Remote worker build-environment failure on {} [{}]: {}; will retry on another worker if available",
worker.id,
error.code_string(),
env_failure.log_detail()
);
RetryableRemoteFault {
log_reason: format!(
"worker build-environment failure ({})",
env_failure.summary()
),
failed_worker: worker.id.clone(),
on_exhaust: RemoteFaultExhaustAction::ExitWithEnvRemediation {
code: result.exit_code,
summary: format!(
"[RCH] remote {} failed [{}] {}",
worker.id,
error.code_string(),
env_failure.summary()
),
remediation: format!(
"[RCH] remediation [{}]: {}",
error.code_string(),
env_failure.remediation()
),
},
}
} else if let Some(signal) = is_signal_killed(result.exit_code)
&& is_cpu_capability_signal(signal)
{
warn!(
"Remote build killed by SIGILL (exit {}) on {} — worker CPU cannot execute build (likely missing ISA, e.g. AVX2); quarantining worker and retrying elsewhere",
result.exit_code, worker.id
);
if let Err(e) = disable_worker_for_fault(
&config.general.socket_path,
&worker.id,
"cpu-capability-fault (SIGILL) — likely missing ISA (e.g. AVX2)",
)
.await
{
warn!("Failed to quarantine SIGILL worker {}: {}", worker.id, e);
}
RetryableRemoteFault {
log_reason: format!(
"worker CPU cannot execute build (SIGILL, exit {}) — quarantined",
result.exit_code
),
failed_worker: worker.id.clone(),
on_exhaust: RemoteFaultExhaustAction::ExitWithCode {
code: result.exit_code,
summary: format!(
"[RCH] remote {} killed (SIGILL — CPU capability)",
worker.id
),
},
}
} else if let Some(signal) =
wrapped_cpu_capability_signal(result.exit_code, &result.stderr)
{
warn!(
"Remote build's build-script/proc-macro killed by {} on {} (cargo exit {}) — worker CPU cannot execute build (likely missing ISA, e.g. AVX2); quarantining worker and retrying elsewhere",
signal_name(signal),
worker.id,
result.exit_code
);
if let Err(e) = disable_worker_for_fault(
&config.general.socket_path,
&worker.id,
"cpu-capability-fault (build-script SIGILL) — likely missing ISA (e.g. AVX2)",
)
.await
{
warn!("Failed to quarantine SIGILL worker {}: {}", worker.id, e);
}
RetryableRemoteFault {
log_reason: format!(
"worker CPU cannot execute build ({} in build script, cargo exit {}) — quarantined",
signal_name(signal),
result.exit_code
),
failed_worker: worker.id.clone(),
on_exhaust: RemoteFaultExhaustAction::ExitWithCode {
code: result.exit_code,
summary: format!(
"[RCH] remote {} build script killed ({} — CPU capability)",
worker.id,
signal_name(signal)
),
},
}
} else if let Some(signal) = is_signal_killed(result.exit_code) {
warn!(
"Remote build killed by {} (exit {}) on {} — cause unconfirmed; inspect worker OOM and SSH/session logs; will retry on another worker if available",
signal_name(signal),
result.exit_code,
worker.id
);
RetryableRemoteFault {
log_reason: format!(
"killed by {} (exit {})",
signal_name(signal),
result.exit_code
),
failed_worker: worker.id.clone(),
on_exhaust: RemoteFaultExhaustAction::ExitWithCode {
code: result.exit_code,
summary: format!(
"[RCH] remote {} killed ({}) — cause unconfirmed; inspect worker OOM and SSH/session logs",
worker.id,
signal_name(signal)
),
},
}
} else {
emit_exec_envelope(&ExecResultEnvelope {
api_version: "1.0",
command: &remote_command,
outcome: "completed",
location: "remote",
fallback_reason: None,
worker_id: Some(worker.id.as_str()),
remote_exit_code: Some(result.exit_code),
duration_ms: Some(exec_start.elapsed().as_millis() as u64),
timing: exec_timing.as_ref(),
result_dirs: exec_dir_stats.as_deref(),
error_code: None,
});
reporter.summary(&format!(
"[RCH] remote {} failed (exit {})",
worker.id, result.exit_code
));
if release_acknowledged && let Err(error) = durable_lease.acknowledge_terminal()
{
warn!(
"Failed to persist acknowledged durable lease terminal state: {}",
error
);
}
std::process::exit(result.exit_code);
}
}
Err(e) => {
if classify_remote_pipeline_failure(&e)
== RemotePipelineFailurePolicy::AllowLocalFallback
&& let Some(preflight_err) = e.downcast_ref::<DependencyPreflightFailure>()
{
let evidence_summary = preflight_err.evidence_summary();
warn!(
"Dependency preflight blocked remote execution [{}]: {}; evidence='{}'",
preflight_err.reason_code, preflight_err.remediation, evidence_summary
);
reporter.summary(&format!(
"[RCH] local (dependency preflight {}: {}; evidence: {})",
preflight_err.reason_code, preflight_err.remediation, evidence_summary
));
reporter.verbose(&format!(
"[RCH] dependency preflight report: {}",
preflight_err.report_json()
));
let fallback_reason =
format!("dependency preflight failed: {evidence_summary}");
if release_acknowledged && let Err(error) = durable_lease.acknowledge_terminal()
{
warn!(
"Failed to persist acknowledged durable lease terminal state: {}",
error
);
}
exit_with_local_fallback(&command, &reporter, &fallback_reason, require_remote);
}
if classify_remote_pipeline_failure(&e)
== RemotePipelineFailurePolicy::AllowLocalFallback
&& let Some(skip_err) = e.downcast_ref::<TransferError>()
&& let TransferError::TransferSkipped { reason } = skip_err
{
reporter.summary(&format!("[RCH] local ({})", reason));
if release_acknowledged && let Err(error) = durable_lease.acknowledge_terminal()
{
warn!(
"Failed to persist acknowledged durable lease terminal state: {}",
error
);
}
exit_with_local_fallback(
&command,
&reporter,
"transfer skipped",
require_remote,
);
}
if classify_remote_pipeline_failure(&e)
== RemotePipelineFailurePolicy::FailClosedNoLocalFallback
{
warn!(
"Remote execution failed on {}; refusing local fallback: {}",
worker.id, e
);
quarantine_worker_on_unverified_timeout_cleanup(
&e,
&config.general.socket_path,
&worker.id,
&reporter,
)
.await;
reporter.summary_critical(&remote_pipeline_failure_summary(&worker.id, &e));
if release_acknowledged && let Err(error) = durable_lease.acknowledge_terminal()
{
warn!(
"Failed to persist acknowledged durable lease terminal state: {}",
error
);
}
emit_exec_envelope(&ExecResultEnvelope {
api_version: "1.0",
command: &remote_command,
outcome: "transport_error",
location: "remote",
fallback_reason: Some("fail_closed_no_local_fallback"),
worker_id: Some(worker.id.as_str()),
timing: exec_timing.as_ref(),
result_dirs: exec_dir_stats.as_deref(),
remote_exit_code: None,
duration_ms: Some(exec_start.elapsed().as_millis() as u64),
error_code: None,
});
std::process::exit(EXIT_BUILD_ERROR);
}
if let Some(stall) = crate::transfer::find_source_sync_stall(&e) {
warn!(
"Source sync stalled on {} (phase {}, no output for {}s); failing over to another worker",
worker.id,
stall.phase,
stall.silence.as_secs()
);
reporter.summary(&format!(
"[RCH] source sync stalled on {} (phase {}, no output for {}s); retrying on another worker",
worker.id,
stall.phase,
stall.silence.as_secs()
));
RetryableRemoteFault {
log_reason: format!(
"source sync stalled (phase {}, {}s silence)",
stall.phase,
stall.silence.as_secs()
),
failed_worker: worker.id.clone(),
on_exhaust: RemoteFaultExhaustAction::GatedLocalFallback {
reason: format!(
"source sync stalled on {} (phase {})",
worker.id, stall.phase
),
},
}
} else {
warn!(
"Remote execution failed on {}: {}; will retry on another worker if available",
worker.id, e
);
RetryableRemoteFault {
log_reason: "remote execution failed".to_string(),
failed_worker: worker.id.clone(),
on_exhaust: RemoteFaultExhaustAction::GatedLocalFallback {
reason: "remote execution failed".to_string(),
},
}
}
}
};
durable_lease
.ensure_released_for_retry()
.context(crate::transfer::RemoteExecutionUnconfirmed)?;
if attempt < max_attempts
&& let Some((next_response, next_worker)) = try_retry_on_bigger_worker(
&config.general.socket_path,
&selection_project,
estimated_cores,
&remote_command,
toolchain.as_ref(),
required_runtime,
command_priority,
&tried_workers,
&preferred_workers,
Some(&wrapper_id),
&reporter,
)
.await
{
attempt += 1;
warn!(
"Remote build {} on {}; retrying on higher-capacity worker {} (attempt {}/{})",
fault.log_reason, fault.failed_worker, next_worker, attempt, max_attempts
);
reporter.summary(&format!(
"[RCH] retry on bigger worker {} (attempt {}/{}) after {} on {}",
next_worker, attempt, max_attempts, fault.log_reason, fault.failed_worker
));
current_query_preferred = vec![next_worker];
response = next_response;
continue;
}
match fault.on_exhaust {
RemoteFaultExhaustAction::ExitWithCode { code, summary } => {
warn!(
"Remote build failed and no higher-capacity worker was available after {} attempt(s); surfacing exit {}",
tried_workers.len(),
code
);
reporter.summary_critical(&summary);
emit_exec_envelope(&ExecResultEnvelope {
api_version: "1.0",
command: &remote_command,
outcome: "completed",
location: "remote",
fallback_reason: None,
worker_id: Some(fault.failed_worker.as_str()),
remote_exit_code: Some(code),
duration_ms: Some(exec_start.elapsed().as_millis() as u64),
timing: exec_timing.as_ref(),
result_dirs: exec_dir_stats.as_deref(),
error_code: None,
});
if release_acknowledged && let Err(error) = durable_lease.acknowledge_terminal() {
warn!(
"Failed to persist acknowledged durable lease terminal state: {}",
error
);
}
std::process::exit(code);
}
RemoteFaultExhaustAction::ExitWithEnvRemediation {
code,
summary,
remediation,
} => {
warn!(
"Remote build failed (worker environment) and no other worker was available after {} attempt(s); surfacing exit {}",
tried_workers.len(),
code
);
reporter.summary_critical(&summary);
reporter.verbose(&remediation);
if release_acknowledged && let Err(error) = durable_lease.acknowledge_terminal() {
warn!(
"Failed to persist acknowledged durable lease terminal state: {}",
error
);
}
emit_exec_envelope(&ExecResultEnvelope {
api_version: "1.0",
command: &remote_command,
outcome: "completed",
location: "remote",
fallback_reason: Some("worker_env_remediation"),
worker_id: Some(fault.failed_worker.as_str()),
remote_exit_code: Some(code),
duration_ms: Some(exec_start.elapsed().as_millis() as u64),
timing: exec_timing.as_ref(),
result_dirs: exec_dir_stats.as_deref(),
error_code: None,
});
std::process::exit(code);
}
RemoteFaultExhaustAction::GatedLocalFallback { reason } => {
let strict_remote = strict_remote_for_exhausted_admission(require_remote, job);
warn!(
"Remote build failed on all {} tried worker(s); {}",
tried_workers.len(),
if allow_local_fallback && !strict_remote {
"falling back to local"
} else {
"refusing local fallback"
}
);
let mut reason = format!("{reason}; remote retries exhausted");
if job && !require_remote {
reason = format!("job admission exhausted: {reason}");
}
if release_acknowledged && let Err(error) = durable_lease.acknowledge_terminal() {
warn!(
"Failed to persist acknowledged durable lease terminal state: {}",
error
);
}
exit_with_gated_local_fallback(
&command,
&reporter,
&reason,
strict_remote,
allow_local_fallback,
);
}
}
}
}
#[derive(Clone, Copy)]
struct HookReporter {
visibility: OutputVisibility,
}
impl HookReporter {
fn new(visibility: OutputVisibility) -> Self {
Self { visibility }
}
fn summary(&self, message: &str) {
if self.visibility != OutputVisibility::None {
eprintln!("{}", message);
}
}
fn summary_critical(&self, message: &str) {
eprintln!("{}", message);
}
fn verbose(&self, message: &str) {
if self.visibility == OutputVisibility::Verbose {
eprintln!("{}", message);
}
}
}
mod auto_start;
mod progress_reporting;
mod transfer_orchestration;
use transfer_orchestration::execute_remote_compilation;
pub(crate) use transfer_orchestration::recovery::recover_job;
mod repo_updater;
pub(crate) mod ssh;
pub(crate) use ssh::{source_authority_activity_prefix, source_authority_cleanup_prefix};
pub(crate) async fn run_owned_worker_topology_command(
worker: &WorkerConfig,
canonical_root: &Path,
alias_root: &Path,
command: &str,
) -> anyhow::Result<Output> {
let mut roots = [canonical_root, alias_root]
.into_iter()
.map(|root| {
root.to_str()
.map(str::to_owned)
.context("worker topology root is not a UTF-8 path")
})
.collect::<anyhow::Result<Vec<_>>>()?;
roots.sort();
roots.dedup();
let identity = uuid::Uuid::new_v4().simple().to_string();
let activity_command = ssh::wrap_remote_source_activity(command, &identity)?;
let guard = match ssh::acquire_remote_source_authority_lock(
worker,
&roots,
None,
&identity,
Duration::from_secs(30),
)
.await
{
Ok(guard) => guard,
Err(acquisition_error) => {
let cancellation = async {
if ssh::cancel_remote_source_authority_intent(worker, &roots, &identity).await? {
ssh::finish_cancel_remote_source_authority_intent(worker, &roots, &identity)
.await?;
}
Ok::<(), anyhow::Error>(())
}
.await;
return match cancellation {
Ok(()) => Err(acquisition_error.context("worker topology setup was not started")),
Err(cancellation_error) => Err(acquisition_error.context(format!(
"worker topology intent {identity} on {} remains unresolved; source ownership is retained: {cancellation_error:#}",
worker.id,
))),
};
}
};
let result =
ssh::run_offload_ssh_command(worker, &activity_command, Duration::from_secs(30)).await;
guard.release().await.with_context(|| {
format!(
"worker topology intent {identity} on {} could not acknowledge release; inspect the retained source claim before retrying setup",
worker.id,
)
})?;
result
}
mod dependency_closure;
use dependency_closure::{
DEPENDENCY_PREFLIGHT_CODE_MATERIALIZATION, DEPENDENCY_PREFLIGHT_CODE_POLICY,
DEPENDENCY_PREFLIGHT_CODE_TIMEOUT, DEPENDENCY_PREFLIGHT_CODE_UNKNOWN,
DEPENDENCY_PREFLIGHT_REMEDIATION_MATERIALIZATION, DEPENDENCY_PREFLIGHT_REMEDIATION_POLICY,
DEPENDENCY_PREFLIGHT_REMEDIATION_TIMEOUT, DEPENDENCY_PREFLIGHT_REMEDIATION_UNKNOWN,
DEPENDENCY_PREFLIGHT_SCHEMA_VERSION, DependencyPreflightEvidence, DependencyPreflightFailure,
DependencyPreflightReport, DependencyPreflightStatus,
};
mod source_fidelity;
mod remote_result;
use remote_result::{
ExecResultDirStat, ExecResultEnvelope, detect_cargo_workspace_inheritance_failure,
detect_worker_system_dependency_failure, emit_exec_envelope, is_cpu_capability_signal,
is_signal_killed, is_toolchain_failure, set_machine_output, signal_name,
wrapped_cpu_capability_signal,
};
mod cargo_target_dir;
use cargo_target_dir::{
resolve_forwarded_cargo_target_dir, rewrite_cargo_target_dir_command_for_remote,
sanitize_cargo_home_token,
};
mod artifact_patterns;
mod artifact_triple;
mod selection_response;
use selection_response::parse_selection_response;
mod timing_history;
use timing_history::record_build_timing;
mod rabs_recorder;
use rabs_recorder::record_invocation;
mod daemon_ipc;
use daemon_ipc::{disable_worker_for_fault, queue_when_busy_enabled, record_build};
pub(crate) use daemon_ipc::{query_daemon, release_worker, restart_admission_is_closed};
mod command_parsing;
pub(crate) use command_parsing::{
cargo_job_count_for_command, estimate_cores_for_command, extract_project_name,
extract_project_name_with_policy, preferred_workers,
};
mod formatting;
use formatting::{estimate_local_time_ms, format_duration_ms};
mod fallback_selection;
use fallback_selection::{build_capacity_snapshots, pick_bigger_worker};
fn is_test_kind(kind: Option<CompilationKind>) -> bool {
matches!(
kind,
Some(CompilationKind::CargoTest | CompilationKind::CargoNextest | CompilationKind::BunTest)
)
}
#[allow(dead_code)]
fn emit_first_run_message(worker: &SelectedWorker, remote_ms: u64, local_ms: Option<u64>) {
let divider = "----------------------------------------";
let remote = format_duration_ms(Duration::from_millis(remote_ms));
eprintln!();
eprintln!("{}", divider);
eprintln!("First remote build complete!");
eprintln!();
if let Some(local_ms) = local_ms {
let local = format_duration_ms(Duration::from_millis(local_ms));
eprintln!(
"Your build ran on '{}' in {} (local estimate ~{}).",
worker.id, remote, local
);
} else {
eprintln!("Your build ran on '{}' in {}.", worker.id, remote);
}
eprintln!("RCH will run silently in the background from now on.");
eprintln!();
eprintln!("To see build activity: rch status --jobs");
eprintln!("To disable this message: rch config set first_run_complete true");
eprintln!("{}", divider);
eprintln!();
}
async fn process_hook(input: HookInput) -> HookOutput {
if input.tool_name != "Bash" {
debug!("Non-Bash tool: {}, allowing", input.tool_name);
return HookOutput::allow();
}
let command = &input.tool_input.command;
debug!("Processing command: {}", mask_sensitive_command(command));
let classify_start = Instant::now();
let classification = crate::cache::classify_hook_command(command, classify_command);
let classification_duration = classify_start.elapsed();
let classification_duration_us = classification_duration.as_micros() as u64;
if !classification.is_compilation {
let duration_ms = classification_duration_us as f64 / 1000.0;
if duration_ms > 1.0 {
warn!(
"Non-compilation decision exceeded 1ms budget: {:.3}ms for '{}'",
duration_ms, command
);
} else {
debug!(
"Non-compilation decision: {:.3}ms for '{}' ({})",
duration_ms, command, classification.reason
);
}
if let Some(structure_reason) = declined_compilation_due_to_structure(command) {
let config = load_config().ok();
let force_remote = config
.as_ref()
.map(|cfg| cfg.general.force_remote)
.unwrap_or(false);
let visibility = config
.as_ref()
.map(|cfg| cfg.output.visibility)
.unwrap_or(OutputVisibility::Summary);
HookReporter::new(visibility).summary(&format!("[RCH] local ({structure_reason})"));
if force_remote {
warn!(
"⚠️ RCH: declined to offload compilation command due to shell structure \
({structure_reason}) while force_remote=true — running LOCALLY: '{command}'. \
Run it as a bare command (no unsupported pipe/subshell) so it can be offloaded."
);
} else {
debug!(
"RCH: declined to offload compilation command due to shell structure \
({structure_reason}): '{command}'"
);
}
}
return HookOutput::allow();
}
let config = match load_config() {
Ok(cfg) => cfg,
Err(e) => {
warn!("Failed to load config: {}, allowing local execution", e);
return HookOutput::allow();
}
};
let reporter = HookReporter::new(config.output.visibility);
if !config.general.enabled {
debug!("RCH disabled via config, allowing local execution");
return HookOutput::allow();
}
if config.general.force_local && config.general.force_remote {
warn!(
"Invalid config: both general.force_local and general.force_remote are set; allowing local execution"
);
reporter.summary("[RCH] local (invalid config: force_local+force_remote)");
return HookOutput::allow();
}
if config.general.force_local {
debug!("RCH force_local enabled, allowing local execution");
reporter.summary("[RCH] local (force_local)");
return HookOutput::allow();
}
let duration_ms = classification_duration_us as f64 / 1000.0;
if duration_ms > 5.0 {
warn!(
"Compilation decision exceeded 5ms budget: {:.3}ms",
duration_ms
);
}
info!(
"Compilation detected: {:?} (confidence: {:.2}, classified in {:.3}ms)",
classification.kind, classification.confidence, duration_ms
);
reporter.verbose(&format!(
"[RCH] compile {:?} (confidence {:.2})",
classification.kind, classification.confidence
));
let confidence_threshold = if config.general.force_remote {
reporter.verbose("[RCH] force_remote enabled: bypassing confidence threshold");
0.0
} else {
config.compilation.confidence_threshold
};
if classification.confidence < confidence_threshold {
debug!(
"Confidence {:.2} below threshold {:.2}, allowing local execution",
classification.confidence, confidence_threshold
);
reporter.summary("[RCH] local (confidence below threshold)");
return HookOutput::allow();
}
if let Some(kind) = classification.kind {
let command_base = kind.command_base();
if !config.execution.is_allowed(command_base) {
debug!(
"Command base '{}' not in execution allowlist, allowing local execution",
command_base
);
reporter.summary(&format!(
"[RCH] local (command '{}' not in execution.allowlist)",
command_base
));
return HookOutput::allow();
}
}
info!(
"Delegating compilation to rch exec (classification: {:?}, compound: {})",
classification.kind,
classification.command_prefix.is_some()
);
reporter.verbose("[RCH] delegating to rch exec...");
let modified_command = if let (Some(prefix), Some(extracted)) = (
&classification.command_prefix,
&classification.extracted_command,
) {
for segment in rch_common::compound_local_compilation_segments(command) {
reporter.summary(&format!(
"[RCH] local (compound segment not offloadable: '{segment}')"
));
}
format!("{}rch exec -- {}", prefix, extracted)
} else {
format!("rch exec -- {}", command)
};
HookOutput::allow_with_modified_command(modified_command)
}
fn selection_cancelled_before_start(response: &SelectionResponse) -> bool {
response.worker.is_none()
&& response.build_id.is_none()
&& matches!(&response.reason, SelectionReason::SelectionError(reason) if reason == "job_cancelled_before_start")
}
#[allow(dead_code)]
#[allow(clippy::too_many_arguments)] async fn handle_selection_response(
response: SelectionResponse,
command: &str,
config: &rch_common::RchConfig,
reporter: &HookReporter,
toolchain: Option<&ToolchainInfo>,
classification_kind: Option<CompilationKind>,
project: &str,
estimated_cores: u32,
) -> HookOutput {
if selection_cancelled_before_start(&response) {
reporter.summary("[RCH] cancelled before remote admission");
return HookOutput::allow_with_modified_command("exit 130".to_owned());
}
let Some(worker) = response.worker else {
warn!(
"⚠️ RCH: No remote workers available ({}), executing locally",
response.reason
);
reporter.summary(&format!("[RCH] local ({})", response.reason));
return HookOutput::allow();
};
info!(
"Selected worker: {} at {}@{} ({} slots remaining after reservation, speed {:.1})",
worker.id, worker.user, worker.host, worker.slots_available, worker.speed_score
);
reporter.verbose(&format!(
"[RCH] selected {}@{} ({} slots remaining after reservation, speed {:.1})",
worker.user, worker.host, worker.slots_available, worker.speed_score
));
let invocation_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let command_tokens = parse_command_tokens(command, reporter);
let forwarded_cargo_target_dir = resolve_forwarded_cargo_target_dir(
classification_kind,
&invocation_cwd,
reporter,
command_tokens.as_deref(),
);
let remote_command = rewrite_cargo_target_dir_command_for_remote(
command,
command_tokens.as_deref(),
forwarded_cargo_target_dir.as_ref(),
reporter,
);
let topology_policy = config.path_topology.to_policy();
let remote_start = Instant::now();
let result = execute_remote_compilation(
&worker,
&remote_command,
config.transfer.clone(),
config.environment.allowlist.clone(),
forwarded_cargo_target_dir,
&config.compilation,
toolchain,
classification_kind,
reporter,
&config.general.socket_path,
config.output.color_mode,
response.build_id,
None,
None,
&topology_policy,
None,
false,
&[],
&[],
config.remediation.pooled_target.reaper_pooled_idle_hours,
config.remediation.pooled_target.store_base.as_deref(),
)
.await;
let remote_elapsed = remote_start.elapsed();
let retain_unconfirmed_ownership = result
.as_ref()
.err()
.is_some_and(is_remote_execution_unconfirmed);
let release_exit_code = result
.as_ref()
.map(|ok| ok.exit_code)
.unwrap_or(EXIT_BUILD_ERROR);
let release_timing = result.as_ref().ok().map(|ok| {
let mut timing = ok.timing.clone();
timing.total = Some(remote_elapsed);
timing
});
if retain_unconfirmed_ownership {
warn!(
"Remote completion unconfirmed; retaining worker {} ownership",
worker.id
);
} else if let Err(e) = release_worker(
&config.general.socket_path,
&worker.id,
estimated_cores,
response.build_id,
Some(release_exit_code),
None,
None,
release_timing.as_ref(),
None,
)
.await
{
warn!("Failed to release worker slots: {}", e);
}
match result {
Ok(result) => {
{
let command_for_corpus = command.to_string();
let cwd_for_corpus = invocation_cwd.clone();
let exit_code = result.exit_code;
let duration_ms = result.duration_ms;
tokio::task::spawn_blocking(move || {
record_invocation(
&command_for_corpus,
&cwd_for_corpus,
classification_kind,
exit_code,
duration_ms,
);
});
}
if result.exit_code == 0 {
info!("Remote compilation succeeded, replacing with no-op for transparency");
reporter.summary(&format!(
"[RCH] remote {} ({})",
worker.id,
format_duration_ms(remote_elapsed)
));
let is_test = classification_kind
.map(|kind| kind.is_test_command())
.unwrap_or(false);
if let Err(e) =
record_build(&config.general.socket_path, &worker.id, project, is_test).await
{
warn!("Failed to record build: {}", e);
}
let project_for_timing = project.to_string();
let duration = result.duration_ms;
tokio::task::spawn_blocking(move || {
record_build_timing(&project_for_timing, classification_kind, duration, true);
});
if !config.output.first_run_complete {
let local_estimate =
estimate_local_time_ms(result.duration_ms, worker.speed_score);
emit_first_run_message(&worker, result.duration_ms, local_estimate);
if let Err(e) = crate::config::set_first_run_complete(true) {
warn!("Failed to persist first_run_complete: {}", e);
}
}
HookOutput::allow_with_modified_command("true")
} else if result.deadline_triggered {
reporter.summary(&format!(
"[RCH] remote {} exceeded its configured deadline (exit {}); not retrying",
worker.id, result.exit_code
));
HookOutput::deny(format!(
"Remote compilation exceeded its configured deadline (exit {}). Do not rerun locally; adjust the deadline before retrying.",
result.exit_code
))
} else if is_toolchain_failure(&result.stderr, result.exit_code) {
warn!(
"Remote toolchain failure detected (exit {}), falling back to local",
result.exit_code
);
reporter.summary(&format!("[RCH] local (toolchain missing on {})", worker.id));
HookOutput::allow()
} else {
let exit_code = result.exit_code;
if let Some(workspace_failure) =
detect_cargo_workspace_inheritance_failure(&result.stderr, exit_code)
{
let error = ErrorCode::BuildCargoWorkspaceInheritance;
warn!(
"Remote Cargo workspace-inheritance failure on {} [{}]: {}",
worker.id,
error.code_string(),
workspace_failure.log_detail()
);
reporter.summary(&format!(
"[RCH] remote {} failed [{}] {}",
worker.id,
error.code_string(),
workspace_failure.summary()
));
reporter.verbose(&format!(
"[RCH] remediation [{}]: {}",
error.code_string(),
workspace_failure.remediation()
));
} else if let Some(signal) = is_signal_killed(exit_code) {
if is_cpu_capability_signal(signal) {
warn!(
"Remote command killed by SIGILL on {} — worker CPU cannot execute build (likely missing ISA, e.g. AVX2); quarantining worker",
worker.id
);
if let Err(e) = disable_worker_for_fault(
&config.general.socket_path,
&worker.id,
"cpu-capability-fault (SIGILL) — likely missing ISA (e.g. AVX2)",
)
.await
{
warn!("Failed to quarantine SIGILL worker {}: {}", worker.id, e);
}
} else {
warn!(
"Remote command killed by signal {} ({}) on {}, replacing with exit code for transparency",
signal,
signal_name(signal),
worker.id
);
}
reporter.summary(&format!(
"[RCH] remote {} killed ({})",
worker.id,
signal_name(signal)
));
} else if let Some(signal) =
wrapped_cpu_capability_signal(exit_code, &result.stderr)
{
warn!(
"Remote command's build-script/proc-macro killed by {} on {} (cargo exit {}) — worker CPU cannot execute build (likely missing ISA, e.g. AVX2); quarantining worker",
signal_name(signal),
worker.id,
exit_code
);
if let Err(e) = disable_worker_for_fault(
&config.general.socket_path,
&worker.id,
"cpu-capability-fault (build-script SIGILL) — likely missing ISA (e.g. AVX2)",
)
.await
{
warn!("Failed to quarantine SIGILL worker {}: {}", worker.id, e);
}
reporter.summary(&format!(
"[RCH] remote {} build script killed ({} — CPU capability)",
worker.id,
signal_name(signal)
));
} else if exit_code == EXIT_TEST_FAILURES {
info!(
"Remote tests failed (exit 101) on {}, replacing with exit code for transparency",
worker.id
);
reporter.summary(&format!("[RCH] remote {} tests failed", worker.id));
} else if exit_code == EXIT_BUILD_ERROR {
info!(
"Remote build error (exit 1) on {}, replacing with exit code for transparency",
worker.id
);
reporter.summary(&format!("[RCH] remote {} build error", worker.id));
} else {
info!(
"Remote command failed (exit {}) on {}, replacing with exit code for transparency",
exit_code, worker.id
);
reporter.summary(&format!(
"[RCH] remote {} failed (exit {})",
worker.id, exit_code
));
}
let project_for_timing = project.to_string();
let duration = result.duration_ms;
tokio::task::spawn_blocking(move || {
record_build_timing(&project_for_timing, classification_kind, duration, true);
});
HookOutput::allow_with_modified_command(format!("exit {}", exit_code))
}
}
Err(e) => {
if let Some(preflight_err) = e.downcast_ref::<DependencyPreflightFailure>() {
let evidence_summary = preflight_err.evidence_summary();
info!(
"Dependency preflight blocked remote execution [{}], falling back to local; evidence='{}'",
preflight_err.reason_code, evidence_summary
);
reporter.summary(&format!(
"[RCH] local (dependency preflight {}: {}; evidence: {})",
preflight_err.reason_code, preflight_err.remediation, evidence_summary
));
reporter.verbose(&format!(
"[RCH] dependency preflight report: {}",
preflight_err.report_json()
));
return HookOutput::allow();
}
if let Some(skip_err) = e.downcast_ref::<TransferError>()
&& let TransferError::TransferSkipped { reason } = skip_err
{
info!(
"Transfer skipped ({}), falling back to local execution",
reason
);
reporter.summary(&format!("[RCH] local ({})", reason));
return HookOutput::allow();
}
if classify_remote_pipeline_failure(&e)
== RemotePipelineFailurePolicy::FailClosedNoLocalFallback
{
warn!(
"Remote execution pipeline failed on {}; refusing local fallback: {}",
worker.id, e
);
quarantine_worker_on_unverified_timeout_cleanup(
&e,
&config.general.socket_path,
&worker.id,
reporter,
)
.await;
reporter.summary_critical(&remote_pipeline_failure_summary(&worker.id, &e));
return HookOutput::allow_with_modified_command(format!(
"exit {}",
EXIT_BUILD_ERROR
));
}
warn!(
"Remote execution pipeline failed: {}, falling back to local",
e
);
if let Some(stall) = crate::transfer::find_source_sync_stall(&e) {
reporter.summary(&format!(
"[RCH] local (source sync stalled on {} — phase {}, no output for {}s)",
worker.id,
stall.phase,
stall.silence.as_secs()
));
} else {
reporter.summary("[RCH] local (remote pipeline failed)");
}
HookOutput::allow()
}
}
}
fn command_priority_from_env(reporter: &HookReporter) -> CommandPriority {
let Ok(raw) = std::env::var("RCH_PRIORITY") else {
return CommandPriority::Normal;
};
match raw.parse::<CommandPriority>() {
Ok(value) => value,
Err(()) => {
reporter.verbose(&format!(
"[RCH] ignoring invalid RCH_PRIORITY={:?} (expected: low|normal|high)",
raw
));
CommandPriority::Normal
}
}
}
fn selected_worker_to_config(worker: &SelectedWorker) -> WorkerConfig {
let tags = worker
.declared_os
.as_deref()
.map(|os| vec![rch_common::os_tag(os)])
.unwrap_or_default();
WorkerConfig {
id: worker.id.clone(),
host: worker.host.clone(),
user: worker.user.clone(),
identity_file: worker.identity_file.clone(),
total_slots: worker.slots_available,
priority: 100,
tags,
tools: Vec::new(),
}
}
#[derive(Debug, Clone)]
struct DependencyRuntimePlan {
sync_roots: Vec<PathBuf>,
fail_open_decision: Option<DependencyRuntimeFailOpenDecision>,
}
#[derive(Debug, Clone)]
struct DependencyRuntimeFailOpenDecision {
reason_code: &'static str,
remediation: &'static str,
detail: String,
}
fn text_indicates_timeout(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.contains("timeout") || lower.contains("timed out")
}
fn classify_dependency_runtime_fail_open(
plan: &DependencyClosurePlan,
) -> DependencyRuntimeFailOpenDecision {
let has_policy_violation = plan
.issues
.iter()
.any(|issue| issue.code == "path-policy-violation");
let has_materialization_failure = plan
.issues
.iter()
.any(|issue| issue.code == "materialization-closure-unavailable");
let has_timeout = plan
.fail_open_reason
.as_deref()
.is_some_and(text_indicates_timeout)
|| plan.issues.iter().any(|issue| {
text_indicates_timeout(&issue.message)
|| issue
.diagnostics
.iter()
.any(|diag| text_indicates_timeout(diag))
});
let (reason_code, remediation) = if has_policy_violation {
(
DEPENDENCY_PREFLIGHT_CODE_POLICY,
DEPENDENCY_PREFLIGHT_REMEDIATION_POLICY,
)
} else if has_materialization_failure {
(
DEPENDENCY_PREFLIGHT_CODE_MATERIALIZATION,
DEPENDENCY_PREFLIGHT_REMEDIATION_MATERIALIZATION,
)
} else if has_timeout {
(
DEPENDENCY_PREFLIGHT_CODE_TIMEOUT,
DEPENDENCY_PREFLIGHT_REMEDIATION_TIMEOUT,
)
} else {
(
DEPENDENCY_PREFLIGHT_CODE_UNKNOWN,
DEPENDENCY_PREFLIGHT_REMEDIATION_UNKNOWN,
)
};
let issue_codes = if plan.issues.is_empty() {
"none".to_string()
} else {
plan.issues
.iter()
.map(|issue| issue.code.clone())
.collect::<Vec<_>>()
.join(",")
};
let fail_open_reason = plan
.fail_open_reason
.as_deref()
.unwrap_or("no planner fail-open reason supplied");
let detail = format!("planner fail-open reason={fail_open_reason}; issue_codes={issue_codes}");
DependencyRuntimeFailOpenDecision {
reason_code,
remediation,
detail,
}
}
fn build_dependency_runtime_fail_open_report(
worker: &WorkerConfig,
normalized_project_root: &Path,
decision: &DependencyRuntimeFailOpenDecision,
) -> DependencyPreflightReport {
let status = if decision.reason_code == DEPENDENCY_PREFLIGHT_CODE_POLICY {
DependencyPreflightStatus::PolicyViolation
} else if decision.reason_code == DEPENDENCY_PREFLIGHT_CODE_MATERIALIZATION {
DependencyPreflightStatus::MaterializationUnavailable
} else if decision.reason_code == DEPENDENCY_PREFLIGHT_CODE_TIMEOUT {
DependencyPreflightStatus::Timeout
} else {
DependencyPreflightStatus::Unknown
};
DependencyPreflightReport {
schema_version: DEPENDENCY_PREFLIGHT_SCHEMA_VERSION,
worker: worker.id.as_str().to_string(),
verified: false,
reason_code: Some(decision.reason_code),
remediation: Some(decision.remediation),
evidence: vec![DependencyPreflightEvidence {
root: normalized_project_root.to_string_lossy().to_string(),
manifest: normalized_project_root
.join("Cargo.toml")
.to_string_lossy()
.to_string(),
required_path: normalized_project_root
.join("Cargo.toml")
.to_string_lossy()
.to_string(),
required_kind: "manifest",
status,
reason_code: decision.reason_code,
detail: decision.detail.clone(),
is_primary: true,
}],
}
}
fn should_force_local_fallback_for_runtime_fail_open(reason_code: &str) -> bool {
reason_code == DEPENDENCY_PREFLIGHT_CODE_POLICY
|| reason_code == DEPENDENCY_PREFLIGHT_CODE_MATERIALIZATION
}
fn command_uses_cargo_dependency_graph(kind: Option<CompilationKind>) -> bool {
matches!(
kind,
Some(
CompilationKind::CargoBuild
| CompilationKind::CargoCheck
| CompilationKind::CargoClippy
| CompilationKind::CargoDoc
| CompilationKind::CargoTest
| CompilationKind::CargoNextest
| CompilationKind::CargoBench
| CompilationKind::CargoZigbuild
)
)
}
pub(crate) fn project_topology_local_reason(
policy: &PathTopologyPolicy,
cwd: &Path,
) -> Option<String> {
normalize_project_path_with_policy(cwd, policy)
.err()
.map(|error| {
format!(
"project {} is outside canonical root {} ({error}); set [path_topology] \
canonical_root or RCH_CANONICAL_PROJECT_ROOT to offload it",
cwd.display(),
policy.canonical_root().display()
)
})
}
fn normalize_dependency_root_for_runtime(
root: &Path,
policy: &PathTopologyPolicy,
) -> Option<PathBuf> {
normalize_project_path_with_policy(root, policy)
.ok()
.map(|normalized| normalized.canonical_path().to_path_buf())
}
fn build_dependency_runtime_plan(
normalized_project_root: &Path,
kind: Option<CompilationKind>,
reporter: &HookReporter,
topology_policy: &PathTopologyPolicy,
) -> DependencyRuntimePlan {
if !command_uses_cargo_dependency_graph(kind) {
return DependencyRuntimePlan {
sync_roots: vec![normalized_project_root.to_path_buf()],
fail_open_decision: None,
};
}
let plan = build_dependency_closure_plan_with_policy(normalized_project_root, topology_policy);
if !plan.is_ready() {
if let Some(reason) = &plan.fail_open_reason {
reporter.verbose(&format!(
"[RCH] dependency closure planner fail-open: {}",
reason
));
}
for issue in &plan.issues {
reporter.verbose(&format!(
"[RCH] dependency closure issue {} ({:?}): {}",
issue.code, issue.risk, issue.message
));
}
let decision = classify_dependency_runtime_fail_open(&plan);
reporter.verbose(&format!(
"[RCH] dependency planner fail-open decision [{}]: {}",
decision.reason_code, decision.remediation
));
return DependencyRuntimePlan {
sync_roots: vec![normalized_project_root.to_path_buf()],
fail_open_decision: Some(decision),
};
}
let mut seen = std::collections::BTreeSet::<PathBuf>::new();
let mut ordered = Vec::<PathBuf>::new();
for action in &plan.sync_order {
if let Some(root) =
normalize_dependency_root_for_runtime(&action.package_root, topology_policy)
&& seen.insert(root.clone())
{
reporter.verbose(&format!(
"[RCH] dependency root {} ({:?})",
root.display(),
action.metadata.reason
));
ordered.push(root);
}
}
if ordered.is_empty() {
ordered.push(normalized_project_root.to_path_buf());
}
if !ordered.iter().any(|root| root == normalized_project_root) {
ordered.push(normalized_project_root.to_path_buf());
}
DependencyRuntimePlan {
sync_roots: ordered,
fail_open_decision: None,
}
}
fn parse_command_tokens(command: &str, reporter: &HookReporter) -> Option<Vec<String>> {
match shell_words::split(command) {
Ok(tokens) => Some(tokens),
Err(error) => {
reporter.verbose(&format!(
"[RCH] failed to parse delegated command for CARGO_TARGET_DIR forwarding: {}",
error
));
None
}
}
}
pub(crate) fn required_runtime_for_kind(kind: Option<CompilationKind>) -> RequiredRuntime {
match kind {
Some(k) => match k {
CompilationKind::CargoBuild
| CompilationKind::CargoTest
| CompilationKind::CargoCheck
| CompilationKind::CargoClippy
| CompilationKind::CargoDoc
| CompilationKind::CargoNextest
| CompilationKind::CargoBench
| CompilationKind::Rustc => RequiredRuntime::Rust,
CompilationKind::CargoZigbuild => RequiredRuntime::Zig,
CompilationKind::BunTest | CompilationKind::BunTypecheck => RequiredRuntime::Bun,
CompilationKind::NixBuild => RequiredRuntime::Nix,
CompilationKind::GoBuild | CompilationKind::GoTest | CompilationKind::GoVet => {
RequiredRuntime::Go
}
CompilationKind::Tsc => RequiredRuntime::Node,
_ => RequiredRuntime::None,
},
None => RequiredRuntime::None,
}
}
pub(crate) fn add_cargo_isolation(command: &str, worker_id: &WorkerId) -> String {
if !command.contains("cargo") {
return command.to_string();
}
let safe_worker_id = sanitize_cargo_home_token(worker_id.as_str());
let cargo_home = rch_common::remote_cargo_cache_expr(&safe_worker_id);
let quoted_cargo_home = format!("\"{cargo_home}\"");
let base_prelude = rch_common::remote_cargo_home_base_prelude();
let base_var = rch_common::RCH_CARGO_HOME_BASE_VAR;
let escaped_command = shell_escape::escape(command.into());
let script = format!(
"{base_var}=\"${{1:-}}\"; if [ -z \"${{{base_var}}}\" ]; then {base_prelude}; fi; mkdir -p {cargo_home} || exit $?; touch {cargo_home} 2>/dev/null || true; export CARGO_HOME={cargo_home}; if command -v git >/dev/null 2>&1; then export CARGO_NET_GIT_FETCH_WITH_CLI=true; fi; sh -c {command}",
base_prelude = base_prelude,
cargo_home = quoted_cargo_home,
command = escaped_command
);
format!(
"sh -c {} rch-cargo-cache \"${{{base_var}:-}}\"",
shell_escape::escape(script.into())
)
}
#[cfg(test)]
mod tests;