feat(desktop): auto-restart setup-mode agents after adapter install, badge drift fallback (#1786)

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:
Will Pfleger
2026-07-12 22:27:51 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent b0ad2b66dd
commit 11a286b9a4
10 changed files with 633 additions and 8 deletions
+14 -2
View File
@@ -189,7 +189,9 @@ const overrides = new Map([
// passthrough (+2 lines).
// doctor-install-reliability: node_required + auth_status + login_hint fields
// added to RawAcpRuntimeCatalogEntry + fromRawAcpRuntimeCatalogEntry mapper (+8).
["src/shared/api/tauri.ts", 1281],
// codex-install-auto-restart: restarted_count + failed_restart_count added to
// RawInstallRuntimeResult + fromRawInstallRuntimeResult mapper (+2).
["src/shared/api/tauri.ts", 1282],
// doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line).
// codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line).
// doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/
@@ -247,7 +249,11 @@ const overrides = new Map([
// locking, is_safe_nvm_tag security validation, classify_probe_output helper,
// auth_probe_args on KnownAcpRuntime (removes probe_args_for indirection),
// process-level timeout replacing inner-thread pattern. (+75 lines)
["src-tauri/src/managed_agents/discovery.rs", 1171],
// codex-install-auto-restart review-fixes: availability_drift pure predicate
// + updated adapter_availability_cached() signature (Option return, cold=None)
// prevents false restart badge on newly restarted agents. Correctness fix;
// load-bearing — required by Thufir's IMPORTANT findings. (+15 lines)
["src-tauri/src/managed_agents/discovery.rs", 1245],
// rebase over codex-acp-package-swap: its version-probe tests union with the
// doctor-install-reliability nvm/login-shell/semver tests — each side alone
// stayed under the 1000 default; the union exceeds it.
@@ -369,6 +375,12 @@ const overrides = new Map([
// +1: doctor-install-reliability: login_hint: None added to goose_runtime test stub.
// +1: doctor-install-reliability review fixes: auth_probe_args: None added to stub.
["src-tauri/src/commands/agent_config.rs", 1021],
// codex-install-auto-restart review-fixes: should_restart_after_install
// takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy
// cache tests replaced with 6 pure availability_drift predicate tests;
// dead-pid non-happy-path added. All load-bearing correctness fixes.
// (+17 lines net vs previous 1330 limit; rustfmt expanded some call sites)
["src-tauri/src/commands/agent_discovery.rs", 1347],
// draft-persistence predicate: submit-time `loadDraft` check + inline comment
// + deps-array entry in submitMessage closes the never-persisted-boundary
// defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to
@@ -70,10 +70,41 @@ pub async fn discover_acp_providers() -> Result<Vec<AcpRuntimeCatalogEntry>, Str
}
#[tauri::command]
pub async fn install_acp_runtime(runtime_id: String) -> Result<InstallRuntimeResult, String> {
tokio::task::spawn_blocking(move || install_acp_runtime_blocking(&runtime_id))
.await
.map_err(|e| format!("install task panicked: {e}"))?
pub async fn install_acp_runtime(
runtime_id: String,
app: tauri::AppHandle,
) -> Result<InstallRuntimeResult, String> {
// ── Phase 1: blocking install ────────────────────────────────────────────
//
// Run the npm install steps synchronously in spawn_blocking. The
// active_installs guard is dropped when install_acp_runtime_blocking
// returns (Guard impl Drop) — so Phase 2's restart path runs outside
// the guard and cannot re-enter the mutex.
let runtime_id_clone = runtime_id.clone();
let install_result =
tokio::task::spawn_blocking(move || install_acp_runtime_blocking(&runtime_id_clone))
.await
.map_err(|e| format!("install task panicked: {e}"))??;
if !install_result.success {
return Ok(install_result);
}
// ── Phase 2: async restart of stuck agents ───────────────────────────────
//
// Mirror set_global_agent_config: after a successful install, restart any
// local agents that were spawned in setup-listener mode for this runtime
// and whose readiness now computes Ready. Best-effort — errors are logged
// and returned as failed_restart_count without failing the command.
let (restarted_count, failed_restart_count) =
restart_setup_mode_agents_after_install(&app, &runtime_id).await;
Ok(InstallRuntimeResult {
success: true,
steps: install_result.steps,
restarted_count,
failed_restart_count,
})
}
/// Err(_) = infrastructure failure (panic, concurrency guard).
@@ -129,6 +160,8 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
return Ok(InstallRuntimeResult {
success: false,
steps,
restarted_count: 0,
failed_restart_count: 0,
});
}
}
@@ -155,6 +188,8 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
return Ok(InstallRuntimeResult {
success: false,
steps,
restarted_count: 0,
failed_restart_count: 0,
});
}
}
@@ -168,6 +203,8 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
return Ok(InstallRuntimeResult {
success: false,
steps,
restarted_count: 0,
failed_restart_count: 0,
});
}
}
@@ -179,9 +216,326 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
Ok(InstallRuntimeResult {
success: true,
steps,
restarted_count: 0,
failed_restart_count: 0,
})
}
// ── Post-install auto-restart (Phase 2 of install_acp_runtime) ───────────────
//
// After a successful adapter install, restart any local agents that:
// 1. are local backend + have a live PID,
// 2. their effective command maps to the just-installed runtime,
// 3. were spawned in setup-listener mode (setup_mode stamp), AND
// 4. their readiness now computes Ready.
//
// Mirrors the two-phase shape of set_global_agent_config.
/// Outcome of a single per-agent restart attempt during post-install restart.
#[derive(Debug)]
enum InstallRestartOutcome {
Restarted,
FailedAfterStop,
Skipped,
}
/// Pure predicate: should this agent be restarted after an adapter install?
///
/// Extracted for unit testing — callers must still re-verify under the lock.
/// The caller is responsible for computing `pid_alive` (via `process_is_running`)
/// before invoking this function, keeping the predicate OS-agnostic and testable
/// on all platforms.
///
/// An agent qualifies iff:
/// - it is a local backend with a live PID (`pid_alive`),
/// - its effective command maps to `runtime_id`,
/// - it was **spawned in setup-listener mode** (`setup_mode`), AND
/// - its readiness **now computes `Ready`** (install fixed the blocker).
fn should_restart_after_install(
is_local: bool,
pid_alive: bool,
runtime_matches: bool,
setup_mode: bool,
now_ready: bool,
) -> bool {
is_local && pid_alive && runtime_matches && setup_mode && now_ready
}
/// Restart all setup-mode agents whose runtime matches `runtime_id` and whose
/// readiness now computes Ready. Returns `(restarted_count, failed_restart_count)`.
async fn restart_setup_mode_agents_after_install(
app: &tauri::AppHandle,
runtime_id: &str,
) -> (u32, u32) {
use crate::{
app_state::AppState,
managed_agents::{
agent_readiness, known_acp_runtime, load_global_agent_config, load_managed_agents,
load_personas, record_agent_command, resolve_effective_agent_env, AgentReadiness,
BackendKind,
},
};
use tauri::Manager;
// ── Pre-scan: collect candidate pubkeys without holding locks ────────────
let state = app.state::<AppState>();
let owner_hex = match super::agents::workspace_owner_hex(&state) {
Ok(h) => h,
Err(e) => {
eprintln!(
"buzz-desktop: install_acp_runtime: failed to compute owner_hex for restart: {e}"
);
return (0, 0);
}
};
let app_for_scan = app.clone();
let runtime_id_owned = runtime_id.to_string();
let candidates = tokio::task::spawn_blocking(move || {
let records = load_managed_agents(&app_for_scan).unwrap_or_default();
let personas = load_personas(&app_for_scan).unwrap_or_default();
let global = load_global_agent_config(&app_for_scan).unwrap_or_default();
// Read the runtimes map to check setup_mode stamps.
let state_inner = app_for_scan.state::<AppState>();
let runtimes = state_inner
.managed_agent_processes
.lock()
.unwrap_or_else(|e| e.into_inner());
records
.iter()
.filter(|record| {
let is_local = record.backend == BackendKind::Local;
let pid = record.runtime_pid;
let effective_cmd = record_agent_command(record, &personas);
let runtime_matches =
known_acp_runtime(&effective_cmd).is_some_and(|r| r.id == runtime_id_owned);
let setup_mode = runtimes
.get(&record.pubkey)
.map(|p| p.setup_mode)
.unwrap_or(false);
let effective = resolve_effective_agent_env(
record,
&personas,
known_acp_runtime(&effective_cmd),
&global,
);
let now_ready = matches!(agent_readiness(&effective), AgentReadiness::Ready);
let pid_alive = pid.is_some_and(|p| crate::managed_agents::process_is_running(p));
should_restart_after_install(
is_local,
pid_alive,
runtime_matches,
setup_mode,
now_ready,
)
})
.map(|r| r.pubkey.clone())
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
if candidates.is_empty() {
return (0, 0);
}
let mut restarted_count: u32 = 0;
let mut failed_restart_count: u32 = 0;
for pubkey in &candidates {
let outcome = restart_single_agent_after_install(app, pubkey, &owner_hex, runtime_id).await;
match outcome {
InstallRestartOutcome::Restarted => restarted_count += 1,
InstallRestartOutcome::FailedAfterStop => failed_restart_count += 1,
InstallRestartOutcome::Skipped => {}
}
}
(restarted_count, failed_restart_count)
}
/// Stop-then-start a single setup-mode agent after a successful adapter install.
///
/// Mirrors `restart_local_agent_on_config_change` from `global_agent_config.rs`:
/// eligibility is re-verified under the store lock before the stop, then the
/// agent is restarted via `start_local_agent_with_preflight`.
async fn restart_single_agent_after_install(
app: &tauri::AppHandle,
pubkey: &str,
owner_hex: &str,
runtime_id: &str,
) -> InstallRestartOutcome {
use crate::{
app_state::AppState,
managed_agents::{
agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime,
load_global_agent_config, load_managed_agents, load_personas, process_is_running,
record_agent_command, resolve_effective_agent_env, save_managed_agents,
stop_managed_agent_process, sync_managed_agent_processes, AgentReadiness, BackendKind,
},
};
use tauri::Manager;
let app_for_stop = app.clone();
let pubkey_owned = pubkey.to_string();
let runtime_id_owned = runtime_id.to_string();
let stop_result = tokio::task::spawn_blocking(move || {
let state = app_for_stop.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| format!("failed to acquire store lock: {e}"))?;
let mut records = load_managed_agents(&app_for_stop)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| format!("failed to acquire runtimes lock: {e}"))?;
// Sync process state so PID liveness reflects current reality.
let (sync_changed, _) = sync_managed_agent_processes(
&mut records,
&mut runtimes,
&current_instance_id(&app_for_stop),
);
if sync_changed {
save_managed_agents(&app_for_stop, &records)?;
}
// Re-verify eligibility under lock.
let record = records
.iter()
.find(|r| r.pubkey == pubkey_owned)
.ok_or_else(|| format!("agent {pubkey_owned} not found"))?;
if record.backend != BackendKind::Local {
return Err(format!("agent {pubkey_owned} is no longer a local agent"));
}
let Some(pid) = record.runtime_pid else {
return Err(format!(
"agent {pubkey_owned} no longer has a recorded PID after sync"
));
};
if !process_is_running(pid) {
return Err(format!(
"agent {pubkey_owned} process {pid} is no longer running"
));
}
let personas = load_personas(&app_for_stop).unwrap_or_default();
let global = load_global_agent_config(&app_for_stop).unwrap_or_default();
let effective_cmd = record_agent_command(record, &personas);
let runtime_matches =
known_acp_runtime(&effective_cmd).is_some_and(|r| r.id == runtime_id_owned);
if !runtime_matches {
return Err(format!(
"agent {pubkey_owned} runtime no longer matches {runtime_id_owned} under lock"
));
}
let setup_mode = runtimes
.get(&pubkey_owned)
.map(|p| p.setup_mode)
.unwrap_or(false);
if !setup_mode {
return Err(format!(
"agent {pubkey_owned} is not in setup mode under lock — skipping"
));
}
let runtime_meta = known_acp_runtime(&effective_cmd);
let effective = resolve_effective_agent_env(record, &personas, runtime_meta, &global);
if !matches!(agent_readiness(&effective), AgentReadiness::Ready) {
return Err(format!(
"agent {pubkey_owned} readiness is still NotReady after install — not bouncing"
));
}
// Stop the process.
let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?;
stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?;
save_managed_agents(&app_for_stop, &records)?;
Ok(())
})
.await;
let stopped = match stop_result {
Ok(Ok(())) => true,
Ok(Err(e)) => {
eprintln!("buzz-desktop: install_acp_runtime: skipping restart of {pubkey}: {e}");
false
}
Err(e) => {
eprintln!(
"buzz-desktop: install_acp_runtime: spawn_blocking failed for stop of {pubkey}: {e}"
);
false
}
};
if !stopped {
return InstallRestartOutcome::Skipped;
}
// Start via the normal preflight path — same as config-change restart.
{
use tauri::Manager;
let state = app.state::<AppState>();
match super::agents::start_local_agent_with_preflight(app, &state, pubkey, owner_hex, false)
.await
{
Ok(_) => {
eprintln!(
"buzz-desktop: install_acp_runtime: restarted setup-mode agent {pubkey} after install"
);
InstallRestartOutcome::Restarted
}
Err(e) => {
eprintln!(
"buzz-desktop: install_acp_runtime: failed to start {pubkey} after install: {e}"
);
// Persist last_error so the UI surfaces a diagnosable stopped state.
if let Err(save_err) = persist_last_error_on_install(app, pubkey, &e) {
eprintln!(
"buzz-desktop: install_acp_runtime: failed to persist last_error for {pubkey}: {save_err}"
);
}
InstallRestartOutcome::FailedAfterStop
}
}
}
}
/// Persist a `last_error` on the agent record under the store lock.
/// Best-effort: called only after a failed restart.
fn persist_last_error_on_install(
app: &tauri::AppHandle,
pubkey: &str,
error: &str,
) -> Result<(), String> {
use crate::{
app_state::AppState,
managed_agents::{find_managed_agent_mut, load_managed_agents, save_managed_agents},
};
use tauri::Manager;
let state = app.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| format!("failed to acquire store lock: {e}"))?;
let mut records = load_managed_agents(app)?;
let record = find_managed_agent_mut(&mut records, pubkey)?;
record.last_error = Some(error.to_string());
record.updated_at = crate::util::now_iso();
save_managed_agents(app, &records)
}
/// Build a login-shell `Command` for `command` with the hermit env vars
/// stripped and the user's PATH set. This is the single source of truth for
/// the shell selection and environment cleanup shared by `run_install_command`
@@ -871,4 +1225,122 @@ mod tests {
"non-codex runtime with resolved binary must not trigger reinstall"
);
}
// ── should_restart_after_install ─────────────────────────────────────────
/// Setup-mode agent on matching runtime that is now Ready → restart.
#[test]
fn test_should_restart_after_install_setup_mode_now_ready_is_candidate() {
assert!(
should_restart_after_install(true, true, true, true, true),
"setup-mode codex agent that became Ready must be restarted after install"
);
}
/// Setup-mode agent still NotReady after install (e.g. logged out) → no restart.
#[test]
fn test_should_restart_after_install_still_not_ready_is_not_candidate() {
assert!(
!should_restart_after_install(true, true, true, true, false),
"setup-mode agent still NotReady must NOT be restarted (would re-enter setup mode)"
);
}
/// Healthy in-pool agent (setup_mode=false) → no restart, even if now Ready.
#[test]
fn test_should_restart_after_install_healthy_agent_is_not_candidate() {
assert!(
!should_restart_after_install(true, true, true, false, true),
"healthy in-pool agent (setup_mode=false) must NOT be bounced on install"
);
}
/// Agent on a different runtime_id → no restart.
#[test]
fn test_should_restart_after_install_different_runtime_is_not_candidate() {
assert!(
!should_restart_after_install(true, true, false, true, true),
"agent on a different runtime must NOT be restarted by this install"
);
}
/// Remote/provider-backend agent → no restart (not local).
#[test]
fn test_should_restart_after_install_non_local_is_not_candidate() {
assert!(
!should_restart_after_install(false, true, true, true, true),
"non-local (provider-backend) agent must NOT be restarted"
);
}
/// Dead process (pid_alive=false) → no restart.
#[test]
fn test_should_restart_after_install_dead_pid_is_not_candidate() {
assert!(
!should_restart_after_install(true, false, true, true, true),
"agent whose process is no longer running must NOT be restarted"
);
}
// ── badge availability-drift (Phase 2) ───────────────────────────────────
//
// `availability_drift` is a pure predicate over two `Option` values —
// no global state, no parallelism hazard.
/// Both sides known and different → drift detected.
#[test]
fn test_availability_drift_detected_when_stamped_differs_from_current() {
use crate::managed_agents::{availability_drift, AcpAvailabilityStatus};
assert!(
availability_drift(
Some(&AcpAvailabilityStatus::Available),
Some(AcpAvailabilityStatus::AdapterOutdated),
),
"Available stamped vs AdapterOutdated current must be detected as drift"
);
}
/// Both sides known and equal → no drift.
#[test]
fn test_availability_drift_no_drift_when_stamped_equals_current() {
use crate::managed_agents::{availability_drift, AcpAvailabilityStatus};
assert!(
!availability_drift(
Some(&AcpAvailabilityStatus::Available),
Some(AcpAvailabilityStatus::Available),
),
"matching stamped and current must not show drift"
);
}
/// Stamped is None (cold cache at spawn) → no drift regardless of current.
#[test]
fn test_availability_drift_none_stamp_never_drifts() {
use crate::managed_agents::{availability_drift, AcpAvailabilityStatus};
assert!(
!availability_drift(None, Some(AcpAvailabilityStatus::Available)),
"None stamp (cold cache at spawn) must never signal drift"
);
}
/// Current is None (cache cold now) → no drift regardless of stamp.
#[test]
fn test_availability_drift_none_current_never_drifts() {
use crate::managed_agents::{availability_drift, AcpAvailabilityStatus};
assert!(
!availability_drift(Some(&AcpAvailabilityStatus::Available), None),
"None current (cache cold) must never signal drift"
);
}
/// Non-codex agent (stamp is None) → no drift (None case).
#[test]
fn test_availability_drift_non_codex_none_never_drifts() {
use crate::managed_agents::{availability_drift, AcpAvailabilityStatus};
// Non-codex agents have `adapter_availability = None` — must never flip.
assert!(
!availability_drift(None, Some(AcpAvailabilityStatus::AdapterMissing)),
"non-codex agent (None stamp) must never trigger drift badge"
);
}
}
@@ -498,6 +498,73 @@ pub fn resolve_command(command: &str) -> Option<PathBuf> {
pub fn clear_resolve_cache() {
let mut guard = resolve_cache().lock().unwrap_or_else(|e| e.into_inner());
guard.clear();
// Also invalidate the adapter-availability cache so a freshly-installed
// adapter is reflected the next time the summary builder checks the badge.
clear_adapter_availability_cache();
}
// ── Adapter availability cache (Phase-2 badge fallback) ─────────────────────
//
// `build_managed_agent_summary` needs to compare the spawn-time adapter
// availability against the *current* availability without triggering a live
// `probe_codex_acp_major_version` subprocess on every poll cycle. This cache
// stores the last availability status of the codex-acp binary at its resolved
// path. It is warmed by `discover_acp_runtimes` (which already probes), so
// the badge path reads warm data, and is invalidated by `clear_resolve_cache`
// (called on every Doctor install and every `discover_acp_providers` call).
fn adapter_availability_cache() -> &'static std::sync::Mutex<Option<AcpAvailabilityStatus>> {
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<Option<AcpAvailabilityStatus>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(None))
}
fn clear_adapter_availability_cache() {
if let Ok(mut guard) = adapter_availability_cache().lock() {
*guard = None;
}
}
/// Cache the current codex-acp adapter availability status.
///
/// Called by `discover_acp_runtimes` after it probes the codex adapter so the
/// badge path has a warm value without re-probing.
pub(crate) fn cache_adapter_availability(status: AcpAvailabilityStatus) {
if let Ok(mut guard) = adapter_availability_cache().lock() {
*guard = Some(status);
}
}
/// Return the most recently cached codex-acp adapter availability, or
/// `None` if no discovery has run yet.
///
/// This is a **read from cache only** — it never spawns a subprocess. The
/// value is populated by `discover_acp_runtimes` and invalidated by
/// `clear_resolve_cache`. When the cache is cold, returning `None` defers
/// the drift check until discovery has produced a real value, preventing
/// a fabricated `AdapterMissing` stamp from triggering a false restart badge
/// on a newly restarted process.
pub(crate) fn adapter_availability_cached() -> Option<AcpAvailabilityStatus> {
adapter_availability_cache()
.lock()
.ok()
.and_then(|g| g.clone())
}
/// Pure predicate: does the stamped adapter availability differ from the
/// current cached availability?
///
/// Returns `false` whenever either side is `None` (unknown) — "no data" is
/// not evidence of drift. This is extracted for unit testing without global
/// state and used by `build_managed_agent_summary`.
pub(crate) fn availability_drift(
stamped: Option<&AcpAvailabilityStatus>,
current: Option<AcpAvailabilityStatus>,
) -> bool {
match (stamped, current) {
(Some(s), Some(c)) => *s != c,
_ => false,
}
}
fn resolve_command_uncached(command: &str) -> Option<PathBuf> {
@@ -1046,6 +1113,13 @@ pub fn discover_acp_runtimes() -> Vec<AcpRuntimeCatalogEntry> {
}
}
// Warm the adapter-availability cache for the badge fallback.
// The cache is scoped to the codex runtime; other runtimes leave it
// unchanged. Invalidated by `clear_resolve_cache`.
if runtime.id == "codex" {
cache_adapter_availability(availability.clone());
}
let underlying_cli_path = runtime
.underlying_cli
.and_then(find_command)
@@ -134,6 +134,8 @@ pub fn finish_spawn(
child: std::process::Child,
log_path: std::path::PathBuf,
spawn_config_hash: u64,
setup_mode: bool,
adapter_availability: Option<super::AcpAvailabilityStatus>,
agent_name: &str,
) -> super::ManagedAgentProcess {
let job = create_job_for_child(child.id());
@@ -147,6 +149,8 @@ pub fn finish_spawn(
child,
log_path,
spawn_config_hash,
setup_mode,
adapter_availability,
job,
}
}
@@ -1321,18 +1321,29 @@ pub fn build_managed_agent_summary(
// at launch; recompute from current disk state and flag drift. Only a
// tracked live process can drift — stopped agents spawn fresh, and
// adopted (runtime_pid-only) processes have no stamped hash to compare.
//
// Additionally, for runtimes with an adapter version gate (codex only),
// check whether the cached adapter availability has drifted from the value
// stamped at spawn. This catches out-of-band adapter changes (manual
// npm install/downgrade) that Phase-1 auto-restart doesn't cover. The
// cache is read-only here — no subprocess is spawned.
let needs_restart = runtimes.get(&record.pubkey).is_some_and(|runtime| {
use tauri::Manager;
let state = app.state::<crate::app_state::AppState>();
let global_for_hash =
crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
runtime.spawn_config_hash
let hash_drift = runtime.spawn_config_hash
!= crate::managed_agents::spawn_hash::spawn_config_hash(
record,
personas,
&crate::relay::relay_ws_url_with_override(&state),
&global_for_hash,
)
);
let availability_drift = super::availability_drift(
runtime.adapter_availability.as_ref(),
super::adapter_availability_cached(),
);
hash_drift || availability_drift
});
// Resolve the effective harness the same way, then derive args/mcp from it,
@@ -1589,6 +1600,11 @@ pub fn spawn_agent_child(
//
// The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp:
// { "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] }
//
// `spawned_setup_mode` is captured outside the block so it can be stamped
// on `ManagedAgentProcess` — used by `install_acp_runtime` to target only
// stuck agents for auto-restart.
let spawned_setup_mode;
{
use crate::managed_agents::{
agent_readiness, resolve_effective_agent_env, AgentReadiness, Requirement,
@@ -1650,6 +1666,8 @@ pub fn spawn_agent_child(
None
};
spawned_setup_mode = setup_payload_json.is_some();
// Strip the key from the process-spawned command on every path.
// Two independent guards protect the invariant:
// 1. BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS, so
@@ -1860,6 +1878,20 @@ pub fn spawn_agent_child(
let spawn_config_hash =
super::spawn_hash::spawn_config_hash(record, &personas, &effective_relay_url, &global);
// Stamp the adapter availability for runtimes with a version gate (codex
// only). The summary builder compares this against the current cached value
// to detect out-of-band adapter changes after spawn (Phase-2 badge fallback).
// Non-codex runtimes get `None` — nothing changes for them.
// When the cache is cold (e.g. Doctor just installed and cleared the cache),
// `adapter_availability_cached()` returns `None`, so the stamp is `None` and
// the drift check is skipped until discovery warms the cache — preventing a
// false restart badge immediately after auto-restart.
let spawned_adapter_availability = if runtime_meta.is_some_and(|r| r.id == "codex") {
super::adapter_availability_cached()
} else {
None
};
let _ = super::write_agent_pid_file(app, &record.pubkey, child.id());
// Windows: assign the harness to a Job Object so its whole tree dies with
@@ -1869,6 +1901,8 @@ pub fn spawn_agent_child(
child,
log_path,
spawn_config_hash,
spawned_setup_mode,
spawned_adapter_availability,
&record.name,
));
#[cfg(not(windows))]
@@ -1876,6 +1910,8 @@ pub fn spawn_agent_child(
child,
log_path,
spawn_config_hash,
setup_mode: spawned_setup_mode,
adapter_availability: spawned_adapter_availability,
})
}
@@ -425,6 +425,18 @@ pub struct ManagedAgentProcess {
/// `runtime_pid` have no `ManagedAgentProcess` entry, so their spawn
/// config is unknown and the badge stays off.
pub spawn_config_hash: u64,
/// Whether this process was spawned in setup-listener mode (i.e.
/// `BUZZ_ACP_SETUP_PAYLOAD` was set at launch because the agent was
/// `NotReady`). Runtime-only — never persisted. Used by
/// `install_acp_runtime` to target only stuck agents for auto-restart,
/// excluding healthy in-pool agents.
pub setup_mode: bool,
/// Adapter availability status stamped at spawn time for runtimes with a
/// version gate (currently codex only; `None` for all others). Runtime-only
/// — never persisted. The summary builder compares this against the current
/// cached availability and sets `needs_restart` on drift, catching out-of-
/// band adapter changes that Phase-1 auto-restart doesn't cover.
pub adapter_availability: Option<AcpAvailabilityStatus>,
/// 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`
@@ -592,6 +604,12 @@ pub struct InstallStepResult {
pub struct InstallRuntimeResult {
pub success: bool,
pub steps: Vec<InstallStepResult>,
/// Number of local agents successfully stopped and restarted after a
/// successful install. Mirrors `GlobalAgentConfigSaveResult.restarted_count`.
pub restarted_count: u32,
/// Number of agents whose stop succeeded but respawn failed.
/// Mirrors `GlobalAgentConfigSaveResult.failed_restart_count`.
pub failed_restart_count: u32,
}
#[derive(Debug, Clone, Serialize)]
+1
View File
@@ -163,6 +163,7 @@ export function useInstallAcpRuntimeMutation() {
mutationFn: (runtimeId: string) => installAcpRuntime(runtimeId),
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey });
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
},
});
}
+4
View File
@@ -247,6 +247,8 @@ export type RawInstallStepResult = {
export type RawInstallRuntimeResult = {
success: boolean;
steps: RawInstallStepResult[];
restarted_count: number;
failed_restart_count: number;
};
type RawCommandAvailability = {
@@ -919,6 +921,8 @@ function fromRawInstallRuntimeResult(
exitCode: step.exit_code,
hint: step.hint,
})),
restartedCount: raw.restarted_count,
failedRestartCount: raw.failed_restart_count,
};
}
+2
View File
@@ -597,6 +597,8 @@ export type InstallStepResult = {
export type InstallRuntimeResult = {
success: boolean;
steps: InstallStepResult[];
restartedCount: number;
failedRestartCount: number;
};
export type CommandAvailability = {
+2
View File
@@ -6405,6 +6405,8 @@ async function handleInstallAcpRuntime(
exit_code: 0,
},
],
restarted_count: 0,
failed_restart_count: 0,
};
}