fix(release): tighten 0.8.52 home and cost accounting

This commit is contained in:
Hunter B
2026-06-03 03:17:57 -07:00
parent c8ce2b8e92
commit b965d2ecd5
6 changed files with 126 additions and 17 deletions
+11 -3
View File
@@ -47,14 +47,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`POST /messages` requests on stale legacy SSE sessions now trigger the same
reconnect-and-retry path as closed SSE streams, removing a release-gate flake
and matching the intended recovery behavior (#2597).
- **Cache-hit cost accounting uses one telemetry source.** Mixed DeepSeek
`prompt_cache_hit_tokens` and OpenAI-style `cached_tokens` usage payloads no
longer infer cache misses from the wrong hit count, avoiding inflated TUI cost
estimates on cached DeepSeek turns (#2567, #2609).
- **Cygwin/MSYS2 config paths honor exported `$HOME`.** CodeWhale and legacy
DeepSeek config roots now prefer a non-empty `$HOME` before falling back to the
platform home resolver, while `CODEWHALE_HOME` remains the strongest explicit
override (#2369, #2610).
### Community
Thanks to **@xyuai** (#2587), **@IcedOranges** (#2584), **@BH8GCJ** (#2588),
**@shenjackyuanjie** (#2618, #2619), **@idling11** (#2606, #2616),
**@AresNing** (#2578), **@gordonlu**, **@encyc**, and **@simuusang** (#2603,
#2620) for reports, patches, retesting, and release-stabilization signals that
shaped this pass.
**@AresNing** (#2578), **@caiyilian** (#2567), **@buko** (#2369),
**@gordonlu**, **@encyc**, and **@simuusang** (#2603, #2620) for reports,
patches, retesting, and release-stabilization signals that shaped this pass.
## [0.8.51] - 2026-06-02
+79 -6
View File
@@ -2038,7 +2038,7 @@ pub fn codewhale_home() -> Result<PathBuf> {
return Ok(PathBuf::from(trimmed));
}
}
let home = dirs::home_dir().context("failed to resolve home directory")?;
let home = effective_home_dir().context("failed to resolve home directory")?;
Ok(home.join(CODEWHALE_APP_DIR))
}
@@ -2046,10 +2046,17 @@ pub fn codewhale_home() -> Result<PathBuf> {
///
/// Always returns the legacy path regardless of whether it exists.
pub fn legacy_deepseek_home() -> Result<PathBuf> {
let home = dirs::home_dir().context("failed to resolve home directory")?;
let home = effective_home_dir().context("failed to resolve home directory")?;
Ok(home.join(LEGACY_APP_DIR))
}
fn effective_home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(dirs::home_dir)
}
/// Resolve a state subdirectory, preferring the CodeWhale root if
/// it already exists, otherwise falling back to the legacy root.
///
@@ -2507,7 +2514,9 @@ mod tests {
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
@@ -3485,6 +3494,73 @@ unix_socket_path = "/tmp/cw-hooks.sock"
);
}
#[test]
fn app_homes_prefer_home_env_before_platform_home_fallback() {
let _lock = env_lock();
struct HomeEnvGuard {
home: Option<OsString>,
userprofile: Option<OsString>,
codewhale_home: Option<OsString>,
}
impl Drop for HomeEnvGuard {
fn drop(&mut self) {
// Safety: test-only environment mutation is serialized by env_lock().
unsafe {
match self.home.take() {
Some(value) => env::set_var("HOME", value),
None => env::remove_var("HOME"),
}
match self.userprofile.take() {
Some(value) => env::set_var("USERPROFILE", value),
None => env::remove_var("USERPROFILE"),
}
match self.codewhale_home.take() {
Some(value) => env::set_var("CODEWHALE_HOME", value),
None => env::remove_var("CODEWHALE_HOME"),
}
}
}
}
let home =
std::env::temp_dir().join(format!("codewhale-config-home-env-{}", std::process::id()));
let userprofile = std::env::temp_dir().join(format!(
"codewhale-config-userprofile-{}",
std::process::id()
));
let _env = HomeEnvGuard {
home: env::var_os("HOME"),
userprofile: env::var_os("USERPROFILE"),
codewhale_home: env::var_os("CODEWHALE_HOME"),
};
// Safety: test-only environment mutation is serialized by env_lock().
unsafe {
env::set_var("HOME", &home);
env::set_var("USERPROFILE", &userprofile);
env::remove_var("CODEWHALE_HOME");
}
assert_eq!(
codewhale_home().expect("codewhale home"),
home.join(CODEWHALE_APP_DIR)
);
assert_eq!(
legacy_deepseek_home().expect("legacy home"),
home.join(LEGACY_APP_DIR)
);
let explicit = std::env::temp_dir().join(format!(
"codewhale-config-explicit-home-{}",
std::process::id()
));
// Safety: test-only environment mutation is serialized by env_lock().
unsafe {
env::set_var("CODEWHALE_HOME", &explicit);
}
assert_eq!(codewhale_home().expect("explicit home"), explicit);
}
#[test]
fn migrate_config_reports_copied_legacy_path() {
let _lock = env_lock();
@@ -3550,9 +3626,6 @@ unix_socket_path = "/tmp/cw-hooks.sock"
"codewhale-config-migration-{}-{unique}",
std::process::id()
));
#[cfg(windows)]
let legacy_dir = legacy_deepseek_home().expect("legacy home");
#[cfg(not(windows))]
let legacy_dir = home.join(LEGACY_APP_DIR);
let primary_dir = home.join(CODEWHALE_APP_DIR);
let legacy_config = legacy_dir.join(CONFIG_FILE_NAME);
+11 -3
View File
@@ -47,14 +47,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`POST /messages` requests on stale legacy SSE sessions now trigger the same
reconnect-and-retry path as closed SSE streams, removing a release-gate flake
and matching the intended recovery behavior (#2597).
- **Cache-hit cost accounting uses one telemetry source.** Mixed DeepSeek
`prompt_cache_hit_tokens` and OpenAI-style `cached_tokens` usage payloads no
longer infer cache misses from the wrong hit count, avoiding inflated TUI cost
estimates on cached DeepSeek turns (#2567, #2609).
- **Cygwin/MSYS2 config paths honor exported `$HOME`.** CodeWhale and legacy
DeepSeek config roots now prefer a non-empty `$HOME` before falling back to the
platform home resolver, while `CODEWHALE_HOME` remains the strongest explicit
override (#2369, #2610).
### Community
Thanks to **@xyuai** (#2587), **@IcedOranges** (#2584), **@BH8GCJ** (#2588),
**@shenjackyuanjie** (#2618, #2619), **@idling11** (#2606, #2616),
**@AresNing** (#2578), **@gordonlu**, **@encyc**, and **@simuusang** (#2603,
#2620) for reports, patches, retesting, and release-stabilization signals that
shaped this pass.
**@AresNing** (#2578), **@caiyilian** (#2567), **@buko** (#2369),
**@gordonlu**, **@encyc**, and **@simuusang** (#2603, #2620) for reports,
patches, retesting, and release-stabilization signals that shaped this pass.
## [0.8.51] - 2026-06-02
+17 -1
View File
@@ -1275,7 +1275,7 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage {
let prompt_cache_miss_tokens = usage
.and_then(|u| u.get("prompt_cache_miss_tokens"))
.and_then(Value::as_u64)
.or_else(|| cached_tokens.map(|cached| input_tokens.saturating_sub(cached)))
.or_else(|| prompt_cache_hit_tokens.map(|hit| input_tokens.saturating_sub(u64::from(hit))))
.map(|v| v as u32);
let reasoning_tokens = reasoning_tokens_raw.map(|v| v as u32);
@@ -3078,6 +3078,22 @@ mod tests {
assert_eq!(usage.prompt_cache_miss_tokens, Some(1000));
}
#[test]
fn parse_usage_infers_cache_miss_from_selected_hit_source() {
let usage = parse_usage(Some(&json!({
"prompt_tokens": 4000,
"completion_tokens": 20,
"prompt_cache_hit_tokens": 3000,
"prompt_tokens_details": {
"cached_tokens": 1000
}
})));
assert_eq!(usage.input_tokens, 4000);
assert_eq!(usage.prompt_cache_hit_tokens, Some(3000));
assert_eq!(usage.prompt_cache_miss_tokens, Some(1000));
}
#[test]
fn sanitize_thinking_mode_counts_reasoning_replay_across_assistant_turns() {
// Multi-turn body that mimics two prior tool-calling rounds: each
+4
View File
@@ -161,6 +161,10 @@ pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) -
}
/// Calculate cost for a turn in both official currencies.
///
/// This legacy helper has no cache telemetry, so it prices all input tokens as
/// cache misses. Prefer [`calculate_turn_cost_estimate_from_usage`] when the
/// provider returned usage details.
#[must_use]
pub fn calculate_turn_cost_estimate(
model: &str,
+4 -4
View File
@@ -965,7 +965,7 @@ async fn test_running_count_counts_running_agents_until_status_reconciles() {
#[tokio::test]
async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() {
let mut manager = SubAgentManager::new(PathBuf::from("."), 1)
.with_running_heartbeat_timeout(Duration::from_secs(300));
.with_running_heartbeat_timeout(Duration::from_millis(1));
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
"test_agent_stale".to_string(),
@@ -978,12 +978,12 @@ async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() {
input_tx,
"boot_test".to_string(),
);
agent.last_activity_at = instant_from_duration(Duration::from_secs(600));
agent.task_handle = Some(tokio::spawn(async {
tokio::time::sleep(Duration::from_secs(60)).await;
}));
let agent_id = agent.id.clone();
manager.agents.insert(agent_id.clone(), agent);
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(
manager.running_count(),
@@ -1046,7 +1046,7 @@ async fn cleanup_keeps_recent_running_agent() {
#[tokio::test]
async fn touch_refreshes_stale_running_agent_heartbeat() {
let mut manager = SubAgentManager::new(PathBuf::from("."), 1)
.with_running_heartbeat_timeout(Duration::from_secs(300));
.with_running_heartbeat_timeout(Duration::from_millis(1));
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
"test_agent_touched".to_string(),
@@ -1059,12 +1059,12 @@ async fn touch_refreshes_stale_running_agent_heartbeat() {
input_tx,
"boot_test".to_string(),
);
agent.last_activity_at = instant_from_duration(Duration::from_secs(600));
agent.task_handle = Some(tokio::spawn(async {
tokio::time::sleep(Duration::from_secs(60)).await;
}));
let agent_id = agent.id.clone();
manager.agents.insert(agent_id.clone(), agent);
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(manager.running_count(), 0);
assert!(manager.touch(&agent_id));