mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: make managed-agent spawn and teardown portable to Windows (#1097)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
7de5517f1d
commit
4201311821
@@ -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 — 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:
|
||||
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
|
||||
# below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's
|
||||
# Windows naming (binaries/<bin>-<triple>.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
|
||||
|
||||
@@ -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<RoleClient, ()>;
|
||||
|
||||
#[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![
|
||||
|
||||
@@ -137,7 +137,7 @@ impl ServerHandler for DevMcp {
|
||||
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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();
|
||||
|
||||
@@ -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(|_| ())
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Vec<String>> {
|
||||
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::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_claude_and_codex_args_to_empty() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! 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, 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<JobHandle> {
|
||||
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::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() 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,
|
||||
}
|
||||
}
|
||||
@@ -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<ManagedAgentProcess, String>;
|
||||
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) => {
|
||||
|
||||
@@ -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<crate::managed_agents::ManagedAgentProcess, String> {
|
||||
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<String> = Vec::new();
|
||||
let mut parts: Vec<std::path::PathBuf> = 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()
|
||||
|
||||
@@ -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<crate::managed_agents::JobHandle>,
|
||||
}
|
||||
|
||||
#[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.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(
|
||||
@@ -222,6 +207,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/<id> is a symlink to a real directory elsewhere,
|
||||
@@ -237,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,
|
||||
@@ -260,6 +243,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/<id> is a symlink whose target does not exist,
|
||||
@@ -273,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(
|
||||
@@ -299,6 +280,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
|
||||
@@ -312,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,
|
||||
@@ -435,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,
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Build the native-separator `<base>/agents/teams/<id>` 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 `<base>/agents/packs/<id>` — 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,6 +59,21 @@ export function UpdateChecker() {
|
||||
</SettingsOptionRow>
|
||||
)}
|
||||
|
||||
{status.state === "unavailable" && (
|
||||
<SettingsOptionRow>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Update status</p>
|
||||
<p className="text-sm font-normal text-muted-foreground">
|
||||
Automatic updates aren't available on this build. Download the
|
||||
latest release manually.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={checkForUpdate}>
|
||||
Check Again
|
||||
</Button>
|
||||
</SettingsOptionRow>
|
||||
)}
|
||||
|
||||
{status.state === "available" && (
|
||||
<SettingsOptionRow>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user