From 4703aa2e05ff84b47ef19f63be7ca5c2c84a06d2 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 16:47:12 -0400 Subject: [PATCH 1/7] fix: make managed-agent spawn and teardown portable to Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several spawn, teardown, and shim paths were #[cfg(unix)]-only and silently no-op (or returned a falsey stub) on Windows, breaking the desktop build four ways. All four are in the same Windows-portability theme. #2/#4 (MCP PermissionDenied on C:\Windows): buzz-agent spawns the MCP with env_clear() then re-adds only an allowlist that omitted the Windows temp/profile vars. Stripped of TMP/TEMP/USERPROFILE, std::env::temp_dir() falls back to C:\Windows and Shim::install() can't write there. Pass the Windows vars through (cfg-gated). The shim itself had two more Unix-isms in the same install path: the PATH separator was hardcoded ':' (now std::env::join_paths) and the non-unix multicall copies dropped the .exe extension PATHEXT needs to exec them. Multicall dispatch now matches on file_stem() so the .exe copies route correctly. #1 (stray console + orphaned process tree): the buzz-acp child spawned with no CREATE_NO_WINDOW (console popped) and the non-unix stop path was Child::kill(), which kills only the harness and orphans the 24 workers + MCP servers. A Win32 Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE now owns the tree and reaps it when the handle drops — the Windows mirror of the Unix process-group teardown. The after-restart path (PID only, no handle) falls back to taskkill /T. The Windows primitives live in a new process_lifecycle module. #4 (program not found): the create-path default agent command was the bare `goose`, not on PATH on a stock Windows install. It now catalog-resolves the bundled `buzz-agent`, the same shape mesh_llm::preset already uses. #3 (updater silently does nothing): when the updater plugin is unavailable the hook collapsed to `idle`, re-rendering the same button — indistinguishable from a no-op. It now sets a visible `unavailable` state and warns to the log so the firing branch is diagnosable on Will's Windows build. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-agent/src/mcp.rs | 26 ++++ crates/buzz-dev-mcp/src/lib.rs | 2 +- crates/buzz-dev-mcp/src/shim.rs | 16 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/commands/agents.rs | 7 +- .../src-tauri/src/managed_agents/discovery.rs | 32 +++- desktop/src-tauri/src/managed_agents/mod.rs | 4 + .../src/managed_agents/process_lifecycle.rs | 141 ++++++++++++++++++ .../src-tauri/src/managed_agents/restore.rs | 8 +- .../src-tauri/src/managed_agents/runtime.rs | 71 ++++++--- desktop/src-tauri/src/managed_agents/types.rs | 7 +- .../src/features/settings/UpdateChecker.tsx | 15 ++ .../features/settings/hooks/use-updater.ts | 7 +- 13 files changed, 300 insertions(+), 38 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/process_lifecycle.rs diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 2c39de270..6a7b61dca 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -60,6 +60,13 @@ const PASSTHROUGH_ENV: &[&str] = &[ "BUZZ_RELAY_URL", ]; +// Windows has no $TMPDIR/$HOME. TMP/TEMP/USERPROFILE are what +// std::env::temp_dir() consults — without them it falls back to C:\Windows, +// which child processes can't write to (PermissionDenied). USERPROFILE is the +// always-set floor. LOCALAPPDATA/APPDATA carry child-tool config (git, etc.). +#[cfg(windows)] +const PASSTHROUGH_ENV_WINDOWS: &[&str] = &["TMP", "TEMP", "USERPROFILE", "LOCALAPPDATA", "APPDATA"]; + type Client = RunningService; #[derive(Clone)] @@ -697,6 +704,12 @@ async fn spawn_one( cmd.env(k, v); } } + #[cfg(windows)] + for k in PASSTHROUGH_ENV_WINDOWS { + if let Ok(v) = std::env::var(k) { + cmd.env(k, v); + } + } for (k, v) in &spec.env { cmd.env(k, v); } @@ -966,6 +979,19 @@ mod content_tests { use super::*; use rmcp::model::Content; + #[cfg(windows)] + #[test] + fn windows_passthrough_includes_temp_dir_vars() { + // std::env::temp_dir() consults these in order; without them it falls + // back to C:\Windows, which children can't write to. + for var in ["TMP", "TEMP", "USERPROFILE"] { + assert!( + PASSTHROUGH_ENV_WINDOWS.contains(&var), + "{var} must pass through or temp_dir() falls back to C:\\Windows" + ); + } + } + #[test] fn tool_result_content_preserves_images() { let blocks = vec![ diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs index edc9b9fb7..ab4f6ad4c 100644 --- a/crates/buzz-dev-mcp/src/lib.rs +++ b/crates/buzz-dev-mcp/src/lib.rs @@ -137,7 +137,7 @@ impl ServerHandler for DevMcp { pub fn run() -> Result<(), Box> { let argv0 = std::env::args().next().unwrap_or_default(); let cmd = Path::new(&argv0) - .file_name() + .file_stem() .and_then(|n| n.to_str()) .unwrap_or("") .to_ascii_lowercase(); diff --git a/crates/buzz-dev-mcp/src/shim.rs b/crates/buzz-dev-mcp/src/shim.rs index 0ecef5061..36a07a18c 100644 --- a/crates/buzz-dev-mcp/src/shim.rs +++ b/crates/buzz-dev-mcp/src/shim.rs @@ -40,12 +40,13 @@ impl Shim { } let original = std::env::var_os("PATH").unwrap_or_default(); - let mut new_path = std::ffi::OsString::from(dir.path()); - if !original.is_empty() { - new_path.push(":"); - new_path.push(&original); - } - let path_env = new_path.to_string_lossy().into_owned(); + let mut entries = vec![PathBuf::from(dir.path())]; + entries.extend(std::env::split_paths(&original)); + // join_paths uses the platform separator (':' on Unix, ';' on Windows). + let path_env = std::env::join_paths(entries) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))? + .to_string_lossy() + .into_owned(); // Read and unconditionally remove NOSTR_PRIVATE_KEY from this process's // env. The key must never leak to child processes regardless of whether @@ -234,6 +235,9 @@ fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> { #[cfg(not(unix))] fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> { + // No symlinks without elevation on Windows; copy instead. The target needs + // a .exe extension or PATH lookup (via PATHEXT) won't treat it as runnable. + let dst = dst.with_extension("exe"); std::fs::copy(src, dst).map(|_| ()) } diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 548094c94..0c8b22644 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -33,7 +33,7 @@ ctrlc = { version = "3", features = ["termination"] } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback"] } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Foundation"] } [dependencies] atomic-write-file = "0.3" diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 37bad60f8..dc64dc826 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -12,8 +12,7 @@ use crate::{ sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, - DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -456,8 +455,8 @@ pub async fn create_managed_agent( .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .unwrap_or(DEFAULT_AGENT_COMMAND) - .to_string(); + .map(str::to_string) + .unwrap_or_else(crate::managed_agents::default_agent_command); let agent_args = normalize_agent_args( &agent_command, input diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 88f5c2d6a..21ca25d58 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -228,6 +228,21 @@ pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRunti KNOWN_ACP_RUNTIMES.iter().find(|p| p.id == id) } +/// The agent command a freshly-created agent defaults to when the create +/// request supplies none. Resolves the bundled `buzz-agent` from the catalog — +/// the same shape `mesh_llm::preset` uses — so the default can't drift from the +/// provider definition. Falls back to the id if the catalog entry is missing. +/// +/// The previous default was the bare global `goose`, which is not on PATH on a +/// stock Windows install: every worker failed with `program not found`. The +/// bundled `buzz-agent` ships with the app and resolves on every platform. +pub fn default_agent_command() -> String { + known_acp_runtime_exact("buzz-agent") + .and_then(|p| p.commands.first().copied()) + .unwrap_or("buzz-agent") + .to_string() +} + fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), @@ -562,8 +577,9 @@ mod tests { use std::path::PathBuf; use super::{ - classify_runtime, find_via_login_shell, managed_agent_avatar_url, normalize_agent_args, - BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, + classify_runtime, default_agent_command, find_via_login_shell, managed_agent_avatar_url, + normalize_agent_args, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, + GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -599,6 +615,18 @@ mod tests { assert!(managed_agent_avatar_url("custom-agent").is_none()); } + #[test] + fn default_agent_command_resolves_bundled_buzz_agent() { + // The create-path default must be the bundled buzz-agent, never the + // bare `goose` that isn't on PATH on a stock Windows install. + assert_eq!(default_agent_command(), "buzz-agent"); + // And buzz-agent takes no `acp` arg — confirm no arg leakage from the default. + assert_eq!( + normalize_agent_args(&default_agent_command(), vec!["acp".into()]), + Vec::::new() + ); + } + #[test] fn normalizes_claude_and_codex_args_to_empty() { assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index f10376a27..d22598ed8 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -5,6 +5,8 @@ mod nest; mod persona_avatars; mod persona_card; mod personas; +#[cfg(windows)] +mod process_lifecycle; #[cfg(feature = "mesh-llm")] mod relay_mesh; mod restore; @@ -20,6 +22,8 @@ pub use env_vars::*; pub use nest::*; pub use persona_card::*; pub use personas::*; +#[cfg(windows)] +pub use process_lifecycle::*; #[cfg(feature = "mesh-llm")] pub use relay_mesh::*; pub use restore::*; diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs new file mode 100644 index 000000000..71e386deb --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -0,0 +1,141 @@ +//! Windows process-tree lifecycle primitives for managed agents. +//! +//! The Unix teardown uses `process_group(0)` + group signals (in `runtime.rs`). +//! Windows has no process groups, so the harness's 24 agent workers + MCP +//! servers are reaped two ways here: +//! - [`JobHandle`] / [`create_job_for_child`] — the in-process stop path. A +//! Job Object owns the tree and `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` kills +//! it when the handle drops. +//! - [`taskkill_tree`] — the after-restart path, where only the PID survives +//! in the record and no job handle is available. +//! +//! This module is `#[cfg(windows)]`-only; nothing here compiles on other +//! platforms. + +use windows_sys::Win32::Foundation::HANDLE; + +/// Win32 Job Object that owns the harness process and (via Windows' default +/// child-inheritance) every process it spawns. Dropping the handle with +/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set kills the whole tree — the Windows +/// mirror of the Unix `process_group(0)` + group-signal teardown. This is what +/// guarantees the 24 agent workers + MCP servers die when we stop or when the +/// app exits, instead of being orphaned by a bare `Child::kill()`. +pub struct JobHandle(HANDLE); + +// The handle is owned exclusively by this wrapper; moving it across threads is +// sound (the spawn path in restore.rs runs in a thread scope). +unsafe impl Send for JobHandle {} + +impl std::fmt::Debug for JobHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("JobHandle(..)") + } +} + +impl Drop for JobHandle { + fn drop(&mut self) { + // KILL_ON_JOB_CLOSE means the tree dies when the LAST handle closes. + // We hold the only handle (not inheritable), so this reaps the tree. + unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) }; + } +} + +/// Create a Job Object, assign `pid` to it, and configure it to kill the whole +/// tree when the returned handle is dropped. Returns `None` on any failure so +/// the caller can fall back to `Child::kill()` — a degraded teardown beats a +/// failed spawn. +/// +/// Assignment happens immediately after spawn. The harness connects to the +/// relay before it spawns any agent workers, so the tiny window between spawn +/// and assignment never contains a grandchild; once assigned, Windows places +/// every subsequently-spawned descendant in the job automatically. +fn create_job_for_child(pid: u32) -> Option { + use std::ptr::null; + use windows_sys::Win32::Foundation::{CloseHandle, FALSE}; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE, + }; + + unsafe { + let job = CreateJobObjectW(null(), null()); + if job.is_null() { + return None; + } + + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let ok = SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + &info as *const _ as *const _, + std::mem::size_of::() as u32, + ); + if ok == FALSE { + CloseHandle(job); + return None; + } + + let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, FALSE, pid); + if process.is_null() { + CloseHandle(job); + return None; + } + let assigned = AssignProcessToJobObject(job, process); + CloseHandle(process); + if assigned == FALSE { + CloseHandle(job); + return None; + } + + Some(JobHandle(job)) + } +} + +/// Kill the entire process tree rooted at `pid` via `taskkill /T`, the closest +/// equivalent to the Unix process-group kill. Used on the after-restart path +/// where no job handle survived. `CREATE_NO_WINDOW` keeps taskkill's own +/// console from flashing. +pub fn taskkill_tree(pid: u32) -> Result<(), String> { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let status = std::process::Command::new("taskkill") + .args(["/T", "/F", "/PID", &pid.to_string()]) + .creation_flags(CREATE_NO_WINDOW) + .status() + .map_err(|error| format!("failed to run taskkill for pid {pid}: {error}"))?; + if status.success() { + Ok(()) + } else { + Err(format!( + "taskkill exited with status {status} for pid {pid}" + )) + } +} + +/// Assign a freshly-spawned harness `child` to a Job Object and package it into +/// a [`ManagedAgentProcess`]. On job-assignment failure the process is still +/// returned with `job: None` — teardown then falls back to `Child::kill()`, +/// which kills only the harness (a degraded teardown beats a failed spawn). +pub fn finish_spawn( + child: std::process::Child, + log_path: std::path::PathBuf, + agent_name: &str, +) -> super::ManagedAgentProcess { + let job = create_job_for_child(child.id()); + if job.is_none() { + eprintln!( + "buzz-desktop: failed to assign agent {agent_name} to a Job Object; \ + teardown will fall back to killing only the harness process" + ); + } + super::ManagedAgentProcess { + child, + log_path, + job, + } +} diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 2bad0fd36..3469dc80d 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -9,7 +9,7 @@ use crate::util; use std::sync::atomic::{AtomicBool, Ordering}; use tauri::Manager; -type SpawnResult = Result<(std::process::Child, std::path::PathBuf), String>; +type SpawnResult = Result; type AgentSpawnResult = (String, SpawnResult); /// Restore managed agents that were running before the app was closed. @@ -176,15 +176,15 @@ pub async fn restore_managed_agents_on_launch( Err(_) => continue, }; match result { - Ok((child, log_path)) => { + Ok(process) => { let now = util::now_iso(); record.updated_at = now.clone(); - record.runtime_pid = Some(child.id()); + record.runtime_pid = Some(process.child.id()); record.last_started_at = Some(now); record.last_stopped_at = None; record.last_exit_code = None; record.last_error = None; - runtimes.insert(pubkey.clone(), ManagedAgentProcess { child, log_path }); + runtimes.insert(pubkey.clone(), process); successfully_spawned.push(pubkey); } Err(error) => { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index d412e790e..328b888fd 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -284,9 +284,17 @@ pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { Ok(()) } -#[cfg(not(unix))] +#[cfg(windows)] +pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { + // No job handle is available on this path (e.g. after an app restart, when + // we only recovered the PID from the record), so fall back to taskkill on + // the whole tree. + super::process_lifecycle::taskkill_tree(pid) +} + +#[cfg(not(any(unix, windows)))] pub(crate) fn terminate_process(_pid: u32) -> Result<(), String> { - Err("managed agent shutdown after app restart is only supported on Unix".to_string()) + Err("managed agent shutdown after app restart is not supported on this platform".to_string()) } /// Send SIGTERM to all given PIDs (as process groups), wait, then SIGKILL @@ -1473,7 +1481,7 @@ pub fn spawn_agent_child( app: &AppHandle, record: &ManagedAgentRecord, owner_hex: Option<&str>, -) -> Result<(std::process::Child, std::path::PathBuf), String> { +) -> Result { let log_path = managed_agent_log_path(app, &record.pubkey)?; append_log_marker( &log_path, @@ -1516,22 +1524,25 @@ pub fn spawn_agent_child( // - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/) // - runtimes (node, python, etc.) via login shell PATH let augmented_path = { - let mut parts: Vec = Vec::new(); + let mut parts: Vec = Vec::new(); if let Some(home) = dirs::home_dir() { - parts.push(home.join(".local").join("bin").display().to_string()); + parts.push(home.join(".local").join("bin")); } if let Ok(exe) = std::env::current_exe() { if let Some(parent) = exe.parent() { - parts.push(parent.display().to_string()); + parts.push(parent.to_path_buf()); } } if let Some(shell_path) = login_shell_path() { - parts.push(shell_path); + parts.push(std::path::PathBuf::from(shell_path)); } if parts.is_empty() { None } else { - Some(parts.join(":")) + // join_paths uses the platform separator (':' on Unix, ';' on Windows). + std::env::join_paths(parts) + .ok() + .map(|s| s.to_string_lossy().into_owned()) } }; @@ -1730,6 +1741,15 @@ pub fn spawn_agent_child( use std::os::unix::process::CommandExt; command.process_group(0); } + // Windows: suppress the harness console window. Without this a bare + // terminal pops for buzz-acp.exe and lingers (the app itself sets + // windows_subsystem="windows", but the spawned child does not inherit it). + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } let child = command.spawn().map_err(|error| { format!( @@ -1741,7 +1761,16 @@ pub fn spawn_agent_child( let _ = super::write_agent_pid_file(app, &record.pubkey, child.id()); - Ok((child, log_path)) + // Windows: assign the harness to a Job Object so its whole tree dies with + // the handle. The Unix process-group equivalent is set above. + #[cfg(windows)] + return Ok(super::process_lifecycle::finish_spawn( + child, + log_path, + &record.name, + )); + #[cfg(not(windows))] + Ok(crate::managed_agents::ManagedAgentProcess { child, log_path }) } fn child_rust_log_filter() -> String { @@ -1798,20 +1827,17 @@ pub fn start_managed_agent_process( record.runtime_pid = None; } - let (child, log_path) = spawn_agent_child(app, record, owner_hex)?; + let process = spawn_agent_child(app, record, owner_hex)?; let now = now_iso(); record.updated_at = now.clone(); - record.runtime_pid = Some(child.id()); + record.runtime_pid = Some(process.child.id()); record.last_started_at = Some(now); record.last_stopped_at = None; record.last_exit_code = None; record.last_error = None; - runtimes.insert( - record.pubkey.clone(), - ManagedAgentProcess { child, log_path }, - ); + runtimes.insert(record.pubkey.clone(), process); Ok(()) } @@ -1838,11 +1864,20 @@ pub fn stop_managed_agent_process( }; // On Unix, kill the entire process group via terminate_process. - // On non-Unix, fall back to Child::kill() since terminate_process - // is not implemented there. + // On Windows, drop the Job Object handle (KILL_ON_JOB_CLOSE) so the whole + // harness tree dies — Child::kill() would orphan the agent workers + MCP + // servers. If job assignment failed at spawn, fall back to Child::kill(). #[cfg(unix)] terminate_process(runtime.child.id())?; - #[cfg(not(unix))] + #[cfg(windows)] + match runtime.job.take() { + Some(job) => drop(job), + None => runtime + .child + .kill() + .map_err(|error| format!("failed to kill agent process: {error}"))?, + } + #[cfg(not(any(unix, windows)))] runtime .child .kill() diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e9d733e54..51761a0f9 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -207,6 +207,12 @@ pub struct RelayMeshConfig { pub struct ManagedAgentProcess { pub child: Child, pub log_path: PathBuf, + /// Win32 Job Object owning the harness + its entire process tree. Closing + /// the handle (via `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) kills the whole + /// tree — the Windows mirror of the Unix process-group teardown. `None` + /// if job creation/assignment failed (we fall back to `Child::kill()`). + #[cfg(windows)] + pub job: Option, } #[derive(Debug, Clone, Serialize)] @@ -545,7 +551,6 @@ pub struct MigrationReport { } pub const DEFAULT_ACP_COMMAND: &str = "buzz-acp"; -pub const DEFAULT_AGENT_COMMAND: &str = "goose"; /// ~5 min (320s) — matches the CLI harness default (BUZZ_ACP_IDLE_TIMEOUT). pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320; /// 1 hour — absolute wall-clock safety cap per turn. diff --git a/desktop/src/features/settings/UpdateChecker.tsx b/desktop/src/features/settings/UpdateChecker.tsx index 4d524760f..1c43c2097 100644 --- a/desktop/src/features/settings/UpdateChecker.tsx +++ b/desktop/src/features/settings/UpdateChecker.tsx @@ -59,6 +59,21 @@ export function UpdateChecker() { )} + {status.state === "unavailable" && ( + +
+

Update status

+

+ Automatic updates aren't available on this build. Download the + latest release manually. +

+
+ +
+ )} + {status.state === "available" && (
diff --git a/desktop/src/features/settings/hooks/use-updater.ts b/desktop/src/features/settings/hooks/use-updater.ts index 3b9c5e632..45aff4262 100644 --- a/desktop/src/features/settings/hooks/use-updater.ts +++ b/desktop/src/features/settings/hooks/use-updater.ts @@ -6,6 +6,7 @@ export type UpdateStatus = | { state: "idle" } | { state: "checking" } | { state: "up-to-date" } + | { state: "unavailable" } | { state: "available"; version: string } | { state: "downloading" } | { state: "installing" } @@ -137,8 +138,12 @@ export function useUpdater() { !background || manualResultRequestedRef.current; if (isUpdaterUnavailable(message)) { + // Surface which branch fired on Windows builds where the updater + // plugin is missing — distinguishes plugin-unavailable from a genuine + // up-to-date result in Will's app log. + console.warn(`updater unavailable: ${message}`); if (shouldShowQuietResult) { - setStatus({ state: "idle" }); + setStatus({ state: "unavailable" }); } return; } From f8034a0d218157751c1d44634d7d83d49921a751 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 17:00:31 -0400 Subject: [PATCH 2/7] docs: correct Job Object race rationale in process_lifecycle The doc comment claimed buzz-acp connects to the relay before spawning its workers, making the spawn-to-assign window structurally empty. Source contradicts this: in crates/buzz-acp/src/lib.rs the agent pool is built (agent_pool_ready, line 1061) before the relay connect (line 1098), and Will's Windows log confirms that order. The window is closed by assign-latency (microsecond synchronous Win32 calls beating buzz-acp's tens-of-ms startup), not by child ordering. Comment-only change. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/managed_agents/process_lifecycle.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs index 71e386deb..b3972d74e 100644 --- a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -45,10 +45,19 @@ impl Drop for JobHandle { /// the caller can fall back to `Child::kill()` — a degraded teardown beats a /// failed spawn. /// -/// Assignment happens immediately after spawn. The harness connects to the -/// relay before it spawns any agent workers, so the tiny window between spawn -/// and assignment never contains a grandchild; once assigned, Windows places -/// every subsequently-spawned descendant in the job automatically. +/// Assignment happens immediately after spawn, on the same parent thread. The +/// child (buzz-acp) does spawn its 24 workers before it connects to the relay, +/// so the window between our spawn and our assignment is NOT structurally empty. +/// What closes it is assign-latency: `OpenProcess` + `AssignProcessToJobObject` +/// are a few synchronous Win32 calls (microseconds), while buzz-acp must init +/// tokio, parse its config, and spawn 24 children (tens-to-hundreds of ms), so +/// the assign reliably wins before any worker exists. Once assigned, Windows +/// places every subsequently-spawned descendant in the job automatically. +/// +/// `CREATE_SUSPENDED` -> assign -> `ResumeThread` would make the window airtight +/// regardless of child timing, but it requires raw `CreateProcessW`/`ResumeThread` +/// (materially more unsafe Win32) to close a microsecond race, so it is +/// deliberately not used here. fn create_job_for_child(pid: u32) -> Option { use std::ptr::null; use windows_sys::Win32::Foundation::{CloseHandle, FALSE}; From 8f4859fab3e6877685c819cc41de551a8994d7e3 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 17:12:40 -0400 Subject: [PATCH 3/7] ci: type-check Windows target on windows-latest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #[cfg(windows)] code paths (Job Object kill-tree, multicall shim, MCP env passthrough) were never compiled by CI — no Linux job builds the MSVC target, and aws-lc-sys needs windows.h, so they shipped verified only by inspection. Add a windows-latest job that runs clippy, a workspace cargo check, and the Tauri-crate check + test against x86_64-pc-windows-msvc, gating exactly the Windows arms. Uses dtolnay/rust-toolchain rather than hermit (which the Linux jobs use) because hermit does not provide MSVC; mirrors release.yml's release-windows toolchain. Sidecar stubs are created before any Tauri compile because Tauri validates externalBin at compile time. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 489ba22d2..600a37cfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -568,6 +568,56 @@ jobs: -p git-credential-nostr \ -p git-sign-nostr + windows-rust: + name: Windows Rust (x86_64-pc-windows-msvc) + runs-on: windows-latest + # Windows runners are slow and this compiles the workspace + Tauri crate + # cold across four steps; budget generously. + timeout-minutes: 45 + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' + permissions: + contents: read + env: + TARGET: x86_64-pc-windows-msvc + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows + # runner with dtolnay/rust-toolchain — hermit, used by the Linux jobs, does + # not provide MSVC. Pinned to the same toolchain as release.yml's + # release-windows job. + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: x86_64-pc-windows-msvc + components: clippy + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: windows-msvc + save-if: ${{ github.event_name != 'pull_request' }} + # Tauri validates externalBin at compile time, so the Tauri-crate steps + # below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's + # Windows naming (binaries/-.exe); empty files suffice for a + # type-check since nothing executes them. + - name: Create sidecar placeholders + shell: bash + run: | + mkdir -p desktop/src-tauri/binaries + for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" + done + - name: Clippy (workspace) + run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings + - name: Check (workspace) + run: cargo check --workspace --all-targets --target $env:TARGET + - name: Check (Tauri crate) + run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Test (Tauri crate) + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + desktop-build-macos: name: Desktop Build (macOS) runs-on: macos-latest From 648b21df031b3083db4865804f9022bd68eb59ec Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 17:36:33 -0400 Subject: [PATCH 4/7] test: gate Unix-symlink migration tests behind #[cfg(unix)] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new windows-rust CI job's Tauri-crate test step (--all-targets) was the first compiler to build these tests against MSVC, and it failed with 10 E0433 errors: migration_tests.rs and migration_team_dir_tests.rs call std::os::unix::fs::symlink directly with no cfg guard. Production code is already correctly #[cfg(unix)] / #[cfg(not(unix))] gated, so the workspace check and clippy passed — only the test compile reached these targets. These tests assert Unix symlink semantics (create symlink, heal/replace it, read through it); there is nothing to verify on Windows, where the production path copies instead. Gate each symlink-using test plus the two helpers they exclusively use (setup_sync_layout, sync_files) so the helpers do not trip dead_code under -D warnings on Windows. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/migration_team_dir_tests.rs | 3 +++ desktop/src-tauri/src/migration_tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/desktop/src-tauri/src/migration_team_dir_tests.rs b/desktop/src-tauri/src/migration_team_dir_tests.rs index afeca0b69..8464ed31b 100644 --- a/desktop/src-tauri/src/migration_team_dir_tests.rs +++ b/desktop/src-tauri/src/migration_team_dir_tests.rs @@ -222,6 +222,7 @@ fn team_dir_reconcile_leaves_record_when_team_missing() { assert!(records[0].get("persona_team_dir").is_none()); } +#[cfg(unix)] #[test] fn team_dir_reconcile_heals_to_symlinked_team_dir() { // When agents/teams/ is a symlink to a real directory elsewhere, @@ -260,6 +261,7 @@ fn team_dir_reconcile_heals_to_symlinked_team_dir() { assert_eq!(records[0]["persona_team_dir"], expected_path); } +#[cfg(unix)] #[test] fn team_dir_reconcile_skips_dangling_candidate_symlink() { // When agents/teams/ is a symlink whose target does not exist, @@ -299,6 +301,7 @@ fn team_dir_reconcile_skips_dangling_candidate_symlink() { assert_eq!(records[0]["persona_pack_path"], stale_path); } +#[cfg(unix)] #[test] fn team_dir_reconcile_through_symlink_preserves_symlink() { // Dev worktree instances reach the canonical store through a symlinked diff --git a/desktop/src-tauri/src/migration_tests.rs b/desktop/src-tauri/src/migration_tests.rs index fefce641f..b414383c1 100644 --- a/desktop/src-tauri/src/migration_tests.rs +++ b/desktop/src-tauri/src/migration_tests.rs @@ -65,6 +65,7 @@ fn copy_dir_all_preserves_nested_files_without_overwriting() { /// Helper: create a temp dir structure mimicking canonical + worktree layout. /// Packs live in a `.main` sibling (not canonical) to match real-world state. /// Returns `(parent_dir_handle, canonical_dir, worktree_dir)`. +#[cfg(unix)] fn setup_sync_layout() -> (tempfile::TempDir, PathBuf, PathBuf) { let parent = tempfile::tempdir().unwrap(); let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); @@ -97,6 +98,7 @@ fn setup_sync_layout() -> (tempfile::TempDir, PathBuf, PathBuf) { /// Mirrors the symlink loop of `sync_shared_agent_data` but takes explicit /// paths. `sync_shared_agent_data` requires a live Tauri AppHandle and /// cannot be unit-tested directly. +#[cfg(unix)] fn sync_files(canonical: &Path, worktree: &Path) -> u32 { let mut synced = 0u32; for rel in SHARED_AGENT_FILES { @@ -180,6 +182,7 @@ fn sync_files(canonical: &Path, worktree: &Path) -> u32 { synced } +#[cfg(unix)] #[test] fn sync_creates_symlinks_to_fresh_worktree() { let (_parent, canonical, worktree) = setup_sync_layout(); @@ -201,6 +204,7 @@ fn sync_creates_symlinks_to_fresh_worktree() { ); } +#[cfg(unix)] #[test] fn sync_replaces_existing_files_with_symlinks() { let (_parent, canonical, worktree) = setup_sync_layout(); @@ -226,6 +230,7 @@ fn sync_replaces_existing_files_with_symlinks() { ); } +#[cfg(unix)] #[test] fn sync_preserves_correct_symlinks() { let (_parent, canonical, worktree) = setup_sync_layout(); @@ -238,6 +243,7 @@ fn sync_preserves_correct_symlinks() { } } +#[cfg(unix)] #[test] fn sync_replaces_wrong_symlinks() { let (_parent, canonical, worktree) = setup_sync_layout(); @@ -256,6 +262,7 @@ fn sync_replaces_wrong_symlinks() { } } +#[cfg(unix)] #[test] fn sync_handles_broken_symlinks() { let (_parent, canonical, worktree) = setup_sync_layout(); @@ -275,6 +282,7 @@ fn sync_handles_broken_symlinks() { } } +#[cfg(unix)] #[test] fn writes_through_symlink_reach_canonical() { let (_parent, canonical, worktree) = setup_sync_layout(); @@ -321,6 +329,7 @@ fn canonical_dev_data_dir_returns_self_for_canonical_instance() { assert_eq!(canonical_dev_data_dir(&canonical).unwrap(), canonical); } +#[cfg(unix)] #[test] fn sync_creates_teams_directory_symlink() { let (_parent, canonical, worktree) = setup_sync_layout(); @@ -341,6 +350,7 @@ fn sync_creates_teams_directory_symlink() { ); } +#[cfg(unix)] #[test] fn sync_migrates_teams_from_sibling_to_canonical() { let (_parent, canonical, worktree) = setup_sync_layout(); @@ -368,6 +378,7 @@ fn sync_migrates_teams_from_sibling_to_canonical() { ); } +#[cfg(unix)] #[test] fn sync_replaces_real_teams_dir_with_symlink() { let (_parent, canonical, worktree) = setup_sync_layout(); @@ -386,6 +397,7 @@ fn sync_replaces_real_teams_dir_with_symlink() { // ── Packs → Teams migration tests ─────────────────────────────────── +#[cfg(unix)] #[test] fn migrate_packs_merge_preserves_non_empty_dir() { // When packs/ contains symlinks that weren't moved (e.g., external tools From 5690b2f3c503a7e9b299d04130a00fa813bba760 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 17:43:53 -0400 Subject: [PATCH 5/7] ci: drop redundant rust-toolchain action from windows-rust job zizmor's superfluous-actions check flagged the dtolnay/rust-toolchain step: windows-latest preinstalls rustup, which honors the repo-root rust-toolchain.toml (channel 1.95.0, profile = default). That profile already provides clippy, and the runner's host triple is x86_64-pc-windows-msvc, so both the explicit toolchain install and the targets/components inputs were no-ops. release.yml's release-windows job keeps its own copy (out of scope, not flagged). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 600a37cfe..56e6037da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -583,13 +583,10 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows - # runner with dtolnay/rust-toolchain — hermit, used by the Linux jobs, does - # not provide MSVC. Pinned to the same toolchain as release.yml's - # release-windows job. - - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - with: - targets: x86_64-pc-windows-msvc - components: clippy + # runner — hermit, used by the Linux jobs, does not provide MSVC. The + # toolchain (1.95.0 + clippy via profile = default) comes from the + # repo-root rust-toolchain.toml, which the runner's preinstalled rustup + # honors on demand; the host triple already is x86_64-pc-windows-msvc. - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: windows-msvc From e2c60f50fc21780b68ec5cf75d32227b242a618e Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 18:03:29 -0400 Subject: [PATCH 6/7] ci: cache Tauri crate workspace in windows-rust job The windows-rust rust-cache had no workspaces key, so it defaulted to the repo root and never cached desktop/src-tauri's separate target dir. The Check/Test (Tauri crate) steps are the heaviest compile on the job and rebuilt cold every run. Mirror the Desktop E2E Relay pattern to cache both workspaces, matching every other job that builds the crate. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56e6037da..ebae8f09b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -589,6 +589,9 @@ jobs: # honors on demand; the host triple already is x86_64-pc-windows-msvc. - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: + workspaces: | + . + desktop/src-tauri key: windows-msvc save-if: ${{ github.event_name != 'pull_request' }} # Tauri validates externalBin at compile time, so the Tauri-crate steps From b73d6912701c19d18436b0d8e6540cc022b628e5 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 18:28:34 -0400 Subject: [PATCH 7/7] fix(desktop): build reconcile team-dir path per-component for Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconcile_team_dirs_in_file built the rewritten path with a single target_dir.join("agents/teams"). On Windows, join does not split the embedded '/', so it persisted a mixed-separator path (...app.dev\agents/teams\id) into managed-agents.json — unlike fresh writes, which build the path per-component and stay all-native. Split the join so reconcile emits the same native-separator path the rest of the system stores. Tests now build expectations via shared team_dir / pack_dir helpers using the same per-component join, so they assert real production output on both Unix and Windows. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/migration.rs | 6 +- .../src-tauri/src/migration_team_dir_tests.rs | 85 +++++++------------ .../src-tauri/src/migration_test_support.rs | 21 +++++ 3 files changed, 58 insertions(+), 54 deletions(-) diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 48df3af84..0387b31a4 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -361,7 +361,11 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { } fn reconcile_team_dirs_in_file(path: &Path, target_dir: &Path) { - let target_teams = target_dir.join("agents/teams"); + // Build per-component so the persisted value uses native separators on + // every platform, matching fresh writes (agents.rs builds the same path as + // base.join("teams").join(id)). A single join("agents/teams") would embed a + // literal '/' on Windows, persisting a mixed-separator path into the store. + let target_teams = target_dir.join("agents").join("teams"); patch_json_records(path, |obj| { // Handle both old field name and new field name let field_name = if obj.contains_key("persona_team_dir") { diff --git a/desktop/src-tauri/src/migration_team_dir_tests.rs b/desktop/src-tauri/src/migration_team_dir_tests.rs index 8464ed31b..1bad6a9e3 100644 --- a/desktop/src-tauri/src/migration_team_dir_tests.rs +++ b/desktop/src-tauri/src/migration_team_dir_tests.rs @@ -11,17 +11,13 @@ fn team_dir_reconcile_rewrites_worktree_path() { // Team must exist on disk for the reconcile to proceed past the existence gate. std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap(); - let worktree_pack_path = format!( - "{}/agents/packs/com.wpfleger.sietch-tabr", - parent + let worktree_pack_path = pack_dir( + &parent .path() - .join("xyz.block.buzz.app.dev.worktree-my-branch") - .display() - ); - let expected_path = format!( - "{}/agents/teams/com.wpfleger.sietch-tabr", - canonical.display() + .join("xyz.block.buzz.app.dev.worktree-my-branch"), + "com.wpfleger.sietch-tabr", ); + let expected_path = team_dir(&canonical, "com.wpfleger.sietch-tabr"); write_agents_json( &canonical, @@ -47,17 +43,13 @@ fn team_dir_reconcile_rewrites_new_field_name() { // Team must exist on disk for the reconcile to proceed past the existence gate. std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap(); - let worktree_team_path = format!( - "{}/agents/teams/com.wpfleger.sietch-tabr", - parent + let worktree_team_path = team_dir( + &parent .path() - .join("xyz.block.buzz.app.dev.worktree-my-branch") - .display() - ); - let expected_path = format!( - "{}/agents/teams/com.wpfleger.sietch-tabr", - canonical.display() + .join("xyz.block.buzz.app.dev.worktree-my-branch"), + "com.wpfleger.sietch-tabr", ); + let expected_path = team_dir(&canonical, "com.wpfleger.sietch-tabr"); write_agents_json( &canonical, @@ -79,10 +71,7 @@ fn team_dir_reconcile_leaves_canonical_path_unchanged() { let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); std::fs::create_dir_all(canonical.join("agents")).unwrap(); - let canonical_path = format!( - "{}/agents/teams/com.wpfleger.sietch-tabr", - canonical.display() - ); + let canonical_path = team_dir(&canonical, "com.wpfleger.sietch-tabr"); write_agents_json( &canonical, @@ -128,12 +117,11 @@ fn team_dir_reconcile_is_idempotent() { // Team must exist on disk for the reconcile to proceed past the existence gate. std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap(); - let worktree_pack_path = format!( - "{}/agents/packs/com.wpfleger.sietch-tabr", - parent + let worktree_pack_path = pack_dir( + &parent .path() - .join("xyz.block.buzz.app.dev.worktree-my-branch") - .display() + .join("xyz.block.buzz.app.dev.worktree-my-branch"), + "com.wpfleger.sietch-tabr", ); write_agents_json( @@ -161,11 +149,11 @@ fn team_dir_reconcile_heals_legacy_release_path() { let release_dir = parent.path().join("xyz.block.buzz.app"); std::fs::create_dir_all(release_dir.join("agents/teams/com.example.team")).unwrap(); - let legacy_path = format!( - "{}/agents/packs/com.example.team", - parent.path().join("xyz.block.sprout.app").display() + let legacy_path = pack_dir( + &parent.path().join("xyz.block.sprout.app"), + "com.example.team", ); - let expected_path = format!("{}/agents/teams/com.example.team", release_dir.display()); + let expected_path = team_dir(&release_dir, "com.example.team"); write_agents_json( &release_dir, @@ -193,12 +181,9 @@ fn team_dir_reconcile_leaves_record_when_team_missing() { std::fs::create_dir_all(canonical.join("agents")).unwrap(); // Intentionally do NOT create agents/teams/com.example.missing. - let stale_path = format!( - "{}/agents/packs/com.example.missing", - parent - .path() - .join("xyz.block.buzz.app.dev.worktree-old") - .display() + let stale_path = pack_dir( + &parent.path().join("xyz.block.buzz.app.dev.worktree-old"), + "com.example.missing", ); write_agents_json( @@ -238,14 +223,11 @@ fn team_dir_reconcile_heals_to_symlinked_team_dir() { std::fs::create_dir_all(canonical.join("agents/teams")).unwrap(); std::os::unix::fs::symlink(&real_team, &symlink_target).unwrap(); - let stale_path = format!( - "{}/agents/packs/com.example.team", - parent - .path() - .join("xyz.block.buzz.app.dev.worktree-old") - .display() + let stale_path = pack_dir( + &parent.path().join("xyz.block.buzz.app.dev.worktree-old"), + "com.example.team", ); - let expected_path = format!("{}/agents/teams/com.example.team", canonical.display()); + let expected_path = team_dir(&canonical, "com.example.team"); write_agents_json( &canonical, @@ -275,12 +257,9 @@ fn team_dir_reconcile_skips_dangling_candidate_symlink() { let symlink_path = canonical.join("agents/teams/com.example.gone"); std::os::unix::fs::symlink(&dangling_target, &symlink_path).unwrap(); - let stale_path = format!( - "{}/agents/packs/com.example.gone", - parent - .path() - .join("xyz.block.buzz.app.dev.worktree-old") - .display() + let stale_path = pack_dir( + &parent.path().join("xyz.block.buzz.app.dev.worktree-old"), + "com.example.gone", ); write_agents_json( @@ -315,8 +294,8 @@ fn team_dir_reconcile_through_symlink_preserves_symlink() { std::fs::create_dir_all(canonical.join("agents/teams/com.example.team")).unwrap(); std::fs::create_dir_all(worktree.join("agents")).unwrap(); - let stale_path = format!("{}/agents/packs/com.example.team", worktree.display()); - let expected_path = format!("{}/agents/teams/com.example.team", canonical.display()); + let stale_path = pack_dir(&worktree, "com.example.team"); + let expected_path = team_dir(&canonical, "com.example.team"); write_agents_json( &canonical, @@ -438,7 +417,7 @@ fn team_dir_reconcile_renames_legacy_field_when_value_already_canonical() { let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); std::fs::create_dir_all(canonical.join("agents/teams/com.example.team")).unwrap(); - let correct_path = format!("{}/agents/teams/com.example.team", canonical.display()); + let correct_path = team_dir(&canonical, "com.example.team"); write_agents_json( &canonical, diff --git a/desktop/src-tauri/src/migration_test_support.rs b/desktop/src-tauri/src/migration_test_support.rs index 64a428949..399cb9c8f 100644 --- a/desktop/src-tauri/src/migration_test_support.rs +++ b/desktop/src-tauri/src/migration_test_support.rs @@ -2,6 +2,27 @@ use std::path::Path; +/// Build the native-separator `/agents/teams/` string the way +/// production does (per-component `Path::join`), so test expectations match +/// reconcile output on Windows as well as Unix. +pub(crate) fn team_dir(base: &Path, id: &str) -> String { + base.join("agents") + .join("teams") + .join(id) + .display() + .to_string() +} + +/// Native-separator `/agents/packs/` — the pre-migration layout used +/// as reconcile input. See [`team_dir`] for why per-component join matters. +pub(crate) fn pack_dir(base: &Path, id: &str) -> String { + base.join("agents") + .join("packs") + .join(id) + .display() + .to_string() +} + pub(crate) fn write_agents_json(dir: &Path, records: &serde_json::Value) { std::fs::create_dir_all(dir.join("agents")).unwrap(); std::fs::write(