Skip to main content

rch_common/
placement.rs

1//! Canonical placement / visibility / strict-remote controls
2//! (bd-session-history-remediation-ocv9i.13.5).
3//!
4//! The complete session-history analysis kept turning up agents reaching for
5//! folklore knobs — `RCH_WORKER`, `RCH_VISIBILITY`, `RCH_QUEUE_WHEN_BUSY`,
6//! `RCH_FORCE_REMOTE`, `RCH_REQUIRE_REMOTE`, daemon wait timeouts, and
7//! worker-scoped target dirs — to compensate for opaque selection and proof
8//! semantics. Those controls were read in a dozen different places, some were
9//! *silently ignored* (`RCH_FORCE_REMOTE` had no effect in the hook path), and
10//! none of them were surfaced back to the agent so it could tell what RCH
11//! actually did.
12//!
13//! This module is the single source of truth that makes them first-class:
14//!
15//! * [`placement_controls`] — the canonical registry (name, aliases, value
16//!   semantics) consumed by `rch capabilities` so the knobs are discoverable.
17//! * [`resolve_placement`] — a *pure* env → [`PlacementPlan`] resolver. It is
18//!   deterministic (takes a getter closure rather than touching the process
19//!   environment) and it never silently drops a control: an unrecognized value
20//!   or a superseded alias becomes a [`ControlDiagnostic`] rather than a quiet
21//!   no-op.
22//! * [`StrictRemotePolicy`] — the canonical force-vs-require distinction, the
23//!   single fix for the most-confused pair of knobs. `RCH_FORCE_REMOTE` means
24//!   *always attempt offload but still fail open*; `RCH_REQUIRE_REMOTE` means
25//!   *fail closed — refuse local fallback* (proof mode).
26//! * [`evaluate_requested_worker`] — a pure admissibility evaluator. A
27//!   requested worker that is unavailable, admin-disabled, bypassed,
28//!   wrong-platform, missing a runtime, project-excluded, or full yields a
29//!   structured refusal keyed to the stable [`IncidentReasonCode`] taxonomy
30//!   plus a concrete next action — never a silent swap to a different worker.
31//!
32//! Everything here is clock-free and I/O-free so it is cheap on the hook hot
33//! path and exhaustively unit-testable.
34
35use schemars::JsonSchema;
36use serde::{Deserialize, Serialize};
37
38use crate::incident::{ControlState, IncidentReasonCode};
39use crate::schema_versions::{SchemaComponent, current_version};
40use crate::types::{WorkerSelectionDiagnostic, WorkerSelectionDiagnosticDecision};
41
42// ---------------------------------------------------------------------------
43// Canonical control vocabulary
44// ---------------------------------------------------------------------------
45
46/// Strict-remote / proof policy, resolved from the env precedence
47/// `RCH_REQUIRE_REMOTE` > `RCH_FORCE_REMOTE`.
48///
49/// These two knobs were the most conflated pair in the session evidence, so the
50/// distinction is encoded in the type rather than left to prose:
51///
52/// * [`Off`](Self::Off) — heuristics decide; local fallback is allowed.
53/// * [`ForceRemote`](Self::ForceRemote) — always *attempt* offload (bypass the
54///   `min_local_time` / speedup gating) but still **fail open** to local if no
55///   worker can take it.
56/// * [`RequireRemote`](Self::RequireRemote) — **fail closed**: refuse local
57///   fallback entirely (proof mode). Implies the force-remote intent.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
59#[serde(rename_all = "snake_case")]
60pub enum StrictRemotePolicy {
61    /// Heuristics decide; local fallback allowed.
62    #[default]
63    Off,
64    /// Always attempt offload, but fail open to local.
65    ForceRemote,
66    /// Fail closed: never fall back to local (proof mode).
67    RequireRemote,
68}
69
70impl StrictRemotePolicy {
71    /// Stable lowercase token.
72    #[must_use]
73    pub fn as_str(self) -> &'static str {
74        match self {
75            StrictRemotePolicy::Off => "off",
76            StrictRemotePolicy::ForceRemote => "force_remote",
77            StrictRemotePolicy::RequireRemote => "require_remote",
78        }
79    }
80
81    /// True when local fallback is refused (proof mode).
82    #[must_use]
83    pub fn fail_closed(self) -> bool {
84        matches!(self, StrictRemotePolicy::RequireRemote)
85    }
86
87    /// True when offload should always be attempted (force or require),
88    /// bypassing the local-runtime / speedup heuristics.
89    #[must_use]
90    pub fn forces_offload(self) -> bool {
91        matches!(
92            self,
93            StrictRemotePolicy::ForceRemote | StrictRemotePolicy::RequireRemote
94        )
95    }
96}
97
98/// Queue-when-busy policy. Defaults to enabled (the daemon may hold a request
99/// for a worker rather than immediately falling back to local).
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
101#[serde(rename_all = "snake_case")]
102pub enum QueuePolicy {
103    /// Wait for a worker to free up (default).
104    #[default]
105    QueueWhenBusy,
106    /// Never queue; fall back to local immediately when workers are busy.
107    NoQueue,
108}
109
110impl QueuePolicy {
111    /// Stable lowercase token.
112    #[must_use]
113    pub fn as_str(self) -> &'static str {
114        match self {
115            QueuePolicy::QueueWhenBusy => "queue_when_busy",
116            QueuePolicy::NoQueue => "no_queue",
117        }
118    }
119
120    /// True when the request should wait for a worker.
121    #[must_use]
122    pub fn waits(self) -> bool {
123        matches!(self, QueuePolicy::QueueWhenBusy)
124    }
125}
126
127/// Hook output visibility. `default` means "no explicit override; use config".
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
129#[serde(rename_all = "snake_case")]
130pub enum VisibilityMode {
131    /// No explicit env override — defer to config.
132    #[default]
133    Default,
134    /// Suppress hook output.
135    None,
136    /// One-line summary.
137    Summary,
138    /// Verbose per-step output.
139    Verbose,
140}
141
142impl VisibilityMode {
143    /// Stable lowercase token.
144    #[must_use]
145    pub fn as_str(self) -> &'static str {
146        match self {
147            VisibilityMode::Default => "default",
148            VisibilityMode::None => "none",
149            VisibilityMode::Summary => "summary",
150            VisibilityMode::Verbose => "verbose",
151        }
152    }
153
154    /// Parse a canonical `RCH_VISIBILITY` value (`none` / `summary` /
155    /// `verbose`). Returns `None` for unrecognized values so the caller can
156    /// raise a diagnostic instead of silently ignoring it.
157    fn parse(value: &str) -> Option<VisibilityMode> {
158        match value.trim().to_ascii_lowercase().as_str() {
159            "none" | "quiet" => Some(VisibilityMode::None),
160            "summary" => Some(VisibilityMode::Summary),
161            "verbose" => Some(VisibilityMode::Verbose),
162            _ => None,
163        }
164    }
165}
166
167/// Worker-scoped target-dir policy.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
169#[serde(rename_all = "snake_case")]
170pub enum TargetDirPolicy {
171    /// Pooled, reuse-friendly remote target dir (default).
172    #[default]
173    Pooled,
174    /// Legacy unique-per-job remote target dir (`RCH_DISABLE_TARGET_REUSE`).
175    PerJob,
176    /// Caller pinned a worker-scoped `CARGO_TARGET_DIR`.
177    WorkerScoped,
178}
179
180impl TargetDirPolicy {
181    /// Stable lowercase token.
182    #[must_use]
183    pub fn as_str(self) -> &'static str {
184        match self {
185            TargetDirPolicy::Pooled => "pooled",
186            TargetDirPolicy::PerJob => "per_job",
187            TargetDirPolicy::WorkerScoped => "worker_scoped",
188        }
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Control registry (capabilities discovery)
194// ---------------------------------------------------------------------------
195
196/// The category a control belongs to, for grouping in discovery surfaces.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
198#[serde(rename_all = "snake_case")]
199pub enum ControlKind {
200    /// Worker placement / selection.
201    Placement,
202    /// Strict-remote / proof policy.
203    StrictRemote,
204    /// Queue-when-busy policy.
205    Queue,
206    /// Daemon wait timeout.
207    WaitTimeout,
208    /// Output visibility.
209    Visibility,
210    /// Worker-scoped target dir.
211    TargetDir,
212    /// Requested execution profile.
213    Profile,
214    /// Self-healing master switch.
215    SelfHealing,
216}
217
218/// A single canonical control: its primary env var, accepted aliases, the
219/// equivalent CLI flag (if any), and human/agent-facing value semantics. This
220/// is the registry `rch capabilities` renders so agents stop discovering knobs
221/// by folklore.
222#[derive(Debug, Clone, Copy)]
223pub struct PlacementControl {
224    /// Canonical environment variable name.
225    pub canonical_env: &'static str,
226    /// Accepted aliases (documented, mapped to the canonical control).
227    pub aliases: &'static [&'static str],
228    /// Equivalent CLI flag, when one exists.
229    pub flag: Option<&'static str>,
230    /// Which family the control belongs to.
231    pub kind: ControlKind,
232    /// Accepted value form (e.g. `worker id`, `0|1`, `secs`).
233    pub value_form: &'static str,
234    /// One-line description of the effect.
235    pub description: &'static str,
236}
237
238/// The canonical placement/visibility/strict/queue/wait control registry.
239///
240/// Adding a knob here is the *only* place it needs to be declared for discovery
241/// — `rch capabilities` derives its env-var list from this slice.
242#[must_use]
243pub fn placement_controls() -> &'static [PlacementControl] {
244    const CONTROLS: &[PlacementControl] = &[
245        PlacementControl {
246            canonical_env: "RCH_WORKER",
247            aliases: &["RCH_WORKERS"],
248            flag: None,
249            kind: ControlKind::Placement,
250            value_form: "worker-id[,worker-id...]",
251            description: "Request specific worker(s) by id. Still passes capability/admission \
252                          checks; an inadmissible requested worker is refused with a reason code.",
253        },
254        PlacementControl {
255            canonical_env: "RCH_PRESET",
256            aliases: &[],
257            flag: None,
258            kind: ControlKind::Profile,
259            value_form: "profile-name",
260            description: "Request a named execution profile (recorded as requested_profile).",
261        },
262        PlacementControl {
263            canonical_env: "RCH_REQUIRE_REMOTE",
264            aliases: &[],
265            flag: None,
266            kind: ControlKind::StrictRemote,
267            value_form: "0|1",
268            description: "Fail closed: refuse local fallback (proof mode). Takes precedence over \
269                          RCH_FORCE_REMOTE.",
270        },
271        PlacementControl {
272            canonical_env: "RCH_FORCE_REMOTE",
273            aliases: &[],
274            flag: None,
275            kind: ControlKind::StrictRemote,
276            value_form: "0|1",
277            description: "Always attempt offload (bypass local-time/speedup gating) but still \
278                          fail open to local. Distinct from RCH_REQUIRE_REMOTE.",
279        },
280        PlacementControl {
281            canonical_env: "RCH_QUEUE_WHEN_BUSY",
282            aliases: &[],
283            flag: None,
284            kind: ControlKind::Queue,
285            value_form: "0|1 (default 1)",
286            description: "Wait for a busy worker instead of immediately falling back to local. \
287                          Set 0 to disable queueing.",
288        },
289        PlacementControl {
290            canonical_env: "RCH_DAEMON_WAIT_RESPONSE_TIMEOUT_SECS",
291            aliases: &["RCH_DAEMON_RESPONSE_TIMEOUT_SECS"],
292            flag: None,
293            kind: ControlKind::WaitTimeout,
294            value_form: "secs (>0)",
295            description: "Maximum time to wait for a queued worker before falling back.",
296        },
297        PlacementControl {
298            canonical_env: "RCH_VISIBILITY",
299            aliases: &["RCH_QUIET", "RCH_VERBOSE"],
300            flag: None,
301            kind: ControlKind::Visibility,
302            value_form: "none|summary|verbose",
303            description: "Hook output verbosity. RCH_QUIET=>none, RCH_VERBOSE=>verbose.",
304        },
305        PlacementControl {
306            canonical_env: "RCH_DISABLE_TARGET_REUSE",
307            aliases: &[],
308            flag: None,
309            kind: ControlKind::TargetDir,
310            value_form: "0|1",
311            description: "Use a legacy unique-per-job remote target dir instead of the pooled, \
312                          reuse-friendly dir.",
313        },
314        PlacementControl {
315            canonical_env: "RCH_NO_SELF_HEALING",
316            aliases: &[],
317            flag: Some("--no-self-healing"),
318            kind: ControlKind::SelfHealing,
319            value_form: "0|1",
320            description: "Master switch disabling hook->daemon autostart and daemon hook install.",
321        },
322    ];
323    CONTROLS
324}
325
326// ---------------------------------------------------------------------------
327// Diagnostics
328// ---------------------------------------------------------------------------
329
330/// Severity of a control diagnostic.
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
332#[serde(rename_all = "snake_case")]
333pub enum ControlDiagnosticLevel {
334    /// Informational (e.g. an alias was canonicalized).
335    Info,
336    /// The value could not be applied as written and a fallback was used.
337    Warning,
338}
339
340/// A single diagnostic explaining how a control was interpreted. The whole
341/// point: a knob that cannot be applied as written is *reported*, never
342/// silently dropped.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
344pub struct ControlDiagnostic {
345    /// Canonical control this diagnostic concerns.
346    pub control: String,
347    /// Severity.
348    pub level: ControlDiagnosticLevel,
349    /// What happened / what to do.
350    pub message: String,
351}
352
353impl ControlDiagnostic {
354    fn info(control: &str, message: impl Into<String>) -> Self {
355        Self {
356            control: control.to_string(),
357            level: ControlDiagnosticLevel::Info,
358            message: message.into(),
359        }
360    }
361
362    fn warning(control: &str, message: impl Into<String>) -> Self {
363        Self {
364            control: control.to_string(),
365            level: ControlDiagnosticLevel::Warning,
366            message: message.into(),
367        }
368    }
369}
370
371// ---------------------------------------------------------------------------
372// Requested-worker admissibility
373// ---------------------------------------------------------------------------
374
375/// Outcome class for an explicitly requested worker. Every non-`Honored`
376/// variant is a *structured refusal*: the requested worker is not silently
377/// swapped for another.
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
379#[serde(rename_all = "snake_case")]
380pub enum RequestedWorkerStatus {
381    /// No worker was explicitly requested.
382    NotRequested,
383    /// A worker was requested but has not yet been evaluated against the fleet.
384    Requested,
385    /// The requested worker is admissible and was honored.
386    Honored,
387    /// The requested worker is unknown/unreachable/drained.
388    Unavailable,
389    /// The requested worker was disabled by an operator.
390    AdminDisabled,
391    /// The requested worker is in transient quarantine (temporary bypass).
392    TemporarilyBypassed,
393    /// The requested worker's OS/arch does not match the build target.
394    WrongPlatform,
395    /// The requested worker lacks the required runtime/toolchain/target.
396    MissingRuntime,
397    /// The requested worker is excluded because it already runs this project.
398    ProjectExcluded,
399    /// The requested worker has no free slots for this request.
400    NoFreeSlots,
401}
402
403impl RequestedWorkerStatus {
404    /// Stable lowercase token.
405    #[must_use]
406    pub fn as_str(self) -> &'static str {
407        match self {
408            RequestedWorkerStatus::NotRequested => "not_requested",
409            RequestedWorkerStatus::Requested => "requested",
410            RequestedWorkerStatus::Honored => "honored",
411            RequestedWorkerStatus::Unavailable => "unavailable",
412            RequestedWorkerStatus::AdminDisabled => "admin_disabled",
413            RequestedWorkerStatus::TemporarilyBypassed => "temporarily_bypassed",
414            RequestedWorkerStatus::WrongPlatform => "wrong_platform",
415            RequestedWorkerStatus::MissingRuntime => "missing_runtime",
416            RequestedWorkerStatus::ProjectExcluded => "project_excluded",
417            RequestedWorkerStatus::NoFreeSlots => "no_free_slots",
418        }
419    }
420
421    /// True when the request was refused (a worker was named but cannot be used
422    /// as asked).
423    #[must_use]
424    pub fn is_refusal(self) -> bool {
425        !matches!(
426            self,
427            RequestedWorkerStatus::NotRequested
428                | RequestedWorkerStatus::Requested
429                | RequestedWorkerStatus::Honored
430        )
431    }
432}
433
434/// The facts an evaluator needs about an explicitly requested worker. The hook
435/// fills these from the daemon's per-worker selection diagnostics; tests fill
436/// them directly.
437#[derive(Debug, Clone, PartialEq, Eq)]
438pub struct RequestedWorkerFacts {
439    /// The requested worker id (empty/none means "no request").
440    pub requested: Option<String>,
441    /// Whether the worker is a known, configured worker.
442    pub exists: bool,
443    /// Operator explicitly disabled this worker.
444    pub admin_disabled: bool,
445    /// Worker is draining or drained (out of service by operator intent).
446    pub draining_or_drained: bool,
447    /// Worker is reachable / responding.
448    pub reachable: bool,
449    /// Worker is in transient quarantine (temporary bypass).
450    pub temporarily_bypassed: bool,
451    /// Worker OS/arch matches the build target.
452    pub platform_matches: bool,
453    /// Worker has the required runtime/toolchain/target.
454    pub has_required_runtime: bool,
455    /// Worker already runs this project (active-project exclusion).
456    pub project_excluded: bool,
457    /// Worker has enough configured total capacity for the request eventually.
458    pub has_total_capacity: bool,
459    /// Worker has enough free slots for the request.
460    pub has_free_slots: bool,
461    /// Estimated core count for the request (the `-j` lever the reader can lower).
462    /// Retained so a capacity refusal can name the concrete numbers that collided
463    /// and the exact `-j` value that would fit (rch#35).
464    pub estimated_cores: u32,
465    /// The worker's total configured slots (its hard capacity ceiling).
466    pub total_slots: u32,
467    /// Stable incident class for a remaining selector denial, if any.
468    pub admission_denial_code: Option<IncidentReasonCode>,
469    /// Human-readable selector reason for a remaining admission denial.
470    pub admission_denial_reason: Option<String>,
471}
472
473impl RequestedWorkerFacts {
474    /// Facts for "no worker requested".
475    #[must_use]
476    pub fn none() -> Self {
477        Self {
478            requested: None,
479            exists: false,
480            admin_disabled: false,
481            draining_or_drained: false,
482            reachable: true,
483            temporarily_bypassed: false,
484            platform_matches: true,
485            has_required_runtime: true,
486            project_excluded: false,
487            has_total_capacity: true,
488            has_free_slots: true,
489            estimated_cores: 0,
490            total_slots: 0,
491            admission_denial_code: None,
492            admission_denial_reason: None,
493        }
494    }
495
496    /// Facts for a fully admissible requested worker (test/clarity helper).
497    #[must_use]
498    pub fn admissible(id: impl Into<String>) -> Self {
499        Self {
500            requested: Some(id.into()),
501            exists: true,
502            ..Self::none()
503        }
504    }
505
506    /// Build canonical requested-worker facts from daemon selection diagnostics.
507    #[must_use]
508    pub fn from_diagnostic(
509        requested: impl Into<String>,
510        worker: &WorkerSelectionDiagnostic,
511    ) -> Self {
512        let toolchain_inadmissible = worker
513            .reason_codes
514            .iter()
515            .any(|code| code.starts_with("toolchain."));
516        let admission_denial_code =
517            (worker.final_decision == WorkerSelectionDiagnosticDecision::Deny).then(|| {
518                if worker
519                    .reason_codes
520                    .iter()
521                    .any(|code| code == "pressure.critical")
522                {
523                    IncidentReasonCode::CriticalPressure
524                } else if worker.reason_codes.iter().any(|code| {
525                    code.starts_with("topology.")
526                        || code.starts_with("preflight.")
527                        || code.starts_with("convergence.")
528                        || code.starts_with("reliability.")
529                }) {
530                    IncidentReasonCode::HardPreflight
531                } else {
532                    IncidentReasonCode::NoAdmissibleWorkers
533                }
534            });
535        Self {
536            requested: Some(requested.into()),
537            exists: true,
538            admin_disabled: worker.status.eq_ignore_ascii_case("disabled"),
539            draining_or_drained: worker.status.to_ascii_lowercase().contains("drain"),
540            reachable: !worker.status.eq_ignore_ascii_case("unreachable"),
541            temporarily_bypassed: worker.circuit_state.eq_ignore_ascii_case("open")
542                || worker.status.to_ascii_lowercase().contains("bypass"),
543            platform_matches: !worker
544                .reason_codes
545                .iter()
546                .any(|code| code.contains("os_arch") || code.contains("platform")),
547            has_required_runtime: worker.runtime_available && !toolchain_inadmissible,
548            project_excluded: worker.active_project_excluded,
549            has_total_capacity: worker.total_slots >= worker.estimated_cores,
550            has_free_slots: worker.available_slots >= worker.estimated_cores,
551            estimated_cores: worker.estimated_cores,
552            total_slots: worker.total_slots,
553            admission_denial_code,
554            admission_denial_reason: admission_denial_code.map(|_| worker.final_reason.clone()),
555        }
556    }
557}
558
559/// The structured result of evaluating a requested worker.
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
561pub struct RequestedWorkerOutcome {
562    /// Outcome class.
563    pub status: RequestedWorkerStatus,
564    /// Stable incident reason code (`RCH-Innn`) for a refusal, when applicable.
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub reason_code: Option<String>,
567    /// Concrete next action for the agent on a refusal.
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub next_action: Option<String>,
570}
571
572impl RequestedWorkerOutcome {
573    /// Outcome for "no worker requested".
574    #[must_use]
575    pub fn not_requested() -> Self {
576        Self {
577            status: RequestedWorkerStatus::NotRequested,
578            reason_code: None,
579            next_action: None,
580        }
581    }
582
583    /// Outcome for "requested, pending fleet evaluation".
584    #[must_use]
585    pub fn requested() -> Self {
586        Self {
587            status: RequestedWorkerStatus::Requested,
588            reason_code: None,
589            next_action: None,
590        }
591    }
592
593    fn honored() -> Self {
594        Self {
595            status: RequestedWorkerStatus::Honored,
596            reason_code: None,
597            next_action: None,
598        }
599    }
600
601    fn refused(
602        status: RequestedWorkerStatus,
603        code: IncidentReasonCode,
604        next_action: impl Into<String>,
605    ) -> Self {
606        Self {
607            status,
608            reason_code: Some(code.code().to_string()),
609            next_action: Some(next_action.into()),
610        }
611    }
612}
613
614/// Evaluate an explicitly requested worker against the fleet facts.
615///
616/// The order is most-specific-first so the *primary* reason a worker cannot be
617/// used is the one reported. Each refusal maps to a stable
618/// [`IncidentReasonCode`] and carries a concrete next action — the request is
619/// never silently rerouted to a different worker.
620#[must_use]
621pub fn evaluate_requested_worker(facts: &RequestedWorkerFacts) -> RequestedWorkerOutcome {
622    let Some(id) = facts.requested.as_deref().filter(|s| !s.trim().is_empty()) else {
623        return RequestedWorkerOutcome::not_requested();
624    };
625
626    if !facts.exists {
627        return RequestedWorkerOutcome::refused(
628            RequestedWorkerStatus::Unavailable,
629            IncidentReasonCode::NoAdmissibleWorkers,
630            format!("'{id}' is not a configured worker; run `rch workers list` to see valid ids"),
631        );
632    }
633    if facts.admin_disabled {
634        return RequestedWorkerOutcome::refused(
635            RequestedWorkerStatus::AdminDisabled,
636            IncidentReasonCode::NoAdmissibleWorkers,
637            format!(
638                "'{id}' is operator-disabled; re-enable with `rch workers enable {id}` or request another worker"
639            ),
640        );
641    }
642    if facts.draining_or_drained || !facts.reachable {
643        return RequestedWorkerOutcome::refused(
644            RequestedWorkerStatus::Unavailable,
645            IncidentReasonCode::NoAdmissibleWorkers,
646            format!(
647                "'{id}' is draining/unreachable; wait for it to return or request another worker"
648            ),
649        );
650    }
651    if facts.temporarily_bypassed {
652        return RequestedWorkerOutcome::refused(
653            RequestedWorkerStatus::TemporarilyBypassed,
654            IncidentReasonCode::CircuitOpen,
655            format!(
656                "'{id}' is in transient quarantine; wait for auto-rejoin or request another worker"
657            ),
658        );
659    }
660    if !facts.platform_matches {
661        return RequestedWorkerOutcome::refused(
662            RequestedWorkerStatus::WrongPlatform,
663            IncidentReasonCode::OsArchMismatch,
664            format!("'{id}' OS/arch does not match the build target; request a matching worker"),
665        );
666    }
667    if !facts.has_required_runtime {
668        return RequestedWorkerOutcome::refused(
669            RequestedWorkerStatus::MissingRuntime,
670            IncidentReasonCode::MissingRuntimeToolchainTarget,
671            format!(
672                "'{id}' lacks the required runtime/toolchain/target; install it or request another worker"
673            ),
674        );
675    }
676    if facts.project_excluded {
677        return RequestedWorkerOutcome::refused(
678            RequestedWorkerStatus::ProjectExcluded,
679            IncidentReasonCode::ActiveProjectExclusion,
680            format!(
681                "'{id}' already runs this project (active-project exclusion); request another worker"
682            ),
683        );
684    }
685    if !facts.has_total_capacity {
686        // Name the two counts that collided and the exact `-j` value that fits, so
687        // the reader is not left guessing that `-j` is the lever — the common
688        // trigger is an unbounded `cargo build`/`run` that inherited the configured
689        // `build_slots` default against a deliberately small pinned worker (rch#35).
690        let next_action = if facts.total_slots > 0 && facts.estimated_cores > 0 {
691            format!(
692                "'{id}' has {total} total slot(s) but the request is estimated at {cores} core(s); \
693                 pass -j {total} (or fewer), or request a larger worker",
694                total = facts.total_slots,
695                cores = facts.estimated_cores,
696            )
697        } else {
698            format!(
699                "'{id}' cannot fit the requested core count; lower the request (-j) or request a larger worker"
700            )
701        };
702        return RequestedWorkerOutcome::refused(
703            RequestedWorkerStatus::NoFreeSlots,
704            IncidentReasonCode::InsufficientSlots,
705            next_action,
706        );
707    }
708    if !facts.has_free_slots {
709        return RequestedWorkerOutcome::refused(
710            RequestedWorkerStatus::NoFreeSlots,
711            IncidentReasonCode::InsufficientSlots,
712            format!(
713                "'{id}' has no free slots right now; queue with RCH_QUEUE_WHEN_BUSY=1 or request another worker"
714            ),
715        );
716    }
717    if let Some(code) = facts.admission_denial_code {
718        let reason = facts
719            .admission_denial_reason
720            .as_deref()
721            .unwrap_or("worker admission denied");
722        let next_action = match code {
723            IncidentReasonCode::CriticalPressure => format!(
724                "'{id}' failed admission under critical pressure ({reason}); remediate worker pressure or request another worker"
725            ),
726            IncidentReasonCode::HardPreflight => format!(
727                "'{id}' failed a hard preflight ({reason}); run `rch diagnose -- <command>`, remediate the preflight, or request another worker"
728            ),
729            _ => format!(
730                "'{id}' failed worker admission ({reason}); run `rch diagnose -- <command>` or request another worker"
731            ),
732        };
733        return RequestedWorkerOutcome::refused(
734            RequestedWorkerStatus::Unavailable,
735            code,
736            next_action,
737        );
738    }
739    RequestedWorkerOutcome::honored()
740}
741
742// ---------------------------------------------------------------------------
743// The resolved plan
744// ---------------------------------------------------------------------------
745
746/// The canonical, agent-facing resolution of all placement controls for a
747/// single request. Field names are exactly the explicit-control surface the
748/// session-history report asked to be made first-class.
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
750pub struct PlacementPlan {
751    /// Schema version (`SchemaComponent::PlacementPlan`).
752    pub schema_version: String,
753    /// Worker(s) the caller explicitly requested.
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub requested_worker: Option<String>,
756    /// Requested execution profile.
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub requested_profile: Option<String>,
759    /// The worker actually selected (filled once selection runs).
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub effective_worker: Option<String>,
762    /// Strict-remote / proof policy.
763    pub strict_remote_policy: StrictRemotePolicy,
764    /// Queue-when-busy policy.
765    pub queue_policy: QueuePolicy,
766    /// Output visibility mode.
767    pub visibility_mode: VisibilityMode,
768    /// Resolved wait timeout in milliseconds, when explicitly set.
769    #[serde(default, skip_serializing_if = "Option::is_none")]
770    pub wait_timeout_ms: Option<u64>,
771    /// Worker-scoped target-dir policy.
772    pub target_dir_policy: TargetDirPolicy,
773    /// Requested-worker admissibility outcome.
774    pub requested_worker_outcome: RequestedWorkerOutcome,
775    /// How each control was interpreted (superseded aliases, bad values, ...).
776    #[serde(default, skip_serializing_if = "Vec::is_empty")]
777    pub diagnostics: Vec<ControlDiagnostic>,
778}
779
780impl PlacementPlan {
781    /// The current placement-plan schema version.
782    #[must_use]
783    pub fn schema_version() -> &'static str {
784        current_version(SchemaComponent::PlacementPlan)
785    }
786
787    /// Record the worker selection finally honored.
788    #[must_use]
789    pub fn with_effective_worker(mut self, worker: Option<String>) -> Self {
790        self.effective_worker = worker;
791        self
792    }
793
794    /// Replace the requested-worker outcome (after fleet evaluation).
795    #[must_use]
796    pub fn with_requested_worker_outcome(mut self, outcome: RequestedWorkerOutcome) -> Self {
797        self.requested_worker_outcome = outcome;
798        self
799    }
800
801    /// Bridge to the sparse [`ControlState`] embedded in incident events: only
802    /// explicitly-set / non-default fields are carried, matching the
803    /// incident-ledger convention of recording *why* a build was steered.
804    #[must_use]
805    pub fn control_state(&self) -> ControlState {
806        ControlState {
807            requested_worker: self.requested_worker.clone(),
808            requested_profile: self.requested_profile.clone(),
809            strict_remote_policy: self.strict_remote_policy != StrictRemotePolicy::Off,
810            queue_policy: (self.queue_policy != QueuePolicy::default())
811                .then(|| self.queue_policy.as_str().to_string()),
812            visibility_mode: (self.visibility_mode != VisibilityMode::Default)
813                .then(|| self.visibility_mode.as_str().to_string()),
814            wait_timeout_ms: self.wait_timeout_ms,
815            target_dir_policy: (self.target_dir_policy != TargetDirPolicy::default())
816                .then(|| self.target_dir_policy.as_str().to_string()),
817        }
818    }
819}
820
821/// Repo-standard env truthiness (`1`/`true`/`yes`/`on`/`enabled`).
822///
823/// Public because it IS the repo standard: every surface that reads a boolean
824/// environment variable must agree on what `RCH_FOO=0` means, and the way that
825/// agreement breaks is two modules each spelling it out for themselves
826/// (bd-e92eh was exactly that shape for `RCH_JSON`).
827#[must_use]
828pub fn env_truthy(value: &str) -> bool {
829    matches!(
830        value.trim().to_ascii_lowercase().as_str(),
831        "1" | "true" | "yes" | "on" | "enabled"
832    )
833}
834
835/// Repo-standard env falsiness for default-enabled flags
836/// (`0`/`false`/`no`/`off`/`disabled`).
837fn env_falsy(value: &str) -> bool {
838    matches!(
839        value.trim().to_ascii_lowercase().as_str(),
840        "0" | "false" | "no" | "off" | "disabled"
841    )
842}
843
844/// Resolve every placement control from the environment into a single
845/// [`PlacementPlan`].
846///
847/// `get` is the environment accessor — `|k| std::env::var(k).ok()` in the hook,
848/// a map lookup in tests. The function is otherwise pure: no clock, no I/O, no
849/// global state. It never silently drops a control; anything it cannot apply as
850/// written becomes a [`ControlDiagnostic`].
851///
852/// The returned plan has no `effective_worker` (selection has not run yet) and a
853/// `requested_worker_outcome` of `NotRequested`/`Requested`; callers refine both
854/// once the daemon answers.
855pub fn resolve_placement<F>(get: F) -> PlacementPlan
856where
857    F: Fn(&str) -> Option<String>,
858{
859    let mut diagnostics = Vec::new();
860
861    // --- requested worker(s): RCH_WORKER (canonical) + RCH_WORKERS (alias) ---
862    let worker_primary = get("RCH_WORKER").filter(|v| !v.trim().is_empty());
863    let worker_alias = get("RCH_WORKERS").filter(|v| !v.trim().is_empty());
864    let requested_worker = match (&worker_primary, &worker_alias) {
865        (Some(p), Some(a)) => {
866            diagnostics.push(ControlDiagnostic::info(
867                "RCH_WORKER",
868                "RCH_WORKER and its alias RCH_WORKERS are both set; merging both into the requested set",
869            ));
870            Some(merge_worker_lists(p, a))
871        }
872        (Some(p), None) => Some(p.trim().to_string()),
873        (None, Some(a)) => {
874            diagnostics.push(ControlDiagnostic::info(
875                "RCH_WORKERS",
876                "RCH_WORKERS is an alias for the canonical RCH_WORKER",
877            ));
878            Some(a.trim().to_string())
879        }
880        (None, None) => None,
881    };
882
883    // --- requested profile: RCH_PRESET ---
884    let requested_profile = get("RCH_PRESET").filter(|v| !v.trim().is_empty());
885
886    // --- strict remote: RCH_REQUIRE_REMOTE > RCH_FORCE_REMOTE ---
887    let require_remote = get("RCH_REQUIRE_REMOTE").is_some_and(|v| env_truthy(&v));
888    let force_remote = get("RCH_FORCE_REMOTE").is_some_and(|v| env_truthy(&v));
889    let strict_remote_policy = if require_remote {
890        if force_remote {
891            diagnostics.push(ControlDiagnostic::info(
892                "RCH_REQUIRE_REMOTE",
893                "RCH_REQUIRE_REMOTE (fail-closed) supersedes RCH_FORCE_REMOTE (fail-open)",
894            ));
895        }
896        StrictRemotePolicy::RequireRemote
897    } else if force_remote {
898        StrictRemotePolicy::ForceRemote
899    } else {
900        StrictRemotePolicy::Off
901    };
902
903    // --- queue-when-busy: default enabled ---
904    let queue_policy = match get("RCH_QUEUE_WHEN_BUSY") {
905        None => QueuePolicy::QueueWhenBusy,
906        Some(v) if env_falsy(&v) => QueuePolicy::NoQueue,
907        Some(v) if env_truthy(&v) || v.trim().is_empty() => QueuePolicy::QueueWhenBusy,
908        Some(v) => {
909            diagnostics.push(ControlDiagnostic::warning(
910                "RCH_QUEUE_WHEN_BUSY",
911                format!("unrecognized value {v:?}; interpreted as enabled (queue_when_busy)"),
912            ));
913            QueuePolicy::QueueWhenBusy
914        }
915    };
916
917    // --- wait timeout: RCH_DAEMON_WAIT_RESPONSE_TIMEOUT_SECS (alias _RESPONSE_) ---
918    let (wait_timeout_ms, wait_diag) = resolve_wait_timeout(&get);
919    diagnostics.extend(wait_diag);
920
921    // --- visibility: RCH_VISIBILITY (canonical), RCH_QUIET / RCH_VERBOSE aliases ---
922    let (visibility_mode, vis_diag) = resolve_visibility(&get);
923    diagnostics.extend(vis_diag);
924
925    // --- target dir policy ---
926    let target_dir_policy = if get("RCH_DISABLE_TARGET_REUSE").is_some_and(|v| env_truthy(&v)) {
927        TargetDirPolicy::PerJob
928    } else if get("CARGO_TARGET_DIR")
929        .map(|v| v.contains(".rch-target") || v.contains("rch-worker"))
930        .unwrap_or(false)
931    {
932        TargetDirPolicy::WorkerScoped
933    } else {
934        TargetDirPolicy::Pooled
935    };
936
937    let requested_worker_outcome = if requested_worker.is_some() {
938        RequestedWorkerOutcome::requested()
939    } else {
940        RequestedWorkerOutcome::not_requested()
941    };
942
943    PlacementPlan {
944        schema_version: PlacementPlan::schema_version().to_string(),
945        requested_worker,
946        requested_profile: requested_profile.map(|v| v.trim().to_string()),
947        effective_worker: None,
948        strict_remote_policy,
949        queue_policy,
950        visibility_mode,
951        wait_timeout_ms,
952        target_dir_policy,
953        requested_worker_outcome,
954        diagnostics,
955    }
956}
957
958/// Resolve placement with the box-level role applied BENEATH the
959/// per-call environment (bd-wywsj): role sets the DEFAULT posture,
960/// explicit env always wins.
961///
962/// * `Dispatcher`: when NEITHER `RCH_REQUIRE_REMOTE` nor
963///   `RCH_FORCE_REMOTE` is present (presence — even with a falsy
964///   value — is an explicit per-call decision), the strict-remote
965///   policy defaults to fail-closed [`StrictRemotePolicy::RequireRemote`]
966///   so a saturated fleet QUEUES instead of repopulating local rustc
967///   on the orchestrator. A diagnostic records the role default.
968/// * `Worker` / `Hybrid`: identical to [`resolve_placement`] — the
969///   box is (or may be) the compute; fail-open stays the default.
970pub fn resolve_placement_with_role<F>(get: F, role: crate::BoxRole) -> PlacementPlan
971where
972    F: Fn(&str) -> Option<String>,
973{
974    let strict_env_present =
975        get("RCH_REQUIRE_REMOTE").is_some() || get("RCH_FORCE_REMOTE").is_some();
976    let mut plan = resolve_placement(get);
977    if role == crate::BoxRole::Dispatcher
978        && !strict_env_present
979        && plan.strict_remote_policy == StrictRemotePolicy::Off
980    {
981        plan.strict_remote_policy = StrictRemotePolicy::RequireRemote;
982        plan.diagnostics.push(ControlDiagnostic::info(
983            "general.role",
984            "role=dispatcher defaults offloadable builds to fail-closed + queue \
985             (set RCH_REQUIRE_REMOTE=0 or RCH_FORCE_REMOTE=1 per call to override)",
986        ));
987    }
988    plan
989}
990
991/// Merge two comma-separated worker lists, trimming and de-duplicating while
992/// preserving first-seen order.
993fn merge_worker_lists(primary: &str, alias: &str) -> String {
994    let mut seen = Vec::new();
995    for part in primary
996        .split(',')
997        .chain(alias.split(','))
998        .map(str::trim)
999        .filter(|p| !p.is_empty())
1000    {
1001        if !seen.contains(&part) {
1002            seen.push(part);
1003        }
1004    }
1005    seen.join(",")
1006}
1007
1008/// Resolve the wait timeout from the canonical var or its alias.
1009fn resolve_wait_timeout<F>(get: &F) -> (Option<u64>, Vec<ControlDiagnostic>)
1010where
1011    F: Fn(&str) -> Option<String>,
1012{
1013    const CANONICAL: &str = "RCH_DAEMON_WAIT_RESPONSE_TIMEOUT_SECS";
1014    const ALIAS: &str = "RCH_DAEMON_RESPONSE_TIMEOUT_SECS";
1015    let mut diags = Vec::new();
1016
1017    let (name, raw) = match (get(CANONICAL), get(ALIAS)) {
1018        (Some(v), _) => (CANONICAL, Some(v)),
1019        (None, Some(v)) => {
1020            diags.push(ControlDiagnostic::info(
1021                ALIAS,
1022                "RCH_DAEMON_RESPONSE_TIMEOUT_SECS also applies to non-wait queries; \
1023                 prefer RCH_DAEMON_WAIT_RESPONSE_TIMEOUT_SECS for the queue wait timeout",
1024            ));
1025            (ALIAS, Some(v))
1026        }
1027        (None, None) => (CANONICAL, None),
1028    };
1029
1030    let Some(raw) = raw else {
1031        return (None, diags);
1032    };
1033    match raw.trim().parse::<u64>() {
1034        Ok(secs) if secs > 0 => (Some(secs.saturating_mul(1000)), diags),
1035        _ => {
1036            diags.push(ControlDiagnostic::warning(
1037                name,
1038                format!("invalid timeout {raw:?}; expected a positive integer in seconds; using the default"),
1039            ));
1040            (None, diags)
1041        }
1042    }
1043}
1044
1045/// Resolve the visibility mode from the canonical var or its quiet/verbose
1046/// aliases.
1047fn resolve_visibility<F>(get: &F) -> (VisibilityMode, Vec<ControlDiagnostic>)
1048where
1049    F: Fn(&str) -> Option<String>,
1050{
1051    let mut diags = Vec::new();
1052
1053    if let Some(raw) = get("RCH_VISIBILITY") {
1054        match VisibilityMode::parse(&raw) {
1055            Some(mode) => return (mode, diags),
1056            None => {
1057                diags.push(ControlDiagnostic::warning(
1058                    "RCH_VISIBILITY",
1059                    format!("unrecognized value {raw:?}; expected none|summary|verbose; deferring to config"),
1060                ));
1061                // Fall through to the alias checks rather than silently ignoring.
1062            }
1063        }
1064    }
1065    if get("RCH_QUIET").is_some_and(|v| env_truthy(&v)) {
1066        return (VisibilityMode::None, diags);
1067    }
1068    if get("RCH_VERBOSE").is_some_and(|v| env_truthy(&v)) {
1069        return (VisibilityMode::Verbose, diags);
1070    }
1071    (VisibilityMode::Default, diags)
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077    use crate::types::{RequiredRuntime, WorkerId, WorkerSelectionDiagnosticDecision};
1078    use std::collections::HashMap;
1079
1080    /// Build an env getter over a fixed map (no process-env mutation, so tests
1081    /// stay deterministic and race-free).
1082    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
1083        let map: HashMap<String, String> = pairs
1084            .iter()
1085            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1086            .collect();
1087        move |k: &str| map.get(k).cloned()
1088    }
1089
1090    fn admissibility_diagnostic() -> WorkerSelectionDiagnostic {
1091        WorkerSelectionDiagnostic {
1092            worker_id: WorkerId::new("css"),
1093            status: "healthy".to_string(),
1094            circuit_state: "closed".to_string(),
1095            pressure_state: "healthy".to_string(),
1096            pressure_reason_code: "pressure.healthy".to_string(),
1097            success_rate: Some(1.0),
1098            min_success_rate: 0.8,
1099            fallback_min_success_rate: 0.5,
1100            required_runtime: RequiredRuntime::Rust,
1101            runtime_available: true,
1102            available_slots: 8,
1103            total_slots: 8,
1104            estimated_cores: 2,
1105            active_project_excluded: false,
1106            final_decision: WorkerSelectionDiagnosticDecision::Allow,
1107            final_reason: "eligible".to_string(),
1108            reason_codes: vec![],
1109        }
1110    }
1111
1112    // --- strict-remote precedence -----------------------------------------
1113
1114    #[test]
1115    fn strict_remote_off_by_default() {
1116        let plan = resolve_placement(env(&[]));
1117        assert_eq!(plan.strict_remote_policy, StrictRemotePolicy::Off);
1118        assert!(!plan.strict_remote_policy.fail_closed());
1119        assert!(!plan.strict_remote_policy.forces_offload());
1120    }
1121
1122    #[test]
1123    fn force_remote_is_fail_open() {
1124        let plan = resolve_placement(env(&[("RCH_FORCE_REMOTE", "1")]));
1125        assert_eq!(plan.strict_remote_policy, StrictRemotePolicy::ForceRemote);
1126        assert!(plan.strict_remote_policy.forces_offload());
1127        assert!(!plan.strict_remote_policy.fail_closed());
1128    }
1129
1130    // --- box role (bd-wywsj) ----------------------------------------------
1131
1132    #[test]
1133    fn dispatcher_role_defaults_fail_closed_with_env_override() {
1134        use crate::BoxRole;
1135        // Bare dispatcher: fail-closed + queue by default, with the
1136        // role default named in a diagnostic.
1137        let plan = resolve_placement_with_role(env(&[]), BoxRole::Dispatcher);
1138        assert_eq!(
1139            plan.strict_remote_policy,
1140            StrictRemotePolicy::RequireRemote,
1141            "dispatcher must never repopulate local rustc by default"
1142        );
1143        assert_eq!(plan.queue_policy, QueuePolicy::QueueWhenBusy);
1144        assert!(
1145            plan.diagnostics.iter().any(|d| d.control == "general.role"),
1146            "the role default must be visible in diagnostics"
1147        );
1148        // Env presence — even FALSY — is an explicit per-call override.
1149        let opted_out =
1150            resolve_placement_with_role(env(&[("RCH_REQUIRE_REMOTE", "0")]), BoxRole::Dispatcher);
1151        assert_eq!(opted_out.strict_remote_policy, StrictRemotePolicy::Off);
1152        // Explicit fail-open force wins over the role default too.
1153        let forced =
1154            resolve_placement_with_role(env(&[("RCH_FORCE_REMOTE", "1")]), BoxRole::Dispatcher);
1155        assert_eq!(forced.strict_remote_policy, StrictRemotePolicy::ForceRemote);
1156        // And explicit fail-closed is unchanged.
1157        let required =
1158            resolve_placement_with_role(env(&[("RCH_REQUIRE_REMOTE", "1")]), BoxRole::Dispatcher);
1159        assert_eq!(
1160            required.strict_remote_policy,
1161            StrictRemotePolicy::RequireRemote
1162        );
1163    }
1164
1165    #[test]
1166    fn worker_and_hybrid_roles_change_nothing() {
1167        use crate::BoxRole;
1168        for role in [BoxRole::Worker, BoxRole::Hybrid] {
1169            let plan = resolve_placement_with_role(env(&[]), role);
1170            let baseline = resolve_placement(env(&[]));
1171            assert_eq!(plan.strict_remote_policy, baseline.strict_remote_policy);
1172            assert_eq!(plan.queue_policy, baseline.queue_policy);
1173            assert_eq!(
1174                plan.diagnostics.len(),
1175                baseline.diagnostics.len(),
1176                "{role:?} must add no diagnostics"
1177            );
1178        }
1179    }
1180
1181    #[test]
1182    fn require_remote_is_fail_closed() {
1183        let plan = resolve_placement(env(&[("RCH_REQUIRE_REMOTE", "true")]));
1184        assert_eq!(plan.strict_remote_policy, StrictRemotePolicy::RequireRemote);
1185        assert!(plan.strict_remote_policy.fail_closed());
1186        assert!(plan.strict_remote_policy.forces_offload());
1187    }
1188
1189    #[test]
1190    fn require_remote_supersedes_force_remote_with_diagnostic() {
1191        let plan = resolve_placement(env(&[
1192            ("RCH_REQUIRE_REMOTE", "1"),
1193            ("RCH_FORCE_REMOTE", "1"),
1194        ]));
1195        assert_eq!(plan.strict_remote_policy, StrictRemotePolicy::RequireRemote);
1196        assert!(
1197            plan.diagnostics
1198                .iter()
1199                .any(|d| d.control == "RCH_REQUIRE_REMOTE"
1200                    && d.level == ControlDiagnosticLevel::Info),
1201            "expected a precedence diagnostic, got {:?}",
1202            plan.diagnostics
1203        );
1204    }
1205
1206    // --- queue-when-busy ---------------------------------------------------
1207
1208    #[test]
1209    fn queue_defaults_enabled() {
1210        let plan = resolve_placement(env(&[]));
1211        assert_eq!(plan.queue_policy, QueuePolicy::QueueWhenBusy);
1212        assert!(plan.queue_policy.waits());
1213    }
1214
1215    #[test]
1216    fn queue_disabled_by_zero() {
1217        let plan = resolve_placement(env(&[("RCH_QUEUE_WHEN_BUSY", "0")]));
1218        assert_eq!(plan.queue_policy, QueuePolicy::NoQueue);
1219        assert!(!plan.queue_policy.waits());
1220    }
1221
1222    #[test]
1223    fn queue_unrecognized_value_warns_not_silently_ignored() {
1224        let plan = resolve_placement(env(&[("RCH_QUEUE_WHEN_BUSY", "maybe")]));
1225        assert_eq!(plan.queue_policy, QueuePolicy::QueueWhenBusy);
1226        assert!(
1227            plan.diagnostics
1228                .iter()
1229                .any(|d| d.control == "RCH_QUEUE_WHEN_BUSY"
1230                    && d.level == ControlDiagnosticLevel::Warning)
1231        );
1232    }
1233
1234    // --- wait timeout ------------------------------------------------------
1235
1236    #[test]
1237    fn wait_timeout_seconds_to_ms() {
1238        let plan = resolve_placement(env(&[("RCH_DAEMON_WAIT_RESPONSE_TIMEOUT_SECS", "120")]));
1239        assert_eq!(plan.wait_timeout_ms, Some(120_000));
1240    }
1241
1242    #[test]
1243    fn wait_timeout_alias_emits_info() {
1244        let plan = resolve_placement(env(&[("RCH_DAEMON_RESPONSE_TIMEOUT_SECS", "45")]));
1245        assert_eq!(plan.wait_timeout_ms, Some(45_000));
1246        assert!(
1247            plan.diagnostics
1248                .iter()
1249                .any(|d| d.control == "RCH_DAEMON_RESPONSE_TIMEOUT_SECS"
1250                    && d.level == ControlDiagnosticLevel::Info)
1251        );
1252    }
1253
1254    #[test]
1255    fn wait_timeout_invalid_warns_and_falls_back() {
1256        let plan = resolve_placement(env(&[("RCH_DAEMON_WAIT_RESPONSE_TIMEOUT_SECS", "soon")]));
1257        assert_eq!(plan.wait_timeout_ms, None);
1258        assert!(
1259            plan.diagnostics
1260                .iter()
1261                .any(|d| d.level == ControlDiagnosticLevel::Warning)
1262        );
1263    }
1264
1265    // --- visibility --------------------------------------------------------
1266
1267    #[test]
1268    fn visibility_canonical_values() {
1269        for (raw, expected) in [
1270            ("none", VisibilityMode::None),
1271            ("summary", VisibilityMode::Summary),
1272            ("verbose", VisibilityMode::Verbose),
1273        ] {
1274            let plan = resolve_placement(env(&[("RCH_VISIBILITY", raw)]));
1275            assert_eq!(plan.visibility_mode, expected);
1276        }
1277    }
1278
1279    #[test]
1280    fn visibility_quiet_and_verbose_aliases() {
1281        let quiet = resolve_placement(env(&[("RCH_QUIET", "1")]));
1282        assert_eq!(quiet.visibility_mode, VisibilityMode::None);
1283        let verbose = resolve_placement(env(&[("RCH_VERBOSE", "1")]));
1284        assert_eq!(verbose.visibility_mode, VisibilityMode::Verbose);
1285    }
1286
1287    #[test]
1288    fn visibility_invalid_warns_then_falls_back_to_alias() {
1289        let plan = resolve_placement(env(&[("RCH_VISIBILITY", "ultra"), ("RCH_QUIET", "1")]));
1290        // Invalid canonical value is reported, and the alias still applies.
1291        assert_eq!(plan.visibility_mode, VisibilityMode::None);
1292        assert!(
1293            plan.diagnostics.iter().any(
1294                |d| d.control == "RCH_VISIBILITY" && d.level == ControlDiagnosticLevel::Warning
1295            )
1296        );
1297    }
1298
1299    // --- requested worker / profile / target dir ---------------------------
1300
1301    #[test]
1302    fn requested_worker_and_profile_captured() {
1303        let plan = resolve_placement(env(&[("RCH_WORKER", "css"), ("RCH_PRESET", "fast")]));
1304        assert_eq!(plan.requested_worker.as_deref(), Some("css"));
1305        assert_eq!(plan.requested_profile.as_deref(), Some("fast"));
1306        assert_eq!(
1307            plan.requested_worker_outcome.status,
1308            RequestedWorkerStatus::Requested
1309        );
1310    }
1311
1312    #[test]
1313    fn worker_alias_merges_and_dedupes() {
1314        let plan = resolve_placement(env(&[
1315            ("RCH_WORKER", "css, ovh-a"),
1316            ("RCH_WORKERS", "ovh-a,ovh-b"),
1317        ]));
1318        assert_eq!(plan.requested_worker.as_deref(), Some("css,ovh-a,ovh-b"));
1319        assert!(plan.diagnostics.iter().any(|d| d.control == "RCH_WORKER"));
1320    }
1321
1322    #[test]
1323    fn target_dir_policy_variants() {
1324        let pooled = resolve_placement(env(&[]));
1325        assert_eq!(pooled.target_dir_policy, TargetDirPolicy::Pooled);
1326        let per_job = resolve_placement(env(&[("RCH_DISABLE_TARGET_REUSE", "1")]));
1327        assert_eq!(per_job.target_dir_policy, TargetDirPolicy::PerJob);
1328        let scoped = resolve_placement(env(&[("CARGO_TARGET_DIR", "/tmp/.rch-target/css")]));
1329        assert_eq!(scoped.target_dir_policy, TargetDirPolicy::WorkerScoped);
1330    }
1331
1332    // --- requested-worker admissibility ------------------------------------
1333
1334    #[test]
1335    fn evaluate_not_requested() {
1336        let out = evaluate_requested_worker(&RequestedWorkerFacts::none());
1337        assert_eq!(out.status, RequestedWorkerStatus::NotRequested);
1338        assert!(out.reason_code.is_none());
1339    }
1340
1341    #[test]
1342    fn evaluate_honored() {
1343        let out = evaluate_requested_worker(&RequestedWorkerFacts::admissible("css"));
1344        assert_eq!(out.status, RequestedWorkerStatus::Honored);
1345        assert!(out.reason_code.is_none());
1346        assert!(!out.status.is_refusal());
1347    }
1348
1349    #[test]
1350    fn evaluate_unknown_worker_refused() {
1351        let facts = RequestedWorkerFacts {
1352            requested: Some("ghost".into()),
1353            exists: false,
1354            ..RequestedWorkerFacts::none()
1355        };
1356        let out = evaluate_requested_worker(&facts);
1357        assert_eq!(out.status, RequestedWorkerStatus::Unavailable);
1358        assert_eq!(out.reason_code.as_deref(), Some("RCH-I001"));
1359        assert!(
1360            out.next_action
1361                .as_deref()
1362                .unwrap()
1363                .contains("rch workers list")
1364        );
1365        assert!(out.status.is_refusal());
1366    }
1367
1368    #[test]
1369    fn evaluate_admin_disabled_refused() {
1370        let facts = RequestedWorkerFacts {
1371            admin_disabled: true,
1372            ..RequestedWorkerFacts::admissible("css")
1373        };
1374        let out = evaluate_requested_worker(&facts);
1375        assert_eq!(out.status, RequestedWorkerStatus::AdminDisabled);
1376        assert_eq!(out.reason_code.as_deref(), Some("RCH-I001"));
1377        assert!(
1378            out.next_action
1379                .as_deref()
1380                .unwrap()
1381                .contains("rch workers enable")
1382        );
1383    }
1384
1385    #[test]
1386    fn evaluate_wrong_platform_refused() {
1387        let facts = RequestedWorkerFacts {
1388            platform_matches: false,
1389            ..RequestedWorkerFacts::admissible("css")
1390        };
1391        let out = evaluate_requested_worker(&facts);
1392        assert_eq!(out.status, RequestedWorkerStatus::WrongPlatform);
1393        assert_eq!(
1394            out.reason_code.as_deref(),
1395            Some(IncidentReasonCode::OsArchMismatch.code())
1396        );
1397    }
1398
1399    #[test]
1400    fn evaluate_missing_runtime_refused() {
1401        let facts = RequestedWorkerFacts {
1402            has_required_runtime: false,
1403            ..RequestedWorkerFacts::admissible("css")
1404        };
1405        let out = evaluate_requested_worker(&facts);
1406        assert_eq!(out.status, RequestedWorkerStatus::MissingRuntime);
1407        assert_eq!(
1408            out.reason_code.as_deref(),
1409            Some(IncidentReasonCode::MissingRuntimeToolchainTarget.code())
1410        );
1411    }
1412
1413    #[test]
1414    fn evaluate_project_excluded_refused() {
1415        let facts = RequestedWorkerFacts {
1416            project_excluded: true,
1417            ..RequestedWorkerFacts::admissible("css")
1418        };
1419        let out = evaluate_requested_worker(&facts);
1420        assert_eq!(out.status, RequestedWorkerStatus::ProjectExcluded);
1421        assert_eq!(
1422            out.reason_code.as_deref(),
1423            Some(IncidentReasonCode::ActiveProjectExclusion.code())
1424        );
1425    }
1426
1427    #[test]
1428    fn evaluate_no_slots_refused() {
1429        let facts = RequestedWorkerFacts {
1430            has_free_slots: false,
1431            ..RequestedWorkerFacts::admissible("css")
1432        };
1433        let out = evaluate_requested_worker(&facts);
1434        assert_eq!(out.status, RequestedWorkerStatus::NoFreeSlots);
1435        assert_eq!(
1436            out.reason_code.as_deref(),
1437            Some(IncidentReasonCode::InsufficientSlots.code())
1438        );
1439    }
1440
1441    #[test]
1442    fn evaluate_insufficient_capacity_refused_without_queue_advice() {
1443        let mut diagnostic = admissibility_diagnostic();
1444        diagnostic.worker_id = WorkerId::new("tiny");
1445        diagnostic.available_slots = 1;
1446        diagnostic.total_slots = 1;
1447        diagnostic.estimated_cores = 2;
1448        let facts = RequestedWorkerFacts::from_diagnostic("tiny", &diagnostic);
1449        let out = evaluate_requested_worker(&facts);
1450        assert_eq!(out.status, RequestedWorkerStatus::NoFreeSlots);
1451        assert_eq!(
1452            out.reason_code.as_deref(),
1453            Some(IncidentReasonCode::InsufficientSlots.code())
1454        );
1455        let next_action = out.next_action.expect("capacity refusal needs an action");
1456        // Names the concrete counts and the -j lever (rch#35), not just a vague
1457        // "lower the request", and still carries no queue advice.
1458        assert!(
1459            next_action.contains("1 total slot"),
1460            "should name total slots: {next_action}"
1461        );
1462        assert!(
1463            next_action.contains("2 core"),
1464            "should name estimated cores: {next_action}"
1465        );
1466        assert!(
1467            next_action.contains("-j 1"),
1468            "should name the exact -j value that fits: {next_action}"
1469        );
1470        assert!(!next_action.contains("RCH_QUEUE_WHEN_BUSY"));
1471    }
1472
1473    #[test]
1474    fn diagnostic_toolchain_refusal_maps_to_missing_runtime_code() {
1475        let mut diagnostic = admissibility_diagnostic();
1476        diagnostic.final_decision = WorkerSelectionDiagnosticDecision::Deny;
1477        diagnostic.final_reason = "toolchain version mismatch".to_string();
1478        diagnostic.reason_codes = vec!["toolchain.version_mismatch".to_string()];
1479        let facts = RequestedWorkerFacts::from_diagnostic("css", &diagnostic);
1480        let out = evaluate_requested_worker(&facts);
1481        assert_eq!(out.status, RequestedWorkerStatus::MissingRuntime);
1482        assert_eq!(
1483            out.reason_code.as_deref(),
1484            Some(IncidentReasonCode::MissingRuntimeToolchainTarget.code())
1485        );
1486    }
1487
1488    #[test]
1489    fn remaining_diagnostic_denials_never_report_honored() {
1490        let cases = [
1491            (
1492                "critical pressure",
1493                "pressure.critical",
1494                IncidentReasonCode::CriticalPressure,
1495            ),
1496            (
1497                "topology preflight failed",
1498                "topology.preflight_failed",
1499                IncidentReasonCode::HardPreflight,
1500            ),
1501            (
1502                "success rate below fallback floor",
1503                "health.below_fallback_min_success_rate",
1504                IncidentReasonCode::NoAdmissibleWorkers,
1505            ),
1506        ];
1507
1508        for (reason, reason_code, expected_incident) in cases {
1509            let mut diagnostic = admissibility_diagnostic();
1510            diagnostic.final_decision = WorkerSelectionDiagnosticDecision::Deny;
1511            diagnostic.final_reason = reason.to_string();
1512            diagnostic.reason_codes = vec![reason_code.to_string()];
1513            let facts = RequestedWorkerFacts::from_diagnostic("css", &diagnostic);
1514            let out = evaluate_requested_worker(&facts);
1515            assert!(out.status.is_refusal(), "{reason_code} must be refused");
1516            assert_eq!(out.reason_code.as_deref(), Some(expected_incident.code()));
1517            assert!(out.next_action.is_some());
1518        }
1519    }
1520
1521    #[test]
1522    fn evaluate_temporary_bypass_refused() {
1523        let facts = RequestedWorkerFacts {
1524            temporarily_bypassed: true,
1525            ..RequestedWorkerFacts::admissible("css")
1526        };
1527        let out = evaluate_requested_worker(&facts);
1528        assert_eq!(out.status, RequestedWorkerStatus::TemporarilyBypassed);
1529        assert_eq!(
1530            out.reason_code.as_deref(),
1531            Some(IncidentReasonCode::CircuitOpen.code())
1532        );
1533    }
1534
1535    #[test]
1536    fn refusal_priority_is_most_specific_first() {
1537        // An unknown worker that is also missing a runtime reports "unavailable"
1538        // (existence is the primary blocker), not "missing_runtime".
1539        let facts = RequestedWorkerFacts {
1540            requested: Some("ghost".into()),
1541            exists: false,
1542            has_required_runtime: false,
1543            ..RequestedWorkerFacts::none()
1544        };
1545        assert_eq!(
1546            evaluate_requested_worker(&facts).status,
1547            RequestedWorkerStatus::Unavailable
1548        );
1549    }
1550
1551    // --- plan -> control-state bridge & serialization ----------------------
1552
1553    #[test]
1554    fn control_state_bridge_is_sparse() {
1555        // Default plan -> empty control state (nothing explicitly set).
1556        let plan = resolve_placement(env(&[]));
1557        assert!(plan.control_state().is_empty());
1558    }
1559
1560    #[test]
1561    fn control_state_bridge_carries_set_fields() {
1562        let plan = resolve_placement(env(&[
1563            ("RCH_WORKER", "css"),
1564            ("RCH_REQUIRE_REMOTE", "1"),
1565            ("RCH_QUEUE_WHEN_BUSY", "0"),
1566        ]));
1567        let cs = plan.control_state();
1568        assert_eq!(cs.requested_worker.as_deref(), Some("css"));
1569        assert!(cs.strict_remote_policy);
1570        assert_eq!(cs.queue_policy.as_deref(), Some("no_queue"));
1571        assert!(!cs.is_empty());
1572    }
1573
1574    #[test]
1575    fn plan_round_trips_through_json() {
1576        let plan = resolve_placement(env(&[("RCH_WORKER", "css"), ("RCH_FORCE_REMOTE", "1")]))
1577            .with_effective_worker(Some("ovh-a".to_string()))
1578            .with_requested_worker_outcome(evaluate_requested_worker(
1579                &RequestedWorkerFacts::admissible("css"),
1580            ));
1581        let v = serde_json::to_value(&plan).unwrap();
1582        assert_eq!(v["strict_remote_policy"], "force_remote");
1583        assert_eq!(v["queue_policy"], "queue_when_busy");
1584        assert_eq!(v["requested_worker"], "css");
1585        assert_eq!(v["effective_worker"], "ovh-a");
1586        assert_eq!(v["requested_worker_outcome"]["status"], "honored");
1587        let back: PlacementPlan = serde_json::from_value(v).unwrap();
1588        assert_eq!(back, plan);
1589    }
1590
1591    #[test]
1592    fn schema_version_is_pinned() {
1593        let plan = resolve_placement(env(&[]));
1594        assert_eq!(plan.schema_version, PlacementPlan::schema_version());
1595    }
1596
1597    // --- registry invariants ----------------------------------------------
1598
1599    #[test]
1600    fn registry_canonical_names_unique_and_distinct_from_aliases() {
1601        let controls = placement_controls();
1602        let mut names = std::collections::HashSet::new();
1603        for c in controls {
1604            assert!(
1605                names.insert(c.canonical_env),
1606                "duplicate canonical env {}",
1607                c.canonical_env
1608            );
1609        }
1610        // No alias collides with a canonical name.
1611        for c in controls {
1612            for alias in c.aliases {
1613                assert!(
1614                    !names.contains(alias),
1615                    "alias {alias} collides with a canonical name"
1616                );
1617            }
1618        }
1619    }
1620
1621    #[test]
1622    fn registry_covers_the_audited_controls() {
1623        let controls = placement_controls();
1624        let has = |n: &str| controls.iter().any(|c| c.canonical_env == n);
1625        for required in [
1626            "RCH_WORKER",
1627            "RCH_REQUIRE_REMOTE",
1628            "RCH_FORCE_REMOTE",
1629            "RCH_QUEUE_WHEN_BUSY",
1630            "RCH_VISIBILITY",
1631            "RCH_NO_SELF_HEALING",
1632        ] {
1633            assert!(has(required), "registry missing audited control {required}");
1634        }
1635    }
1636}