From fe4803673df94973051cde12fe8371dc98c271c6 Mon Sep 17 00:00:00 2001 From: Coden Date: Sun, 5 Jul 2026 21:08:48 +0900 Subject: [PATCH] Fix Codex CMUX stale status cleanup Stabilize CMUX status ownership and clear observed stale understatus keys before applying new pills. Bump release metadata to 0.7.3 for deployment. Constraint: keep CMUX integration fail-soft and avoid set-progress Confidence: high Scope-risk: narrow Directive: Preserve status-key cleanup semantics and path-scoped staging for future fixes Tested: node npm/verify-release.js 0.7.3 --verify-packlist; cargo fmt --all -- --check; cargo test --all-targets --locked; cargo clippy --all-targets --locked -- -D warnings; npm test --prefix npm; git diff --check Not-tested: GitHub Actions release workflows not run until tag deployment --- Cargo.lock | 2 +- Cargo.toml | 2 +- npm/install.js | 2 +- npm/package.json | 2 +- src/cmux.rs | 223 +++++++++++++++++++++++++++++++++++++++++++-- src/codex_hooks.rs | 21 ++++- src/main.rs | 14 +-- tests/oneline.rs | 216 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 453 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 335e6a4..77afcd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,7 +172,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "understatus" -version = "0.7.2" +version = "0.7.3" dependencies = [ "anyhow", "libc", diff --git a/Cargo.toml b/Cargo.toml index 12d64e6..d43c08d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "understatus" -version = "0.7.2" +version = "0.7.3" edition = "2021" description = "A calm, unobtrusive macOS statusline addon for AI coding CLIs (Claude Code): CPU/memory/disk/network + session info with a quiet glyph theme." # macOS 전용 크레이트 (host_processor_info / sysctl / IOKit FFI 의존) diff --git a/npm/install.js b/npm/install.js index 5711c97..13a9d52 100644 --- a/npm/install.js +++ b/npm/install.js @@ -22,7 +22,7 @@ const os = require('os'); // 네이티브 바이너리를 받아오는 GitHub 릴리스 태그. // release/publish guard가 Cargo.toml, npm/package.json, 이 값, git tag를 lockstep으로 // 검증합니다. 새 네이티브 릴리스를 낼 때 네 버전을 함께 올리세요. -const VERSION = '0.7.2'; +const VERSION = '0.7.3'; // CPU 아키텍처를 Rust 타겟 트리플로 매핑 const archMap = { diff --git a/npm/package.json b/npm/package.json index 96f59e4..6148464 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "understatus", - "version": "0.7.2", + "version": "0.7.3", "description": "Claude Code statusline addon for macOS — CPU, memory, session info with a calm glyph theme", "bin": { "understatus": "bin/understatus.js" diff --git a/src/cmux.rs b/src/cmux.rs index 40f7f59..6ad27f3 100644 --- a/src/cmux.rs +++ b/src/cmux.rs @@ -16,6 +16,8 @@ use crate::render::{CmuxStatusOutput, Pill}; /// Session cache file containing the last keys applied by the Codex/CMUX bridge. const APPLIED_KEYS_CACHE: &str = "cmux_codex_applied_keys"; +const STATUS_KEY_PREFIX: &str = "understatus.codex."; +const HOOK_SIDEBAR_STATE_TIMEOUT_MS: u64 = 750; /// Default hard timeout for a single manual `cmux` CLI invocation. pub(crate) const DEFAULT_CMUX_TIMEOUT_MS: u64 = 5_000; @@ -128,7 +130,8 @@ fn env_nonempty(name: &str) -> Option { /// Derive a stable namespaced CMUX key for one understatus pill. pub(crate) fn namespaced_key(owner: &str, pill_key: &str) -> String { format!( - "understatus.codex.{}.{}", + "{}{}.{}", + STATUS_KEY_PREFIX, chain::sanitize_session_key(owner), chain::sanitize_session_key(pill_key) ) @@ -227,20 +230,33 @@ pub(crate) fn apply_pills( }; } + let apply_started = Instant::now(); let previous = read_applied_state(&owner); + let observed_keys = read_target_status_keys(&target, sidebar_state_timeout_ms(mode)); + let execute_mode = match mode_after_elapsed(mode, apply_started.elapsed()) { + Some(mode) => mode, + None => { + return ApplyReport { + target_resolved: true, + set_count: 0, + clear_count: 0, + command_success: false, + }; + } + }; let current_keys: Vec = output .pills .iter() .map(|pill| namespaced_key(&owner, &pill.key)) .collect(); let (clear_commands, set_commands) = - plan_apply_commands(output, &owner, &target, previous.as_ref()); + plan_apply_commands(output, &owner, &target, previous.as_ref(), &observed_keys); let clear_count = clear_commands.len(); let set_count = set_commands.len(); let mut all_commands = clear_commands; all_commands.extend(set_commands); - let command_success = execute_commands(&all_commands, mode); + let command_success = execute_commands(&all_commands, execute_mode); if command_success { write_applied_state( &owner, @@ -284,10 +300,7 @@ pub(crate) fn clear_owner( }; } let keys = previous.map(|state| state.keys).unwrap_or_default(); - let commands: Vec = keys - .iter() - .map(|key| plan_clear_status(key, &target)) - .collect(); + let commands = plan_clear_commands(&keys, &target, &[]); let clear_count = commands.len(); let command_success = execute_commands(&commands, mode); if command_success { @@ -307,11 +320,47 @@ pub(crate) fn clear_owner( } } +/// Clear every understatus Codex key visible on the target. This is the fallback cleanup path for +/// legacy per-session owners and for Codex clients that do not emit a session-end hook. +pub(crate) fn clear_all_codex(target: CmuxTarget, mode: ApplyMode) -> ApplyReport { + if mode.hook && !target.has_caller_target() { + return ApplyReport { + target_resolved: false, + set_count: 0, + clear_count: 0, + command_success: true, + }; + } + let clear_started = Instant::now(); + let observed_keys = read_target_status_keys(&target, sidebar_state_timeout_ms(mode)); + let execute_mode = match mode_after_elapsed(mode, clear_started.elapsed()) { + Some(mode) => mode, + None => { + return ApplyReport { + target_resolved: true, + set_count: 0, + clear_count: 0, + command_success: false, + }; + } + }; + let commands = plan_clear_commands(&[], &target, &observed_keys); + let clear_count = commands.len(); + let command_success = execute_commands(&commands, execute_mode); + ApplyReport { + target_resolved: true, + set_count: 0, + clear_count, + command_success, + } +} + fn plan_apply_commands( output: &CmuxStatusOutput, owner: &str, target: &CmuxTarget, previous: Option<&AppliedState>, + observed_keys: &[String], ) -> (Vec, Vec) { let current: BTreeSet = output .pills @@ -319,6 +368,7 @@ fn plan_apply_commands( .map(|pill| namespaced_key(owner, &pill.key)) .collect(); let mut clear = Vec::new(); + let mut seen_clear = BTreeSet::new(); if let Some(previous) = previous { let previous_keys: BTreeSet = previous.keys.iter().cloned().collect(); let stale: Vec = if previous.target == *target { @@ -327,9 +377,12 @@ fn plan_apply_commands( previous_keys.into_iter().collect() }; for key in stale { - clear.push(plan_clear_status(&key, &previous.target)); + push_clear_unique(&mut clear, &mut seen_clear, &key, &previous.target); } } + for key in stale_observed_keys(observed_keys, ¤t) { + push_clear_unique(&mut clear, &mut seen_clear, &key, target); + } let set = output .pills .iter() @@ -338,6 +391,97 @@ fn plan_apply_commands( (clear, set) } +fn plan_clear_commands( + owner_keys: &[String], + target: &CmuxTarget, + observed_keys: &[String], +) -> Vec { + let mut clear = Vec::new(); + let mut seen_clear = BTreeSet::new(); + for key in owner_keys + .iter() + .filter(|key| key.starts_with(STATUS_KEY_PREFIX)) + { + push_clear_unique(&mut clear, &mut seen_clear, key, target); + } + for key in stale_observed_keys(observed_keys, &BTreeSet::new()) { + push_clear_unique(&mut clear, &mut seen_clear, &key, target); + } + clear +} + +fn stale_observed_keys(observed_keys: &[String], keep: &BTreeSet) -> Vec { + observed_keys + .iter() + .filter(|key| key.starts_with(STATUS_KEY_PREFIX) && !keep.contains(*key)) + .cloned() + .collect() +} + +fn push_clear_unique( + clear: &mut Vec, + seen: &mut BTreeSet, + key: &str, + target: &CmuxTarget, +) { + let command = plan_clear_status(key, target); + let signature = command.args.join("\u{1f}"); + if seen.insert(signature) { + clear.push(command); + } +} + +fn read_target_status_keys(target: &CmuxTarget, timeout_ms: u64) -> Vec { + let mut args = vec!["sidebar-state".to_string()]; + target.append_target_args(&mut args); + let mut process = Command::new("cmux"); + process + .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .stdout(Stdio::piped()); + let Some(output) = command_output_with_timeout(&mut process, timeout_ms) else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + let text = String::from_utf8_lossy(&output.stdout); + parse_sidebar_status_keys(&text) +} + +fn sidebar_state_timeout_ms(mode: ApplyMode) -> u64 { + if mode.hook { + HOOK_SIDEBAR_STATE_TIMEOUT_MS.min(mode.timeout_ms) + } else { + mode.timeout_ms + } +} + +fn mode_after_elapsed(mut mode: ApplyMode, elapsed: Duration) -> Option { + let Some(total_timeout_ms) = mode.total_timeout_ms else { + return Some(mode); + }; + let elapsed_ms = elapsed.as_millis().try_into().unwrap_or(u64::MAX); + let remaining_ms = total_timeout_ms.checked_sub(elapsed_ms)?; + if remaining_ms == 0 { + return None; + } + mode.total_timeout_ms = Some(remaining_ms); + Some(mode) +} + +fn parse_sidebar_status_keys(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let line = line.trim_start(); + let (key, _) = line.split_once('=')?; + let key = key.trim(); + key.starts_with(STATUS_KEY_PREFIX).then(|| key.to_string()) + }) + .collect() +} + fn plan_set_status(owner: &str, pill: &Pill, target: &CmuxTarget) -> CmuxCommand { let mut args = vec![ "set-status".to_string(), @@ -472,8 +616,13 @@ mod tests { workspace: Some("workspace:1".to_string()), window: Some("window:2".to_string()), }; - let (_, set) = - plan_apply_commands(&output(&[("model", "gpt-5.5")]), "owner", &target, None); + let (_, set) = plan_apply_commands( + &output(&[("model", "gpt-5.5")]), + "owner", + &target, + None, + &[], + ); assert_eq!(set.len(), 1); assert_eq!(set[0].args[0], "set-status"); assert!(set[0].args.contains(&"--workspace".to_string())); @@ -516,6 +665,7 @@ mod tests { "owner", &target, Some(&previous), + &[], ); assert_eq!(set.len(), 1); assert_eq!(clear.len(), 1); @@ -523,6 +673,59 @@ mod tests { assert!(clear[0].args[1].ends_with(".ctx")); } + #[test] + fn plan_apply_clears_observed_legacy_session_keys() { + let target = CmuxTarget { + workspace: Some("workspace:1".to_string()), + window: None, + }; + let observed = vec![ + namespaced_key("old-session", "cpu"), + namespaced_key("old-session", "mem"), + namespaced_key("owner", "cpu"), + "other.tool.cpu".to_string(), + ]; + let (clear, set) = plan_apply_commands( + &output(&[("cpu", "cpu 10%")]), + "owner", + &target, + None, + &observed, + ); + assert_eq!(set.len(), 1); + let clear_keys: Vec = clear.iter().map(|cmd| cmd.args[1].clone()).collect(); + assert_eq!( + clear_keys, + vec![ + namespaced_key("old-session", "cpu"), + namespaced_key("old-session", "mem") + ] + ); + assert!(clear.iter().all(|cmd| cmd.args[0] == "clear-status")); + assert!(clear + .iter() + .all(|cmd| cmd.args.contains(&"--workspace".to_string()))); + } + + #[test] + fn parse_sidebar_status_keys_keeps_only_understatus_keys() { + let keys = parse_sidebar_status_keys( + r#" +status_count=3 + understatus.codex.old.cpu=cpu 31% color=#6D8296 priority=100 + build=ok color=#00ff00 priority=1 + understatus.codex.old.mem=mem 68% color=#A0A0A0 priority=90 +"#, + ); + assert_eq!( + keys, + vec![ + "understatus.codex.old.cpu".to_string(), + "understatus.codex.old.mem".to_string() + ] + ); + } + #[test] fn hook_mode_without_target_noops() { let report = apply_pills( diff --git a/src/codex_hooks.rs b/src/codex_hooks.rs index 7fe58aa..2f03eed 100644 --- a/src/codex_hooks.rs +++ b/src/codex_hooks.rs @@ -79,9 +79,6 @@ pub(crate) fn owner_key( target: &CmuxTarget, surface_id: Option<&str>, ) -> String { - if let Some(session_id) = &input.session_id { - return session_id.clone(); - } let surface = surface_id.filter(|surface| !surface.is_empty()); if let Some(surface) = surface { if target.workspace.is_some() || target.window.is_some() { @@ -94,6 +91,16 @@ pub(crate) fn owner_key( } return surface.to_string(); } + if target.workspace.is_some() || target.window.is_some() { + return format!( + "cmux-{}-{}", + target.workspace.as_deref().unwrap_or("workspace"), + target.window.as_deref().unwrap_or("window") + ); + } + if let Some(session_id) = &input.session_id { + return session_id.clone(); + } if let Some(turn_id) = &input.turn_id { return turn_id.clone(); } @@ -429,7 +436,7 @@ mod tests { } #[test] - fn owner_prefers_session_then_surface_target_then_turn() { + fn owner_prefers_surface_target_then_session_then_turn() { let target = CmuxTarget { workspace: Some("workspace:1".to_string()), window: None, @@ -442,7 +449,11 @@ mod tests { }; assert_eq!( owner_key(&with_session, &target, Some("surface")), - "session" + "surface-workspace:1-window" + ); + assert_eq!( + owner_key(&with_session, &target, None), + "cmux-workspace:1-window" ); let without_session = HookInput { session_id: None, diff --git a/src/main.rs b/src/main.rs index dc31518..47a7a9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -508,17 +508,11 @@ fn run_cmux_clear_codex(args: &[String]) -> ExitCode { window: parsed.window, }; let target = cmux::resolve_target(explicit_target, false); - let fallback = codex_hooks::HookInput { - hook_event_name: "Manual".to_string(), - session_id: None, - turn_id: None, - cwd: current_dir_string(), + let report = if let Some(owner) = parsed.session_key { + cmux::clear_owner(&owner, target, cmux::ApplyMode::manual(parsed.quiet)) + } else { + cmux::clear_all_codex(target, cmux::ApplyMode::manual(parsed.quiet)) }; - let surface = std::env::var("CMUX_SURFACE_ID").ok(); - let owner = parsed - .session_key - .unwrap_or_else(|| codex_hooks::owner_key(&fallback, &target, surface.as_deref())); - let report = cmux::clear_owner(&owner, target, cmux::ApplyMode::manual(parsed.quiet)); if !parsed.quiet { eprintln!( "understatus: cmux clear-codex clear={} target_resolved={} success={}", diff --git a/tests/oneline.rs b/tests/oneline.rs index 04619d5..20003d6 100644 --- a/tests/oneline.rs +++ b/tests/oneline.rs @@ -767,6 +767,222 @@ exit 0 let _ = std::fs::remove_file(&record_path); } +/// Codex + CMUX bridge: live `cmux sidebar-state`에 남아 있는 이전 session_id owner key를 +/// 다음 apply가 먼저 clear해야 한다. 그렇지 않으면 CMUX가 priority 높은 `*.cpu`만 여러 개 +/// 보여주고 model/mem/git이 밀리는 회귀가 생긴다. +#[cfg(unix)] +#[test] +fn cmux_apply_codex_clears_observed_legacy_session_keys() { + use std::os::unix::fs::PermissionsExt; + + let token = unique_token(); + let home = make_isolated_home("cmux-apply-clears-observed"); + let config = write_deterministic_core_config("cmux-apply-clears-observed"); + let bin_dir = std::env::temp_dir().join(format!("understatus-fake-cmux-clear-bin-{token}")); + let record_path = + std::env::temp_dir().join(format!("understatus-fake-cmux-clear-record-{token}.txt")); + std::fs::create_dir_all(&bin_dir).expect("fake cmux bin dir 생성 실패"); + let cmux_path = bin_dir.join("cmux"); + std::fs::write( + &cmux_path, + r##"#!/bin/sh +cmd="$1" +{ + printf '%s' "$cmd" + shift + for arg in "$@"; do + printf '\t%s' "$arg" + done + printf '\n' +} >> "$CMUX_RECORD" +if [ "$cmd" = "sidebar-state" ]; then + cat <<'STATE' +status_count=4 + understatus.codex.old_session.cpu=cpu 99% color=#6D8296 priority=100 + understatus.codex.old_session.mem=mem 88% color=#A0A0A0 priority=90 + understatus.codex.it_owner.cpu=cpu 1% color=#6D8296 priority=100 + build=ok color=#00ff00 priority=1 +STATE +fi +exit 0 +"##, + ) + .expect("fake cmux 작성 실패"); + let mut perms = std::fs::metadata(&cmux_path) + .expect("fake cmux metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&cmux_path, perms).expect("fake cmux chmod 실패"); + + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir.clone()]; + paths.extend(std::env::split_paths(&old_path)); + let path = std::env::join_paths(paths).expect("PATH join 실패"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_understatus")) + .args([ + "cmux", + "apply-codex", + "--workspace", + "workspace:test", + "--window", + "window:test", + "--session-key", + "it_owner", + "--quiet", + ]) + .env("NO_COLOR", "1") + .env("UNDERSTATUS_CONFIG", &config) + .env("HOME", &home) + .env("PATH", &path) + .env("CMUX_RECORD", &record_path) + .env_remove("CMUX_WORKSPACE_ID") + .env_remove("CMUX_WINDOW_ID") + .env_remove("CMUX_WINDOW") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("understatus cmux apply-codex 실행 실패"); + child + .stdin + .take() + .expect("stdin 핸들 없음") + .write_all(br#"{"agent":"codex","cwd":"/tmp/cmux-apply-clears-observed"}"#) + .expect("stdin 쓰기 실패"); + let output = child.wait_with_output().expect("자식 종료 대기 실패"); + assert!( + output.status.success(), + "apply-codex는 stale clear + set-status 성공 시 성공해야 함: {:?}, stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + + let record = std::fs::read_to_string(&record_path).expect("fake cmux 호출 기록"); + assert!( + record.contains("sidebar-state\t--workspace\tworkspace:test\t--window\twindow:test"), + "현재 CMUX status를 먼저 조회해야 함: {record:?}" + ); + assert!( + record.contains("clear-status\tunderstatus.codex.old_session.cpu"), + "이전 session owner cpu key clear 필요: {record:?}" + ); + assert!( + record.contains("clear-status\tunderstatus.codex.old_session.mem"), + "이전 session owner mem key clear 필요: {record:?}" + ); + assert!( + !record.contains("clear-status\tunderstatus.codex.it_owner.cpu"), + "현재 owner cpu key는 유지해야 함: {record:?}" + ); + assert!( + record.contains("set-status\tunderstatus.codex.it_owner.cpu"), + "현재 owner cpu set-status 필요: {record:?}" + ); + + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_file(&config); + let _ = std::fs::remove_dir_all(&bin_dir); + let _ = std::fs::remove_file(&record_path); +} + +/// `clear-codex`를 session key 없이 실행해도 현재 CMUX sidebar에 관측되는 understatus key 전체를 +/// clear해야 한다. 종료 hook이 없는 Codex 버전에서 남은 cpu/mem을 수동/후속 cleanup으로 제거하는 경로다. +#[cfg(unix)] +#[test] +fn cmux_clear_codex_without_session_key_clears_observed_understatus_keys() { + use std::os::unix::fs::PermissionsExt; + + let token = unique_token(); + let home = make_isolated_home("cmux-clear-observed"); + let bin_dir = std::env::temp_dir().join(format!("understatus-fake-cmux-clear-all-{token}")); + let record_path = std::env::temp_dir().join(format!( + "understatus-fake-cmux-clear-all-record-{token}.txt" + )); + std::fs::create_dir_all(&bin_dir).expect("fake cmux bin dir 생성 실패"); + let cmux_path = bin_dir.join("cmux"); + std::fs::write( + &cmux_path, + r##"#!/bin/sh +cmd="$1" +{ + printf '%s' "$cmd" + shift + for arg in "$@"; do + printf '\t%s' "$arg" + done + printf '\n' +} >> "$CMUX_RECORD" +if [ "$cmd" = "sidebar-state" ]; then + cat <<'STATE' +status_count=3 + understatus.codex.session_a.cpu=cpu 31% color=#6D8296 priority=100 + understatus.codex.session_a.mem=mem 68% color=#A0A0A0 priority=90 + other.tool=ok color=#00ff00 priority=1 +STATE +fi +exit 0 +"##, + ) + .expect("fake cmux 작성 실패"); + let mut perms = std::fs::metadata(&cmux_path) + .expect("fake cmux metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&cmux_path, perms).expect("fake cmux chmod 실패"); + + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir.clone()]; + paths.extend(std::env::split_paths(&old_path)); + let path = std::env::join_paths(paths).expect("PATH join 실패"); + + let output = Command::new(env!("CARGO_BIN_EXE_understatus")) + .args([ + "cmux", + "clear-codex", + "--workspace", + "workspace:test", + "--window", + "window:test", + "--quiet", + ]) + .env("NO_COLOR", "1") + .env("HOME", &home) + .env("PATH", &path) + .env("CMUX_RECORD", &record_path) + .env_remove("CMUX_WORKSPACE_ID") + .env_remove("CMUX_WINDOW_ID") + .env_remove("CMUX_WINDOW") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("understatus cmux clear-codex 실행 실패"); + assert!( + output.status.success(), + "clear-codex는 observed understatus clear 성공 시 성공해야 함: {:?}, stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + + let record = std::fs::read_to_string(&record_path).expect("fake cmux 호출 기록"); + assert!( + record.contains("clear-status\tunderstatus.codex.session_a.cpu"), + "관측된 cpu key clear 필요: {record:?}" + ); + assert!( + record.contains("clear-status\tunderstatus.codex.session_a.mem"), + "관측된 mem key clear 필요: {record:?}" + ); + assert!( + !record.contains("clear-status\tother.tool"), + "다른 도구 key는 건드리면 안 됨: {record:?}" + ); + + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&bin_dir); + let _ = std::fs::remove_file(&record_path); +} + // ===== E2E: Codex 세션 심층판독(spec §11 E2E, AC1/AC2) ===== /// 넓은 max_width(codex 풀 프로필이 폭 트림으로 잘리지 않게)를 가진 임시 config를 만든다.