cmux-host-budget: the host CPU/RAM budget in one Rust crate, with the app's level rules and their C ABI (no FFI input yet)
The macOS app judges the host's CPU/RAM budget twice: acpmux's daemon reports a verdict (host_pressure in _acpmux/status), and when the daemon does not answer, the app's own Swift copy of the same formula (CmuxNextHostPressure) judges it. This PR moves the formula out of acpmux into a crate the app can link later, and ports the app's own rules (the level tracker and the rejudge on a memory pressure event) into it as Rust, with their C ABI. Nothing in the app changes yet.
Why the app needs a local verdict at all
The app starts acpmux at launch (AgentTabs prewarm), but the host pressure read never starts a daemon and gives up after 2 s. So the local verdict covers: the daemon still starting, restarting for an update or gone; a daemon too slow to answer under load (the overload the indicator is for); and builds with no acpmux (showcase, mock agent host). Deleting the fallback would leave the indicator blind in those cases, so the formula has to reach the app in Rust, in-process.
What changes
- New crate
cmux-tui/crates/cmux-host-budget: acpmux'shost_pressuremodule (formula, override file, probe,Meter,evaluate) moved with its tests. The behavior is the same; the code differs only in small ways:evaluate's pressure and warning steps are helpers that the rejudge shares,Samplereads from JSON, and two panic-ratchet fixes (a checkedu32conversion andget(..end)). acpmux'ssrc/host_pressure.rsre-exports it, sodaemon statusand the send warning read the sameMeter::shared(). The meter's tokio reads (current,peek) are behind the defaulttokiofeature, so the app can link it without tokio. level.rs: the app'sHostPressureTracker(warning at one reason or 75% of the load limit, a 10% deadband while raised, critical at critical pressure or two reasons, fall aftercalm_samplescalm verdicts).rejudge.rs: the app'sHostBudgetVerdict.with(pressure:)on the verdict JSON, so a daemon verdict keeps its session facts (top,local_turns).json.rs,ffi.rs,include/cmux_host_budget_ffi.h(moduleCCmuxHostBudgetFFI): the app's own verdict from the process'sMeter(blocking, off the main thread), the rejudge, a judge of given numbers (the Debug Settings samples), the level and a tracker handle. JSON in acpmux'shost_pressureshape, about once per 15 s; no call panics across the boundary.cmux-tui/Cargo.lockgets the crate. The lock was written on an agent VM withcargo metadata --offline. The proof that it resolves is a fleet job:test level::tests4eb95c52fc6046138b02d75cat cd0817eb72b, the head that merged. It rancargo nextest run --lockedin the cmux-tui workspace and passed. cmux-app-ffi's lock does not change in this PR.
This is no app FFI input: cmux-app-ffi does not depend on the crate here (check-app-ffi-pin.sh --list-inputs does not list it), so this PR needs no re-pin. The FFI move PR adds the cmux-app-ffi link, the header in build-app-ffi.sh, the crate in app-ffi-release.yml's push paths (needed for correctness: without it a change to the crate would publish no FFI release), the re-pin and the Swift half together. That Swift half replaces LocalHostPressureSource, HostSampler, HostBudgetFormula, the verdict's formula init, with(pressure:) and HostPressureTracker with calls into this ABI; the dispatch memory pressure source, the 15 s timer and the views stay in Swift.
The app's tracker and formula tests are ported as the Rust corpus (level_tests.rs, json_tests.rs, ffi_tests.rs). Swift tests not ported, and why: the override-file path from ACPMUX_HOST_BUDGET (a Rust test would set a process environment variable, which is unsafe in Rust 2024; it stays on the Swift side until the Swift half deletes it); theVerdictComesFromDaemonStatus, anAcpmuxWithoutAVerdictIsAskedRarely and all of HostPressureMonitorTests (they test Swift code that stays: the daemon read, its retry and the monitor); theSwapRateIsPerMinute and the HostSampler perf-level tests (the moved meter_tests.rs swap test and performance_cores_are_every_level_but_efficiency already cover them). The small differences between the Swift copy and the daemon's code are listed in the FFI move PR, where the app starts to use this code.
Risk: normal (the label). A crate move behind a re-export plus new rules nothing calls yet; acpmux's behavior is the same code.
Changelog
none
Observed / Not run
Observed (fleet, Linux):
- Red at da425a23455 (the tests with stub rules),
test level::tests672cef6bf7a189222df3da3a: 17 run, 11 failed on assertions (for examplea_raised_level_falls_only_after_calm_samplespanicked atlevel_tests.rs:95), 6 passed (the moved formula, which has no stub). - Green at cd0817eb72b:
test level::tests4eb95c52fc6046138b02d75c,json::testse4e2f6a7b51f035f2c0b52c1,ffi::tests019c69376ac54408b9eef413,meter::testsf70355df532147e45932adbc: all passed. - At cd0817eb72b, clippy
d71f8e3cd0f282526d5aad1e, fmta6b5a475a90a24eb1f95b81eand panic ratchetf6cf68450d300b1e8d0712dfare red only on base findings in files this PR does not touch; none names a cmux-host-budget file (cmux-host-budget clippy is clean): - At bc20fdc3d5f the same three steps found two issues of this branch: an unneeded
crate::path inffi_tests.rsand oneas u32cast. db00697f4ad fixes both. scripts/cmux-next/tests/app-ffi-release-paths.test.sh: ok on this VM.
Known, for the FFI move PR (review comment at this head): cmux_host_budget_with_pressure_json takes one level (1, 2 or 4) and reads any other value as unknown. The dispatch source gives bit flags, and the Swift monitor reads 6 as critical. Before the app calls this ABI, the FFI move PR reads the bits (4 critical, then 2 warn, then 1 normal) and tests 6 and 3, or the Swift side maps the flags first. The Swift half also keeps the last verdict itself: the Rust Tracker does not hold it.
Not run: the crate-root tests module (src/tests.rs, the moved formula, override-file and /proc parser tests) and the acpmux tests at this head were not run as their own fleet steps; the FFI move PR runs cargo test -p cmux-host-budget in full, also with --no-default-features. anything in Swift or the app (no app change here).