Skip to content
1 change: 1 addition & 0 deletions cmux-tui/crates/acpmux/docs/background-processes.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ process's command line and its kill never leave the machine.
| Chat closed, no client attached | Keeps running: a running process blocks the idle reap. |
| Over the session's memory budget (cx-v9s8g) | Enforced by the kernel, not by acpmux measuring: on Linux the session's cgroup scope (`MemoryMax`); `killed`, reason `budget_memory`. |
| Over the CPU budget (cx-v9s8g) | Lower priority, not killed: the cgroup's `CPUWeight` on Linux, `setpriority(PRIO_PGRP)` on macOS. |
| Start (macOS) | The harness starts at background CPU class (`PRIO_DARWIN_BG`, `cpu_class.rs`), so every process it runs inherits it and cmux's GUI and renderers go first. `normal` opts out: a profile's env `ACPMUX_SESSION_CPU_CLASS`, the daemon's env, or config `session.cpuClass`. |
| Harness time limit (Claude's `timeout`; acpmux lifts the ceiling to about 24.8 days) | `killed`, reason `harness_time_limit`. |
| Daemon restart | Keeps running (the agent host owns the harness); the list is rebuilt from the `process_update` records. A process still `running` there stays so until its harness reports its end. |
| Harness or host exit, app update relaunch | `lost` (built), with the last output kept and Run again offered. A real reattach needs Claude Code's background sessions (`claude agents`, 2.1.196); evaluated separately. |
Expand Down
5 changes: 5 additions & 0 deletions cmux-tui/crates/acpmux/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,11 @@ impl ChildAgent {
let preview_format = Some(pinned_preview_format(&cmd));
#[cfg(windows)]
cmd.creation_flags(crate::job::AGENT_FLAGS);
// Below the GUI's CPU class (macOS), the tools it starts with it (`cpu_class.rs`).
// From the harness's own env: its profile's value as resolved.
let env = command_env(&cmd);
crate::cpu_class::CpuClass::for_session(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.apply(&mut cmd);
let mut child = cmd.spawn().map_err(|e| spawn_error(&format!("spawn agent {name}"), e))?;
#[cfg(windows)]
let job = {
Expand Down
2 changes: 2 additions & 0 deletions cmux-tui/crates/acpmux/src/agent_host/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ async fn start(spec: &SpawnSpec) -> Result<Started> {
// started (background shells included).
.process_group(0)
.kill_on_drop(false);
// Below the GUI's CPU class (macOS), the tools it starts with it.
spec.cpu_class.apply(&mut cmd);
// Bind before the harness starts, so a failure leaves nothing running.
let _ = std::fs::remove_file(&spec.socket);
let listener = UnixListener::bind(&spec.socket)
Expand Down
4 changes: 4 additions & 0 deletions cmux-tui/crates/acpmux/src/agent_host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ pub struct SpawnSpec {
/// (`cwd_pin.rs`). The host's harness spawn fails when `cwd` leads elsewhere by then.
#[serde(default)]
pub cwd_pin: Option<crate::cwd_pin::CwdPin>,
/// The CPU class the harness starts at (`cpu_class.rs`), decided by the daemon. A spec from
/// a daemon that predates it starts the harness as before.
#[serde(default)]
pub cpu_class: crate::cpu_class::CpuClass,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
3 changes: 2 additions & 1 deletion cmux-tui/crates/acpmux/src/cli/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,8 @@ pub enum Command {
show_token: bool,
/// Set a daemon host-wide setting as KEY=VALUE (for example,
/// `session.memory_mb=6144`, `session.swap_mb=2048`,
/// `session.tasks_max=8192` or `session.sessions_tasks_max=131072`).
/// `session.tasks_max=8192`, `session.sessions_tasks_max=131072` or
/// `session.cpu_class=normal`).
#[arg(long, value_name = "KEY=VALUE")]
set: Option<String>,
},
Expand Down
28 changes: 24 additions & 4 deletions cmux-tui/crates/acpmux/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -820,9 +820,10 @@ pub struct TitlesConfig {
pub model: Option<String>,
}

/// Host-wide limits applied to every Linux session scope. These are daemon
/// settings, rather than spawn environment, so a CLI can set them without
/// crossing the app-only spawn-env gate.
/// Host-wide limits applied to every Linux session scope, and the CPU class
/// of every macOS session's harness. These are daemon settings, rather than
/// spawn environment, so a CLI can set them without crossing the app-only
/// spawn-env gate.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct SessionResourceConfig {
Expand All @@ -843,6 +844,11 @@ pub struct SessionResourceConfig {
/// kernel's `threads-max`, between 16384 and 262144.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sessions_tasks_max: Option<u64>,
/// `cpuClass`: the CPU class a macOS session's harness starts at,
/// `background` or `normal` (`cpu_class.rs`). Env `ACPMUX_SESSION_CPU_CLASS`
/// overrides it, a profile's env overrides both. Unset: `background`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_class: Option<crate::cpu_class::CpuClass>,
}

impl SessionResourceConfig {
Expand Down Expand Up @@ -1089,6 +1095,19 @@ impl Config {
/// `none`, and `clear` remove an override and restore the computed default.
pub fn set_session_resource(&mut self, key: &str, value: &str) -> Result<()> {
let key = key.trim().replace('-', "_").to_ascii_lowercase();
if matches!(
key.as_str(),
"session.cpu_class" | "session.cpuclass" | "cpu_class" | "cpuclass"
) {
let value = value.trim();
self.session.cpu_class = match value.to_ascii_lowercase().as_str() {
"" | "default" | "none" | "clear" => None,
_ => Some(crate::cpu_class::CpuClass::parse(value).ok_or_else(|| {
anyhow::anyhow!("{key} must be `background`, `normal` or `default`")
})?),
};
return Ok(());
}
let slot = match key.as_str() {
"session.memory_mb" | "session.memorymb" | "memory_mb" | "memorymb" => {
&mut self.session.memory_mb
Expand All @@ -1105,7 +1124,8 @@ impl Config {
| "sessionstasksmax" => &mut self.session.sessions_tasks_max,
_ => anyhow::bail!(
"unknown session resource setting `{key}` (use session.memory_mb, \
session.swap_mb, session.tasks_max or session.sessions_tasks_max)"
session.swap_mb, session.tasks_max, session.sessions_tasks_max or \
session.cpu_class)"
),
};
let tasks = key.contains("tasks");
Expand Down
17 changes: 17 additions & 0 deletions cmux-tui/crates/acpmux/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ fn session_task_caps_are_typed_config_values() {
assert!(config.set_session_resource("session.tasks_max", "lots").is_err());
}

#[test]
fn cli_session_cpu_class_is_a_config_value() {
use crate::cpu_class::CpuClass;
let mut config = Config::default();
config.set_session_resource("session.cpu_class", "normal").unwrap();
assert_eq!(config.session.cpu_class, Some(CpuClass::Normal));
let saved = serde_json::to_value(&config).unwrap();
assert_eq!(saved["session"]["cpuClass"], "normal");
let loaded: Config = serde_json::from_value(saved).unwrap();
assert_eq!(loaded.session.cpu_class, Some(CpuClass::Normal));
config.set_session_resource("session.cpu-class", "Background").unwrap();
assert_eq!(config.session.cpu_class, Some(CpuClass::Background));
assert!(config.set_session_resource("session.cpu_class", "utility").is_err());
config.set_session_resource("session.cpu_class", "default").unwrap();
assert_eq!(config.session.cpu_class, None);
}

fn prof(kind: HarnessKind, argv: &[&str]) -> HarnessProfile {
HarnessProfile {
kind,
Expand Down
123 changes: 123 additions & 0 deletions cmux-tui/crates/acpmux/src/cpu_class.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//! The CPU class a session's harness starts at (macOS). Agent harnesses and the tools they run
//! (builds, tests) otherwise compete with cmux and its Chromium renderers at the same priority,
//! and a busy Mac makes a dragged pane's page trail it (cmuxterm-hq#4947: p95 55 ms with 14 busy
//! processes at the default class, 6 ms with the same load at background class).
//!
//! `background` (the default) starts the harness with `PRIO_DARWIN_BG`, as cmux-git starts its
//! git children (`cmux-git` `run.rs`): the lowest CPU priority, throttled disk I/O and
//! background-class new sockets. It is set in the harness process between fork and exec, so the
//! harness and every process it starts afterwards inherit it; a running process is never
//! reniced. `normal` leaves the harness at the daemon's class.
//!
//! The class, first set wins: the profile's env `ACPMUX_SESSION_CPU_CLASS` as the harness gets it
//! (its `${env:…}` references resolved; one build-heavy profile can opt out), the daemon's env
//! `ACPMUX_SESSION_CPU_CLASS`, config `session.cpuClass`
//! (`acpmux config --set session.cpu_class=normal`), then `background`. Linux sessions get their
//! CPU share from their cgroup scope's `CPUWeight` instead (`session_scope.rs`), and other
//! platforms start harnesses as before: there the class is read and ignored.

use serde::{Deserialize, Serialize};

/// The knob, in a profile's env or the daemon's.
pub const ENV: &str = "ACPMUX_SESSION_CPU_CLASS";

/// The CPU class a session's harness starts at.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CpuClass {
/// The daemon's own class: nothing changes at spawn.
#[default]
Normal,
/// `PRIO_DARWIN_BG` on macOS.
Background,
}

/// The class a session starts at when nothing names one.
pub const DEFAULT: CpuClass = CpuClass::Background;

impl CpuClass {
/// `normal` or `background` (any case, trimmed); None for anything else.
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"normal" => Some(Self::Normal),
"background" => Some(Self::Background),
_ => None,
}
}

/// The class of a session whose harness gets the environment `harness_env` (the command's
/// complete one: the profile's values resolved, the daemon's own `ACPMUX_*` removed), with the
/// daemon's env and config read now (at each spawn, as the session scope's limits are).
#[cfg(target_os = "macos")]
pub fn for_session<'a>(harness_env: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
let profile = harness_env.into_iter().find(|(k, _)| *k == ENV).map(|(_, v)| v);
let config = crate::config::Config::load().ok().and_then(|c| c.session.cpu_class);
Self::resolve(profile, std::env::var(ENV).ok(), config)
}

/// Off macOS: `normal`, with nothing read.
#[cfg(not(target_os = "macos"))]
pub fn for_session<'a>(_harness_env: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
Self::Normal
}

/// The first valid of the profile's value, the daemon's env value and config's, else
/// [`DEFAULT`]. A value that names no class is skipped, with a warning.
pub fn resolve(profile: Option<&str>, daemon: Option<String>, config: Option<Self>) -> Self {
let named = |source: &str, value: Option<&str>| {
let value = value?;
let class = Self::parse(value);
if class.is_none() {
tracing::warn!("{source} {ENV}={value:?} names no CPU class (normal, background)");
}
class
};
named("profile env", profile)
.or_else(|| named("daemon env", daemon.as_deref()))
.or(config)
.unwrap_or(DEFAULT)
}

/// Starts `cmd`'s process at this class (macOS; elsewhere `cmd` is unchanged).
pub fn apply(self, cmd: &mut tokio::process::Command) {
#[cfg(target_os = "macos")]
if self == Self::Background {
// SAFETY: the hook runs in the child between fork and exec and makes one
// setpriority(2) call on itself: no allocation, no lock, async-signal-safe. It never
// fails the spawn: a harness that could not be lowered runs as before.
unsafe {
cmd.pre_exec(|| {
// <sys/resource.h>: PRIO_DARWIN_PROCESS and PRIO_DARWIN_BG.
const PRIO_DARWIN_PROCESS: libc::c_int = 4;
const PRIO_DARWIN_BG: libc::c_int = 0x1000;
libc::setpriority(PRIO_DARWIN_PROCESS, 0, PRIO_DARWIN_BG);
Ok(())
});
}
}
#[cfg(not(target_os = "macos"))]
let _ = cmd;
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn the_first_valid_source_names_the_class_else_background() {
let bg = Some(CpuClass::Background);
assert_eq!(CpuClass::resolve(None, None, None), CpuClass::Background);
assert_eq!(CpuClass::resolve(None, None, Some(CpuClass::Normal)), CpuClass::Normal);
// The daemon's env overrides config; the profile overrides both.
assert_eq!(CpuClass::resolve(None, Some("normal".into()), bg), CpuClass::Normal);
assert_eq!(
CpuClass::resolve(Some("background"), Some("normal".into()), Some(CpuClass::Normal)),
CpuClass::Background
);
assert_eq!(CpuClass::resolve(Some(" Normal "), None, bg), CpuClass::Normal);
// A value that names no class is skipped.
assert_eq!(CpuClass::resolve(Some("utility"), Some("normal".into()), bg), CpuClass::Normal);
assert_eq!(CpuClass::resolve(Some(""), None, None), CpuClass::Background);
}
}
1 change: 1 addition & 0 deletions cmux-tui/crates/acpmux/src/hub/catalog_reload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ mod tests {
swap_mb: Some(2048),
tasks_max: Some(8192),
sessions_tasks_max: Some(131_072),
cpu_class: Some(crate::cpu_class::CpuClass::Normal),
},
SessionResourceConfig::default(),
] {
Expand Down
16 changes: 11 additions & 5 deletions cmux-tui/crates/acpmux/src/hub/hosts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1609,15 +1609,20 @@ impl Hub {
self.record_preview_format(session, crate::agent::pinned_preview_format(&cmd));
let std_cmd = cmd.as_std();
let hosts = crate::agent_host::hosts_dir();
let env = if meta.remote_origin {
crate::agent::command_env_cleared(&cmd)
} else {
crate::agent::command_env(&cmd)
};
// From the harness's own env: its profile's value as resolved (`cpu_class.rs`).
let cpu_class = crate::cpu_class::CpuClass::for_session(
env.iter().map(|(k, v)| (k.as_str(), v.as_str())),
);
let spec = crate::agent_host::SpawnSpec {
session_id: session.id.clone(),
program: std_cmd.get_program().to_string_lossy().into_owned(),
args: std_cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(),
env: if meta.remote_origin {
crate::agent::command_env_cleared(&cmd)
} else {
crate::agent::command_env(&cmd)
},
env,
cwd: meta.cwd.clone(),
codex_bridge,
translator,
Expand All @@ -1626,6 +1631,7 @@ impl Hub {
buffer_cap: crate::agent_host::DEFAULT_BUFFER_CAP,
router_owner_fifo: crate::daemon::router_owner_path(),
cwd_pin: crate::cwd_pin::CwdPin::current(),
cpu_class,
};
let launcher = crate::agent_host::link::HostLauncher::current().map_err(internal)?;
// Its MCP configs are read before it starts, never after (`hub/idle_census.rs`).
Expand Down
16 changes: 11 additions & 5 deletions cmux-tui/crates/acpmux/src/hub/pool/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,20 @@ impl Hub {
let preview = crate::agent::pinned_preview_format(&cmd);
let std_cmd = cmd.as_std();
let dir = pool_dir();
let env = if draft.remote_origin {
crate::agent::command_env_cleared(&cmd)
} else {
crate::agent::command_env(&cmd)
};
// From the harness's own env: its profile's value as resolved (`cpu_class.rs`).
let cpu_class = crate::cpu_class::CpuClass::for_session(
env.iter().map(|(k, v)| (k.as_str(), v.as_str())),
);
let host_spec = agent_host::SpawnSpec {
session_id: session_id.clone(),
program: std_cmd.get_program().to_string_lossy().into_owned(),
args: std_cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(),
env: if draft.remote_origin {
crate::agent::command_env_cleared(&cmd)
} else {
crate::agent::command_env(&cmd)
},
env,
cwd: draft.cwd.clone(),
codex_bridge: !claude && crate::codex_app_server::is_profile_launch(&profile.argv),
translator,
Expand All @@ -112,6 +117,7 @@ impl Hub {
router_owner_fifo: crate::daemon::router_owner_path(),
// Never a sub-session's checked folder: a sub-session start takes no pooled harness.
cwd_pin: None,
cpu_class,
};
// No host starts once `stop_pool` began: it could not end it.
if self.pool.stopping.load(Ordering::SeqCst) {
Expand Down
1 change: 1 addition & 0 deletions cmux-tui/crates/acpmux/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub mod codex_app_server;
mod codex_auth_file;
pub mod complete;
pub mod config;
pub mod cpu_class;
pub mod cua_socket;
pub mod cua_v2;
pub mod cwd_pin;
Expand Down
2 changes: 1 addition & 1 deletion cmux-tui/crates/acpmux/src/session_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
//! missing or cannot make a scope (no user manager, as in a container), the
//! harness starts as before; that answer is asked once per `systemd-run` and
//! limits. macOS gets the process-group half of the valve on the holder track
//! (cx-t2sqm.3).
//! (cx-t2sqm.3); its harnesses start at background CPU class (`cpu_class.rs`).
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]

use std::collections::BTreeMap;
Expand Down
1 change: 1 addition & 0 deletions cmux-tui/crates/acpmux/tests/agent_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ fn spec(dir: &Path, session: &str) -> SpawnSpec {
buffer_cap: agent_host::DEFAULT_BUFFER_CAP,
router_owner_fifo: None,
cwd_pin: None,
cpu_class: Default::default(),
}
}

Expand Down
Loading
Loading