feat(desktop): restart-required badge from spawn-time config hash (#1602)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-07 14:53:58 -06:00
committed by GitHub
parent c5a541ee2d
commit cc42a49799
13 changed files with 384 additions and 21 deletions

View File

@@ -125,7 +125,9 @@ const overrides = new Map([
// config-bridge-aware requirements: getRuntimeFileConfig command adds ~15 lines.
// +26 lines from PRs landing on main between prior rebase and this rebase.
// baked-env-required-badge: getBakedBuildEnvKeys wrapper adds ~16 lines. Queued to split.
["src/shared/api/tauri.ts", 1388],
// restart-badge: started the queued split — start/stopManagedAgent moved to
// tauriManagedAgents.ts; limit ratcheted down 1388 → 1380 to bank the headroom.
["src/shared/api/tauri.ts", 1380],
// readiness-gate: PersonaDialog.tsx threads computeLocalModeGate +
// requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog
// shows required markers and credential amber rows (parity with

View File

@@ -22,6 +22,7 @@ mod repos;
mod restore;
pub mod retention;
mod runtime;
pub(crate) mod spawn_hash;
mod storage;
pub(crate) mod team_events;
mod team_repair;

View File

@@ -133,6 +133,7 @@ pub fn taskkill_tree(pid: u32) -> Result<(), String> {
pub fn finish_spawn(
child: std::process::Child,
log_path: std::path::PathBuf,
spawn_config_hash: u64,
agent_name: &str,
) -> super::ManagedAgentProcess {
let job = create_job_for_child(child.id());
@@ -145,6 +146,7 @@ pub fn finish_spawn(
super::ManagedAgentProcess {
child,
log_path,
spawn_config_hash,
job,
}
}

View File

@@ -1355,6 +1355,21 @@ pub fn build_managed_agent_summary(
// next to an "out of date" badge would contradict it.
let (persona_out_of_date, persona_orphaned) = persona_drift_state(record, personas);
// Restart badge: the running process stamped its effective spawn config
// 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.
let needs_restart = runtimes.get(&record.pubkey).is_some_and(|runtime| {
use tauri::Manager;
let state = app.state::<crate::app_state::AppState>();
runtime.spawn_config_hash
!= crate::managed_agents::spawn_hash::spawn_config_hash(
record,
personas,
&crate::relay::relay_ws_url_with_override(&state),
)
});
// Resolve the effective harness the same way, then derive args/mcp from it,
// so the UI reflects the persona's current harness (or an explicit pin).
let effective_command = crate::managed_agents::effective_agent_command(
@@ -1388,6 +1403,7 @@ pub fn build_managed_agent_summary(
provider: record.provider.clone(),
persona_out_of_date,
persona_orphaned,
needs_restart,
mcp_toolsets: record.mcp_toolsets.clone(),
env_vars: record.env_vars.clone(),
backend: record.backend.clone(),
@@ -1861,6 +1877,13 @@ pub fn spawn_agent_child(
)
})?;
// Stamp the effective spawn config so the summary builder can flag
// needs_restart when disk state drifts from what this process runs.
// `effective_relay_url` is already resolved, and resolution is idempotent,
// so it serves as the workspace-relay input here.
let spawn_config_hash =
super::spawn_hash::spawn_config_hash(record, &personas, &effective_relay_url);
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,10 +1892,15 @@ pub fn spawn_agent_child(
return Ok(super::process_lifecycle::finish_spawn(
child,
log_path,
spawn_config_hash,
&record.name,
));
#[cfg(not(windows))]
Ok(crate::managed_agents::ManagedAgentProcess { child, log_path })
Ok(crate::managed_agents::ManagedAgentProcess {
child,
log_path,
spawn_config_hash,
})
}
fn child_rust_log_filter() -> String {

View File

@@ -0,0 +1,111 @@
//! Spawn-time config hash for the restart-required badge.
//!
//! [`spawn_config_hash`] digests the *effective spawned values* — what a
//! process launch of `record` would actually receive — so the UI can compare
//! a running process's hash (stamped on [`super::ManagedAgentProcess`] at
//! spawn) against a recomputation from current disk state and show a
//! "restart required" badge only when a restart would change what runs.
//!
//! Scope rules (decided in #centralize-personas-and-agents, revised in PR
//! #1602 review):
//! - Inputs mirror what a start would actually run: the start/restore paths
//! re-snapshot the linked persona's prompt/model/provider/env onto the
//! record immediately before spawning (`start_local_agent_with_preflight`,
//! `restore_managed_agents_on_launch`), so persona edits to those fields DO
//! apply on a plain restart and are hashed via the same prospective
//! re-snapshot. Harness command, args/mcp, env layering, and the record
//! fields the spawn env writes read are hashed as spawn resolves them.
//! - The relay URL is hashed in resolved form (`effective_agent_relay_url`):
//! a record with a blank relay spawns against the active workspace relay,
//! so a workspace relay change means a restart would change what runs.
//! - Channel membership is not an input: agents pick up channel changes live
//! (#1468), never via restart.
//!
//! The hash never crosses a process or persistence boundary, so
//! `DefaultHasher` (not stable across Rust releases) is sufficient.
use std::hash::{DefaultHasher, Hash, Hasher};
use super::{
effective_agent_command, known_acp_runtime, normalize_agent_args,
persona_events::persona_snapshot_with_agent_config_fallback,
resolve_effective_agent_env,
types::{ManagedAgentRecord, PersonaRecord},
};
/// Digest the effective spawn configuration of `record` under the current
/// `personas`, resolving a blank record relay against `workspace_relay`.
/// Pure — no `AppHandle`, no disk, no keyring.
pub(crate) fn spawn_config_hash(
record: &ManagedAgentRecord,
personas: &[PersonaRecord],
workspace_relay: &str,
) -> u64 {
// Prospective re-snapshot: mirror the mutation start/restore apply to the
// record right before spawning, so the hash covers what a restart would
// actually run. Idempotent, so the spawn-time stamp (post-snapshot record)
// and later recomputes (persisted record) agree when nothing changed.
let mut record = record.clone();
if let Some(persona_id) = record.persona_id.clone() {
if let Some(persona) = personas.iter().find(|p| p.id == persona_id) {
let snapshot = persona_snapshot_with_agent_config_fallback(
persona,
&record.env_vars,
record.model.as_deref(),
record.provider.as_deref(),
);
if let Some(prompt) = snapshot.system_prompt {
record.system_prompt = Some(prompt);
}
record.model = snapshot.model;
record.provider = snapshot.provider;
record.env_vars = snapshot.env_vars;
}
}
let record = &record;
let effective_command = effective_agent_command(
record.persona_id.as_deref(),
personas,
record.agent_command_override.as_deref(),
);
let runtime_meta = known_acp_runtime(&effective_command);
let effective = resolve_effective_agent_env(record, personas, runtime_meta);
let mut hasher = DefaultHasher::new();
// Harness identity and derivations (live-persona-resolved, like spawn).
record.acp_command.hash(&mut hasher);
effective_command.hash(&mut hasher);
normalize_agent_args(&effective_command, record.agent_args.clone()).hash(&mut hasher);
runtime_meta
.and_then(|r| r.mcp_command)
.unwrap_or("")
.hash(&mut hasher);
// Effective env layering (baked floor → runtime metadata → user env).
// BTreeMap iteration is ordered, so this is deterministic.
effective.env.hash(&mut hasher);
// Record fields the spawn env writes read directly. The relay is hashed
// resolved: a blank record relay spawns on the workspace relay, so a
// workspace relay change must trip the badge.
crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher);
record.system_prompt.hash(&mut hasher);
record.model.hash(&mut hasher);
record.provider.hash(&mut hasher);
record.auth_tag.hash(&mut hasher);
record.respond_to.as_str().hash(&mut hasher);
record.respond_to_allowlist.hash(&mut hasher);
record.idle_timeout_seconds.hash(&mut hasher);
record.max_turn_duration_seconds.hash(&mut hasher);
record.parallelism.hash(&mut hasher);
record.mcp_toolsets.hash(&mut hasher);
record.persona_team_dir.hash(&mut hasher);
record.persona_name_in_team.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,177 @@
use super::*;
use std::collections::BTreeMap;
fn record() -> ManagedAgentRecord {
ManagedAgentRecord {
pubkey: "p".repeat(64),
name: "agent".into(),
persona_id: None,
private_key_nsec: "nsec1fake".into(),
auth_tag: None,
relay_url: "ws://localhost:3000".into(),
avatar_url: None,
acp_command: "buzz-acp".into(),
agent_command: "goose".into(),
agent_command_override: None,
agent_args: vec![],
mcp_command: String::new(),
turn_timeout_seconds: 320,
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
parallelism: 1,
system_prompt: Some("You are a test agent.".into()),
model: None,
provider: None,
persona_source_version: None,
mcp_toolsets: None,
env_vars: BTreeMap::new(),
start_on_app_launch: false,
runtime_pid: None,
backend: Default::default(),
backend_agent_id: None,
provider_binary_path: None,
persona_team_dir: None,
persona_name_in_team: None,
created_at: "now".into(),
updated_at: "now".into(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
respond_to: Default::default(),
respond_to_allowlist: vec![],
relay_mesh: None,
}
}
fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> PersonaRecord {
PersonaRecord {
id: id.into(),
display_name: id.into(),
avatar_url: None,
system_prompt: prompt.into(),
runtime: runtime.map(str::to_string),
model: None,
provider: None,
name_pool: vec![],
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: BTreeMap::new(),
created_at: "now".into(),
updated_at: "now".into(),
}
}
#[test]
fn hash_is_deterministic() {
let rec = record();
assert_eq!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&rec, &[], "wss://ws.example")
);
}
#[test]
fn record_env_var_edit_changes_hash() {
let rec = record();
let mut edited = record();
edited
.env_vars
.insert("SOME_KEY".into(), "some-value".into());
assert_ne!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}
#[test]
fn record_prompt_edit_changes_hash() {
let rec = record();
let mut edited = record();
edited.system_prompt = Some("Edited prompt.".into());
assert_ne!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}
#[test]
fn persona_runtime_edit_changes_hash() {
// The harness command resolves live personas at spawn, so a persona
// runtime change means a restart WOULD change what runs → badge trips.
let mut rec = record();
rec.persona_id = Some("pers".into());
let before = [persona("pers", Some("goose"), "prompt")];
let after = [persona("pers", Some("claude"), "prompt")];
assert_ne!(
spawn_config_hash(&rec, &before, "wss://ws.example"),
spawn_config_hash(&rec, &after, "wss://ws.example")
);
}
#[test]
fn persona_prompt_edit_changes_hash() {
// Start/restore re-snapshot the persona prompt onto the record right
// before spawning, so a persona prompt edit DOES apply on a plain
// restart → the badge must trip.
let mut rec = record();
rec.persona_id = Some("pers".into());
let before = [persona("pers", Some("goose"), "old prompt")];
let after = [persona("pers", Some("goose"), "new prompt")];
assert_ne!(
spawn_config_hash(&rec, &before, "wss://ws.example"),
spawn_config_hash(&rec, &after, "wss://ws.example")
);
}
#[test]
fn workspace_relay_change_trips_hash_for_blank_record_relay() {
// A blank record relay spawns against the active workspace relay, so a
// workspace relay change means a restart would change what runs.
let mut rec = record();
rec.relay_url = String::new();
assert_ne!(
spawn_config_hash(&rec, &[], "wss://relay-a.example"),
spawn_config_hash(&rec, &[], "wss://relay-b.example")
);
}
#[test]
fn workspace_relay_change_ignored_for_pinned_record_relay() {
// An explicit per-agent relay pins the agent regardless of workspace, so
// a workspace relay change must NOT badge a pinned agent.
let rec = record();
assert_eq!(
spawn_config_hash(&rec, &[], "wss://relay-a.example"),
spawn_config_hash(&rec, &[], "wss://relay-b.example")
);
}
#[test]
fn respond_to_allowlist_edit_changes_hash() {
let rec = record();
let mut edited = record();
edited.respond_to_allowlist = vec!["a".repeat(64)];
assert_ne!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}
#[test]
fn non_spawn_bookkeeping_fields_do_not_change_hash() {
// updated_at / runtime_pid / last_* are lifecycle bookkeeping, not spawn
// inputs — routine record saves must not trip the badge.
let rec = record();
let mut edited = record();
edited.updated_at = "later".into();
edited.runtime_pid = Some(12345);
edited.last_started_at = Some("later".into());
edited.last_exit_code = Some(0);
assert_eq!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}

View File

@@ -240,6 +240,13 @@ pub struct RelayMeshConfig {
pub struct ManagedAgentProcess {
pub child: Child,
pub log_path: PathBuf,
/// Digest of the effective spawn config at launch (see
/// `spawn_hash::spawn_config_hash`). Runtime-only — never persisted. The
/// summary builder recomputes the hash from current disk state and flags
/// `needs_restart` on mismatch. Agents adopted via a persisted
/// `runtime_pid` have no `ManagedAgentProcess` entry, so their spawn
/// config is unknown and the badge stays off.
pub spawn_config_hash: u64,
/// 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`
@@ -283,6 +290,13 @@ pub struct ManagedAgentSummary {
/// so the UI should not prompt a respawn — the pinned snapshot is all the
/// config that remains.
pub persona_orphaned: bool,
/// `true` when the running process was spawned with a config that no
/// longer matches what a spawn would use today — a plain restart would
/// change what runs. Complements `persona_out_of_date`: the badge means
/// "a restart would change what runs"; out-of-date means "a respawn
/// would." Always `false` for stopped agents and for processes adopted
/// via a persisted `runtime_pid` (their spawn config is unknown).
pub needs_restart: bool,
pub mcp_toolsets: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env_vars: BTreeMap<String, String>,

View File

@@ -12,9 +12,9 @@ import {
createManagedAgent,
getChannelMembers,
listManagedAgents,
startManagedAgent,
updateManagedAgent,
} from "@/shared/api/tauri";
import { startManagedAgent } from "@/shared/api/tauriManagedAgents";
import type {
AcpRuntime,
ChannelRole,

View File

@@ -21,10 +21,13 @@ import {
installAcpRuntime,
listManagedAgents,
listRelayAgents,
startManagedAgent,
stopManagedAgent,
updateManagedAgent,
} from "@/shared/api/tauri";
import {
setManagedAgentStartOnAppLaunch,
startManagedAgent,
stopManagedAgent,
} from "@/shared/api/tauriManagedAgents";
import {
createPersona,
deletePersona,
@@ -33,7 +36,6 @@ import {
setPersonaActive,
updatePersona,
} from "@/shared/api/tauriPersonas";
import { setManagedAgentStartOnAppLaunch } from "@/shared/api/tauriManagedAgents";
import {
createTeam,
deleteTeam,

View File

@@ -1,6 +1,11 @@
import * as React from "react";
import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react";
import {
AlertTriangle,
ChevronDown,
ChevronRight,
RefreshCw,
} from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
@@ -229,6 +234,12 @@ function AgentSummary({
<Badge variant="secondary">{personaLabel}</Badge>
) : null}
<AgentOriginBadge agent={agent} />
{agent.needsRestart ? (
<Badge className="gap-1" variant="warning">
<RefreshCw className="h-3 w-3" />
Restart required
</Badge>
) : null}
{agent.personaOutOfDate ? (
<Badge className="gap-1" variant="warning">
<AlertTriangle className="h-3 w-3" />
@@ -246,6 +257,12 @@ function AgentSummary({
<span>Remote deployment</span>
)}
</div>
{agent.needsRestart ? (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
Configuration changed since this agent started. Restart to apply
it.
</p>
) : null}
{agent.personaOutOfDate ? (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
Persona updated since this agent was created. Respawn to apply the

View File

@@ -215,6 +215,7 @@ export type RawManagedAgent = {
provider: string | null;
persona_out_of_date: boolean;
persona_orphaned: boolean;
needs_restart: boolean;
mcp_toolsets: string | null;
env_vars?: Record<string, string>;
status: ManagedAgent["status"];
@@ -991,6 +992,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
provider: agent.provider ?? null,
personaOutOfDate: agent.persona_out_of_date ?? false,
personaOrphaned: agent.persona_orphaned ?? false,
needsRestart: agent.needs_restart ?? false,
mcpToolsets: agent.mcp_toolsets,
envVars: agent.env_vars ?? {},
status: agent.status,
@@ -1161,20 +1163,6 @@ export async function createManagedAgent(input: CreateManagedAgentInput) {
};
}
export async function startManagedAgent(pubkey: string): Promise<ManagedAgent> {
const response = await invokeTauri<RawManagedAgent>("start_managed_agent", {
pubkey,
});
return fromRawManagedAgent(response);
}
export async function stopManagedAgent(pubkey: string): Promise<ManagedAgent> {
const response = await invokeTauri<RawManagedAgent>("stop_managed_agent", {
pubkey,
});
return fromRawManagedAgent(response);
}
export async function deleteManagedAgent(
pubkey: string,
forceRemoteDelete?: boolean,

View File

@@ -5,6 +5,20 @@ import {
} from "@/shared/api/tauri";
import type { ManagedAgent } from "@/shared/api/types";
export async function startManagedAgent(pubkey: string): Promise<ManagedAgent> {
const response = await invokeTauri<RawManagedAgent>("start_managed_agent", {
pubkey,
});
return fromRawManagedAgent(response);
}
export async function stopManagedAgent(pubkey: string): Promise<ManagedAgent> {
const response = await invokeTauri<RawManagedAgent>("stop_managed_agent", {
pubkey,
});
return fromRawManagedAgent(response);
}
export async function setManagedAgentStartOnAppLaunch(
pubkey: string,
startOnAppLaunch: boolean,

View File

@@ -398,6 +398,13 @@ export type ManagedAgent = {
* a respawn — the pinned snapshot is all the config that remains.
*/
personaOrphaned: boolean;
/**
* `true` when the running process was spawned with a config that no longer
* matches what a spawn would use today — a plain restart would change what
* runs. Complements `personaOutOfDate` ("a respawn would change it").
* Always `false` for stopped agents.
*/
needsRestart: boolean;
mcpToolsets: string | null;
/** Per-agent env vars. Layered on top of persona envVars. */
envVars: Record<string, string>;