Files
buzz/desktop/src-tauri/src/managed_agents/spawn_hash.rs
2026-07-12 19:30:21 -04:00

137 lines
6.4 KiB
Rust

//! 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::{
known_acp_runtime, normalize_agent_args,
persona_events::apply_persona_snapshot,
resolve_effective_agent_env,
types::{AgentDefinition, ManagedAgentRecord},
GlobalAgentConfig,
};
/// The prompt a spawn would actually deliver: `Some("")` collapses to `None`
/// because an empty BUZZ_ACP_SYSTEM_PROMPT is no prompt — EXCEPT for
/// team-pack records, where buzz-acp falls back to the pack persona's prompt
/// when the env var is absent, making set-but-empty a deliberate suppression.
///
/// The single source of truth for the spawn env write AND the config hash:
/// both call this, so they cannot disagree (the hash's contract is "digest
/// what a spawn would actually run"). B5 hash row 2 depends on the collapse:
/// backfilled prompt-less records re-snapshot to `Some("")` from their
/// manufactured definition and must not trip the restart badge.
pub(crate) fn effective_spawn_prompt(record: &ManagedAgentRecord) -> Option<String> {
let has_pack_fallback =
record.persona_team_dir.is_some() && record.persona_name_in_team.is_some();
record
.system_prompt
.clone()
.filter(|p| has_pack_fallback || !p.is_empty())
}
/// 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: &[AgentDefinition],
workspace_relay: &str,
global: &GlobalAgentConfig,
) -> u64 {
// Prospective re-snapshot: apply the same `apply_persona_snapshot` the
// start/restore paths run 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. The persona env itself reaches the hash through
// `resolve_effective_agent_env` below; `persona_source_version` is set on
// the clone but is not a hash input.
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) {
apply_persona_snapshot(&mut record, persona);
}
}
let record = &record;
let effective_command = crate::managed_agents::record_agent_command(record, personas);
let runtime_meta = known_acp_runtime(&effective_command);
let effective = resolve_effective_agent_env(record, personas, runtime_meta, global);
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);
// Prompt via the shared spawn-effective filter (see its doc for the
// Some("")/None collapse and the team-pack exception).
effective_spawn_prompt(record).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);
// The allowlist is hashed as the env receives it: spawn sets
// BUZZ_ACP_RESPOND_TO_ALLOWLIST only in allowlist mode, and normalized
// (trim/lowercase/dedup via `validate_respond_to_allowlist`) — so edits
// that don't survive normalization, or edits while another mode is
// active, must not badge. A list spawn would reject hashes raw: the
// stamped hash comes from a successful spawn, so any invalid edit
// correctly compares unequal.
if record.respond_to == super::types::RespondTo::Allowlist {
super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)
.unwrap_or_else(|_| record.respond_to_allowlist.clone())
.hash(&mut hasher);
}
record.idle_timeout_seconds.hash(&mut hasher);
// Spawn writes BUZZ_ACP_MAX_TURN_DURATION with a default.
record
.max_turn_duration_seconds
.unwrap_or(super::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS)
.hash(&mut hasher);
record.parallelism.hash(&mut hasher);
record.persona_team_dir.hash(&mut hasher);
record.persona_name_in_team.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests;