feat(desktop): show which config fields need a restart

The restart-required badge was driven by a lossy u64 digest of the
effective spawn config, so it could report that something drifted but
never what. Replace the digest with a typed SpawnConfigSnapshot whose
canonical JSON is the single representation both the badge and a new
redacted `restart_diff` read, making badge-on and diff-non-empty true
by construction rather than by convention.

The diff is a generic walk of that JSON, so a future snapshot field
reaches the UI with no diff-code change; the only per-field knowledge
is a path-based masking policy that also backs the snapshot's manual
Debug, keeping one redaction authority for env values, auth tags, CLI
args (`--token=` is legal), and relay URLs (the normalizer preserves
query strings).

The stamp now happens before spawn(), built from the values that
populated the Command. Re-resolving afterwards let an edit landing in
between stamp the new config onto a child running the old one.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-29 20:12:43 -04:00
parent 788b3c002b
commit 1cbebcc13b
17 changed files with 1291 additions and 435 deletions

View File

@@ -30,7 +30,7 @@ mod runtime;
mod runtime_commands;
mod runtime_types;
pub(crate) mod snapshot_avatar;
pub(crate) mod spawn_hash;
pub(crate) mod spawn_snapshot;
pub(crate) mod storage;
pub(crate) mod team_events;
mod team_repair;

View File

@@ -450,12 +450,12 @@ pub fn persona_snapshot(persona: &AgentDefinition) -> PersonaSnapshot {
/// This is the single apply used by every snapshot-apply site: the spawn
/// re-pin (`start_local_agent_with_preflight`), the launch backfill and
/// restore re-snapshot (`restore.rs`), and the prospective re-snapshot inside
/// `spawn_config_hash` — so a future `PersonaSnapshot` field addition
/// propagates to all of them at once.
/// `prospective_spawn_config_snapshot` — so a future `PersonaSnapshot` field
/// addition propagates to all of them at once.
///
/// Deliberately does NOT touch `updated_at`: persistence stamps are the
/// caller's concern, and `spawn_config_hash` (which applies this to a clone)
/// must stay pure.
/// caller's concern, and the prospective snapshot (which applies this to a
/// clone) must stay pure.
pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDefinition) {
let snapshot = persona_snapshot(persona);
if let Some(prompt) = snapshot.system_prompt {
@@ -498,8 +498,9 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe
/// paths re-pin it to its linked persona, without mutating `record` itself.
///
/// Every decision made ahead of the real re-pin — the relay-mesh preflight in
/// `start_local_agent_with_preflight`, the restart-badge hash in
/// `spawn_config_hash` — needs to reason about spawn-time state, not
/// `start_local_agent_with_preflight`, the restart-badge snapshot in
/// `prospective_spawn_config_snapshot` — needs to reason about spawn-time
/// state, not
/// pre-snapshot bytes, so a persona edit that flips a field (e.g. `provider`
/// to/from relay-mesh) between saves is reflected in the decision instead of
/// the stale value the real [`apply_persona_snapshot`] is about to overwrite
@@ -507,7 +508,7 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe
/// so the spawn-time stamp and later recomputes agree when nothing changed.
///
/// Orphaned records (persona deleted) pass through unchanged: the caller's
/// own orphan handling — refusing to spawn, hashing as `(None, None, None)`
/// own orphan handling — refusing to spawn, snapshotting as `(None, None, None)`
/// — runs on the real record downstream, not on this preview.
pub fn preview_prospective_persona_snapshot(
record: &ManagedAgentRecord,

View File

@@ -133,7 +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,
spawn_config: super::spawn_snapshot::SpawnConfigSnapshot,
setup_mode: bool,
adapter_availability: Option<super::AcpAvailabilityStatus>,
start_nonce: String,
@@ -149,7 +149,7 @@ pub fn finish_spawn(
super::ManagedAgentProcess {
child,
log_path,
spawn_config_hash,
spawn_config,
setup_mode,
adapter_availability,
start_nonce,

View File

@@ -82,7 +82,7 @@ pub(crate) struct EffectiveAgentEnv {
//
// A single owned type that fully describes what a spawn would run. Produced
// by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child,
// spawn_config_hash, build_managed_agent_summary, get_agent_models, and
// spawn_snapshot, build_managed_agent_summary, get_agent_models, and
// agent_readiness — so the harness-definition lookup and arg/env resolution
// happen exactly once, in one place.

View File

@@ -18,7 +18,9 @@ use tauri::Manager;
/// restore would kill reconcile's lazy child by its receipt and replace it with
/// an eager one, flipping the pair's laziness on a startup race.
enum SpawnOutcome {
Spawned(super::ManagedAgentRuntimeKey, ManagedAgentProcess),
/// Boxed: the spawned process carries its full spawn-config snapshot, so an
/// inline variant would make every `Skipped`/`Failed` outcome pay for it.
Spawned(super::ManagedAgentRuntimeKey, Box<ManagedAgentProcess>),
Skipped,
Failed(String),
}
@@ -338,7 +340,9 @@ pub async fn restore_managed_agents_on_launch(
owner_hex_ref,
)
}) {
Ok(process) => SpawnOutcome::Spawned(key, process),
Ok(process) => {
SpawnOutcome::Spawned(key, Box::new(process))
}
Err(error) => SpawnOutcome::Failed(error),
}
}
@@ -400,7 +404,7 @@ pub async fn restore_managed_agents_on_launch(
record.last_stopped_at = None;
record.last_exit_code = None;
record.last_error = None;
runtimes.insert(key, super::ManagedAgentPairRuntime::starting(process));
runtimes.insert(key, super::ManagedAgentPairRuntime::starting(*process));
successfully_spawned.push(pubkey);
}
SpawnOutcome::Failed(error) => {

View File

@@ -226,49 +226,47 @@ pub fn build_managed_agent_summary(
}
};
// Restart badge: the running process stamped its effective spawn config
// at launch; recompute from current disk state and flag drift. Only the
// tracked live pair for THIS workspace can drift — stopped agents spawn
// fresh, adopted (runtime_pid-only) processes have no stamped hash to
// compare, and pairs running for other communities are judged in their
// own community (hashing them against this workspace's relay would flag
// a spurious restart on every community switch).
// Restart badge: the running process stamped the effective spawn config
// it was launched with; recompute a prospective one from current disk
// state and report every differing field. Only the tracked live pair for
// THIS workspace can drift — stopped agents spawn fresh, adopted
// (runtime_pid-only) processes have no stamp to compare, and pairs running
// for other communities are judged in their own community (comparing them
// against this workspace's relay would flag a spurious restart on every
// community switch).
//
// 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.
// Adapter-availability drift (codex only) contributes its own synthetic
// entry, so an out-of-band adapter change (manual npm install/downgrade)
// that Phase-1 auto-restart doesn't cover still shows the user what moved.
// The cache is read-only here — no subprocess is spawned.
//
// Global config drives both the restart-drift hash and descriptor env
// layering below — the caller loads it once and passes it in, so
// Global config drives both the prospective snapshot and the descriptor
// env layering below — the caller loads it once and passes it in, so
// list-style callers pay one disk read per call rather than one per record.
let needs_restart = pair_key
let restart_diff = pair_key
.as_ref()
.and_then(|key| runtimes.get(key).map(|runtime| (key, runtime)))
.is_some_and(|(key, runtime)| {
let teams_for_hash = crate::managed_agents::load_teams(app).unwrap_or_default();
let hash_drift = runtime.spawn_config_hash
!= crate::managed_agents::spawn_hash::spawn_config_hash(
record,
personas,
&teams_for_hash,
&key.relay_url,
global_config,
);
let availability_drift = super::availability_drift(
.map(|(key, runtime)| {
let teams = crate::managed_agents::load_teams(app).unwrap_or_default();
let current = crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot(
record,
personas,
&teams,
&key.relay_url,
global_config,
);
crate::managed_agents::spawn_snapshot::eligible_restart_diff(
persona_orphaned,
&runtime.spawn_config,
&current,
runtime.adapter_availability.as_ref(),
super::adapter_availability_cached(),
);
// An orphan can never be restarted successfully —
// `spawn_agent_child` refuses it before any process side effect —
// so `needs_restart` must never fire for one regardless of hash or
// availability drift. Surfacing "Restart required" here would offer
// an action guaranteed to fail; the UI shows `persona_orphaned`
// instead (see `ManagedAgentSummary::persona_orphaned`).
restart_eligible(persona_orphaned, hash_drift, availability_drift)
});
)
})
.unwrap_or_default();
// One vector is the whole truth: badge on ⟺ there is a diff to show.
let needs_restart = !restart_diff.is_empty();
// Resolve the effective harness via the single typed descriptor — same resolver
// as spawn, so the UI reflects the persona's current harness (or explicit pin).
@@ -321,6 +319,7 @@ pub fn build_managed_agent_summary(
persona_out_of_date,
persona_orphaned,
needs_restart,
restart_diff,
env_vars: record.env_vars.clone(),
backend: record.backend.clone(),
backend_agent_id: record.backend_agent_id.clone(),
@@ -341,19 +340,6 @@ pub fn build_managed_agent_summary(
})
}
/// Pure predicate: should the "Restart required" badge fire?
///
/// An orphaned linked instance (its persona/definition no longer exists)
/// can never be restarted successfully — `spawn_agent_child` refuses to
/// spawn it before any process side effect. Surfacing "Restart required"
/// for one would offer an action guaranteed to fail, so this always
/// returns `false` for an orphan regardless of drift. Extracted for unit
/// testing without `AppHandle`/global state, following the
/// `availability_drift` pattern in `discovery.rs`.
fn restart_eligible(persona_orphaned: bool, hash_drift: bool, availability_drift: bool) -> bool {
!persona_orphaned && (hash_drift || availability_drift)
}
pub fn find_managed_agent_mut<'a>(
records: &'a mut [ManagedAgentRecord],
pubkey: &str,
@@ -474,7 +460,7 @@ pub fn spawn_agent_child(
let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
// Resolve model/provider/prompt ONCE, here, at the shared spawn boundary —
// the single source both the env writes below and `spawn_config_hash`
// the single source both the env writes below and the spawn-config snapshot
// read from. Previously prompt was read from the record's own (possibly
// stale, Phase-A-snapshot) bytes while model/provider were resolved live
// from `personas`; a definition edit landing between a caller's snapshot
@@ -491,8 +477,9 @@ pub fn spawn_agent_child(
// Single typed resolver: validates runtime id (dangling harness → Err), resolves
// command, args (instance wins over definition default), and the full env layer stack.
// This is the sole path for harness-definition lookup — spawn, hash, summary, and
// model probes all consume this descriptor rather than assembling values inline.
// This is the sole path for harness-definition lookup — spawn, snapshot,
// summary, and model probes all consume this descriptor rather than
// assembling values inline.
// Like the orphan refusal above, this runs before any side effect so a refused
// spawn leaves no trace.
let descriptor =
@@ -736,7 +723,7 @@ pub fn spawn_agent_child(
}
}
}
let team_instructions = super::spawn_hash::effective_team_instructions(record, &teams);
let team_instructions = super::spawn_snapshot::effective_team_instructions(record, &teams);
if let Some(instructions) = &team_instructions {
command.env("BUZZ_ACP_TEAM_INSTRUCTIONS", instructions);
} else {
@@ -744,8 +731,8 @@ pub fn spawn_agent_child(
}
// Prompt, model, and provider all come from the single `effective_cfg`
// resolved at the top of this function — the SAME resolve `spawn_config_hash`
// performs below, so env write and restart badge cannot disagree. Linked
// resolved at the top of this function — the SAME resolve the spawn-config
// snapshot reads, so env write and restart badge cannot disagree. Linked
// instances never consult the record's own model/provider/prompt bytes;
// definition-less instances fall back to their own fields, then global.
//
@@ -771,8 +758,9 @@ pub fn spawn_agent_child(
}
// Session title for the harness to pass out-of-band on `session/new`. The
// adapter names the session after it; it never reaches the prompt, so this
// is display metadata only. `spawn_config_hash` hashes the same resolve, so
// a rename raises the restart badge instead of leaving the process stale.
// is display metadata only. The spawn-config snapshot records the same
// resolve, so a rename raises the restart badge instead of leaving the
// process stale.
if let Some(title) = resolve_session_title(record.display_name.as_deref(), &record.name) {
command.env(SESSION_TITLE_ENV_VAR, title);
} else {
@@ -887,6 +875,22 @@ pub fn spawn_agent_child(
.env("BUZZ_MANAGED_AGENT", current_instance_id(app))
.env("BUZZ_MANAGED_AGENT_START_NONCE", &start_nonce);
// Stamp the effective spawn config from the values that populated the
// `Command` above, BEFORE spawning. Re-resolving after `spawn()` would let
// a persona/harness/global edit landing in between stamp the NEW config
// onto a child running the OLD one, silently suppressing the badge.
let spawn_config = super::spawn_snapshot::SpawnConfigSnapshot::from_inputs(
super::spawn_snapshot::SpawnConfigInputs {
record,
descriptor: &descriptor,
relay_url: &effective_relay_url,
team_instructions: team_instructions.as_deref(),
system_prompt: effective_prompt.as_deref(),
model: effective_model.as_deref(),
provider: effective_provider.as_deref(),
},
);
// Spawn the harness in its own process group so we can kill the entire
// tree (harness + MCP servers + agent subprocesses) on shutdown.
#[cfg(unix)]
@@ -912,18 +916,6 @@ 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,
&teams,
&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).
@@ -946,7 +938,7 @@ pub fn spawn_agent_child(
return Ok(super::process_lifecycle::finish_spawn(
child,
log_path,
spawn_config_hash,
spawn_config,
spawned_setup_mode,
spawned_adapter_availability,
start_nonce,
@@ -956,7 +948,7 @@ pub fn spawn_agent_child(
Ok(crate::managed_agents::ManagedAgentProcess {
child,
log_path,
spawn_config_hash,
spawn_config,
setup_mode: spawned_setup_mode,
adapter_availability: spawned_adapter_availability,
start_nonce,

View File

@@ -25,7 +25,7 @@ pub(crate) fn runtime_metadata_env_vars<'a>(
}
/// Env var carrying the session title to the harness. Shared with
/// `spawn_hash` so the restart badge hashes the same key the spawn writes.
/// `spawn_snapshot` so the restart badge records the same key the spawn writes.
pub(crate) const SESSION_TITLE_ENV_VAR: &str = "BUZZ_ACP_SESSION_TITLE";
/// Resolve the session title for an agent: its `display_name` when it has one,

View File

@@ -1271,7 +1271,13 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun
let process = crate::managed_agents::ManagedAgentProcess {
child,
log_path: std::path::PathBuf::new(),
spawn_config_hash: 0,
spawn_config: crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot(
&minimal_record(&"cc".repeat(32)),
&[],
&[],
"wss://relay.example",
&Default::default(),
),
setup_mode: false,
adapter_availability: None,
start_nonce: "test-nonce".to_string(),
@@ -1280,37 +1286,3 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun
};
crate::managed_agents::ManagedAgentPairRuntime::starting(process)
}
// ── restart_eligible tests ──────────────────────────────────────────────
#[test]
fn restart_eligible_true_when_non_orphan_has_hash_drift() {
assert!(super::restart_eligible(false, true, false));
}
#[test]
fn restart_eligible_true_when_non_orphan_has_availability_drift() {
assert!(super::restart_eligible(false, false, true));
}
#[test]
fn restart_eligible_false_when_orphan_has_hash_drift() {
// An orphan can never be restarted successfully — spawn refuses it —
// so hash drift alone must not surface "Restart required".
assert!(!super::restart_eligible(true, true, false));
}
#[test]
fn restart_eligible_false_when_orphan_has_availability_drift() {
assert!(!super::restart_eligible(true, false, true));
}
#[test]
fn restart_eligible_false_when_orphan_has_no_drift() {
assert!(!super::restart_eligible(true, false, false));
}
#[test]
fn restart_eligible_false_when_non_orphan_has_no_drift() {
assert!(!super::restart_eligible(false, false, false));
}

View File

@@ -1,160 +0,0 @@
//! 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`):
//! every record spawns against the active workspace relay (legacy per-record
//! pins are ignored), 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_config::{resolve_effective_config, EffectiveConfigResult},
known_acp_runtime, normalize_agent_args,
persona_events::preview_prospective_persona_snapshot,
runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR},
types::{AgentDefinition, ManagedAgentRecord, TeamRecord},
GlobalAgentConfig,
};
/// Resolve the current instructions for this instance's deployment-time team binding.
/// A deleted team deliberately degrades to no team section.
pub(crate) fn effective_team_instructions(
record: &ManagedAgentRecord,
teams: &[TeamRecord],
) -> Option<String> {
teams
.iter()
.find(|team| Some(team.id.as_str()) == record.team_id.as_deref())
.and_then(|team| team.instructions.as_deref())
.map(str::trim)
.filter(|instructions| !instructions.is_empty())
.map(str::to_string)
}
/// 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],
teams: &[TeamRecord],
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
// the descriptor's layered env below; `persona_source_version` is set on
// the clone but is not a hash input.
let record = preview_prospective_persona_snapshot(record, personas);
let record = &record;
// Resolve command, args, and env via the single typed descriptor — same path
// as spawn_agent_child. Dangling harness id falls back to the infallible
// record_agent_command (no-op: a dangling harness can't be spawned, so the
// hash never matters for that agent).
let descriptor =
crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global)
.unwrap_or_else(|_| {
let cmd = crate::managed_agents::record_agent_command(record, personas);
let args = normalize_agent_args(&cmd, record.agent_args.clone());
crate::managed_agents::readiness::EffectiveHarnessDescriptor {
command: cmd,
args,
env: Default::default(),
}
});
let runtime_meta = known_acp_runtime(&descriptor.command);
let mut hasher = DefaultHasher::new();
// Harness identity and derivations (live-persona-resolved, like spawn).
record.acp_command.hash(&mut hasher);
descriptor.command.hash(&mut hasher);
descriptor.args.hash(&mut hasher);
runtime_meta
.and_then(|r| r.mcp_command)
.unwrap_or("")
.hash(&mut hasher);
// Effective env layering (baked floor → runtime metadata → definition env
// → global → persona → agent). BTreeMap iteration is ordered, deterministic.
descriptor.env.hash(&mut hasher);
// Record fields the spawn env writes read directly. The relay is hashed
// resolved: every record spawns on the workspace relay (legacy pins
// ignored), so a workspace relay change must trip the badge.
crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher);
// Team instructions use the same resolver as spawn.
effective_team_instructions(record, teams).hash(&mut hasher);
// Prompt, model, and provider all come from ONE `resolve_effective_config`
// call — the SAME resolve `spawn_agent_child` performs for the env write,
// so env write and this badge cannot disagree. An orphaned link (missing
// definition) hashes as if all three were absent: `spawn_agent_child`
// refuses to spawn an orphan regardless, so this is a display-only
// convenience, not the spawn gate.
let (resolved_prompt, resolved_model, resolved_provider) =
match resolve_effective_config(record, personas, global) {
EffectiveConfigResult::Resolved(cfg) => {
(cfg.system_prompt.value, cfg.model.value, cfg.provider.value)
}
EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None),
};
resolved_prompt.hash(&mut hasher);
resolved_model.hash(&mut hasher);
resolved_provider.hash(&mut hasher);
// Session title: the same resolve `spawn_agent_child` performs for its env
// write, so a rename raises the restart badge. Skipped when a user env
// override shadows it — spawn writes the title BEFORE the user env layer,
// so the override is what actually runs, and it already reaches this hash
// through `descriptor.env` above. Hashing the record-derived value under an
// override would badge a rename that changes nothing.
let effective_session_title = (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR))
.then(|| resolve_session_title(record.display_name.as_deref(), &record.name))
.flatten();
effective_session_title.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);
record.max_turn_duration_seconds.hash(&mut hasher);
record.parallelism.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,263 @@
//! Spawn-time config snapshot for the restart-required badge.
//!
//! [`SpawnConfigSnapshot`] captures the *effective spawned values* — what a
//! process launch of a record would actually receive. The running process
//! stamps one on [`super::ManagedAgentProcess`] at spawn; the summary builder
//! recomputes a prospective one from current disk state and compares. Drift
//! means a restart would change what runs, and the field-by-field difference
//! is what the UI shows (see [`diff`]).
//!
//! 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 reach the prospective snapshot via the same
//! re-snapshot. Harness command, args/mcp, env layering, and the record
//! fields the spawn env writes read are captured as spawn resolves them.
//! - The relay URL is captured in resolved form (`effective_agent_relay_url`):
//! every record spawns against the active workspace relay (legacy per-record
//! pins are ignored), 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 snapshot never crosses a process or persistence boundary — it is
//! runtime state only, held on the running `ManagedAgentProcess`.
use std::collections::BTreeMap;
use serde::Serialize;
use super::{
effective_config::{resolve_effective_config, EffectiveConfigResult},
known_acp_runtime, normalize_agent_args,
persona_events::preview_prospective_persona_snapshot,
readiness::EffectiveHarnessDescriptor,
runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR},
types::{AgentDefinition, ManagedAgentRecord, TeamRecord},
GlobalAgentConfig,
};
pub(crate) mod diff;
pub(crate) use diff::{eligible_restart_diff, RestartDiffEntry};
/// Resolve the current instructions for this instance's deployment-time team binding.
/// A deleted team deliberately degrades to no team section.
pub(crate) fn effective_team_instructions(
record: &ManagedAgentRecord,
teams: &[TeamRecord],
) -> Option<String> {
teams
.iter()
.find(|team| Some(team.id.as_str()) == record.team_id.as_deref())
.and_then(|team| team.instructions.as_deref())
.map(str::trim)
.filter(|instructions| !instructions.is_empty())
.map(str::to_string)
}
/// The already-resolved values a spawn feeds into its `Command`.
///
/// Taking them rather than re-resolving is what makes the stamp describe the
/// process that was actually launched: a persona/harness/global edit landing
/// between spawn's resolution and the stamp can no longer suppress the badge.
pub(crate) struct SpawnConfigInputs<'a> {
pub record: &'a ManagedAgentRecord,
pub descriptor: &'a EffectiveHarnessDescriptor,
/// Resolved workspace/pair relay — never the record's legacy pin.
pub relay_url: &'a str,
pub team_instructions: Option<&'a str>,
pub system_prompt: Option<&'a str>,
pub model: Option<&'a str>,
pub provider: Option<&'a str>,
}
/// The effective spawn configuration of one managed-agent process.
///
/// Serialization invariants (load-bearing — the drift comparison and the diff
/// walk both read `canonical()`):
/// - plain derived `Serialize`: no `flatten`, no `skip_serializing_if`, no
/// custom or fallible field serializers, no colliding serialized names, so
/// every field is always present on both sides of a comparison;
/// - `Option::None` serializes as JSON `null`; a *missing* key is reserved for
/// dynamic-map membership (`env.<KEY>` added/removed);
/// - arrays are atomic leaves — `args` and `respond_to_allowlist` compare and
/// render whole, never element-wise.
///
/// `Debug` is implemented by hand: [`ManagedAgentProcess`] derives `Debug`, so
/// a derived impl here would print env values, auth tags, and CLI arguments.
///
/// [`ManagedAgentProcess`]: super::ManagedAgentProcess
#[derive(Clone, Serialize)]
pub(crate) struct SpawnConfigSnapshot {
/// The ACP harness binary the desktop launches (`buzz-acp`).
pub acp_command: String,
/// The effective agent command the harness drives.
pub command: String,
pub args: Vec<String>,
/// Catalog-derived from `command`; `""` when the runtime has none.
pub mcp_command: String,
/// Fully layered process env: baked floor -> runtime metadata ->
/// definition -> global -> persona -> agent.
pub env: BTreeMap<String, String>,
pub relay_url: String,
pub team_instructions: Option<String>,
pub system_prompt: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
/// `None` when a user env override shadows `BUZZ_ACP_SESSION_TITLE`: spawn
/// writes the title BEFORE the user env layer, so the override is what
/// actually runs and it already reaches this snapshot through `env`.
/// Capturing the record-derived value under an override would badge a
/// rename that changes nothing.
pub session_title: Option<String>,
pub auth_tag: Option<String>,
pub respond_to: String,
/// `None` outside allowlist mode — spawn sets
/// `BUZZ_ACP_RESPOND_TO_ALLOWLIST` only there, so edits to a dormant list
/// must not badge. Normalized (trim/lowercase/dedup) as the env receives
/// it, so edits that don't survive normalization must not badge either.
pub respond_to_allowlist: Option<Vec<String>>,
pub idle_timeout_seconds: Option<u64>,
pub max_turn_duration_seconds: Option<u64>,
pub parallelism: u32,
}
impl SpawnConfigSnapshot {
/// Assemble the snapshot from values a spawn has already resolved.
pub(crate) fn from_inputs(inputs: SpawnConfigInputs<'_>) -> Self {
let SpawnConfigInputs {
record,
descriptor,
relay_url,
team_instructions,
system_prompt,
model,
provider,
} = inputs;
Self {
acp_command: record.acp_command.clone(),
command: descriptor.command.clone(),
args: descriptor.args.clone(),
mcp_command: known_acp_runtime(&descriptor.command)
.and_then(|runtime| runtime.mcp_command)
.unwrap_or("")
.to_string(),
env: descriptor.env.clone(),
relay_url: relay_url.to_string(),
team_instructions: team_instructions.map(str::to_string),
system_prompt: system_prompt.map(str::to_string),
model: model.map(str::to_string),
provider: provider.map(str::to_string),
session_title: (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR))
.then(|| resolve_session_title(record.display_name.as_deref(), &record.name))
.flatten(),
auth_tag: record.auth_tag.clone(),
respond_to: record.respond_to.as_str().to_string(),
respond_to_allowlist: (record.respond_to == super::types::RespondTo::Allowlist).then(
|| {
// A list spawn would reject is captured raw: the stamped
// snapshot comes from a successful spawn, so any invalid
// edit correctly compares unequal.
super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)
.unwrap_or_else(|_| record.respond_to_allowlist.clone())
},
),
idle_timeout_seconds: record.idle_timeout_seconds,
max_turn_duration_seconds: record.max_turn_duration_seconds,
parallelism: record.parallelism,
}
}
/// Canonical JSON projection — the single representation both the drift
/// comparison and the diff walk read, so a lit badge always has a
/// non-empty diff and vice versa.
///
/// Infallible by the serialization invariants documented on the struct
/// (plain derive over strings, scalars, string maps, and string vectors);
/// a failure here is a broken invariant, never a runtime condition, so it
/// must not degrade into an empty diff.
pub(crate) fn canonical(&self) -> serde_json::Value {
serde_json::to_value(self).expect("SpawnConfigSnapshot serializes infallibly")
}
}
impl std::fmt::Debug for SpawnConfigSnapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"SpawnConfigSnapshot({})",
diff::redacted_canonical(&self.canonical())
)
}
}
/// Snapshot the effective spawn configuration `record` would get if it were
/// started right now under the current `personas`/`teams`/`global`, resolving
/// a blank record relay against `workspace_relay`.
///
/// Pure — no `AppHandle`, no disk, no keyring. This is the *prospective* side
/// of the comparison; the stamped side is built at spawn from the values that
/// actually fed the child's `Command`.
pub(crate) fn prospective_spawn_config_snapshot(
record: &ManagedAgentRecord,
personas: &[AgentDefinition],
teams: &[TeamRecord],
workspace_relay: &str,
global: &GlobalAgentConfig,
) -> SpawnConfigSnapshot {
// Prospective re-snapshot: apply the same `apply_persona_snapshot` the
// start/restore paths run right before spawning, so this describes what a
// restart would actually run. Idempotent, so a spawn-time stamp taken
// after those paths saved the record compares equal when nothing changed.
// The persona env itself arrives through the descriptor's layered env
// below; `persona_source_version` is set on the clone but is not an input.
let record = preview_prospective_persona_snapshot(record, personas);
let record = &record;
// Resolve command, args, and env via the single typed descriptor — same
// path as spawn_agent_child. Dangling harness id falls back to the
// infallible record_agent_command (no-op: a dangling harness can't be
// spawned, so the snapshot never matters for that agent).
let descriptor =
crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global)
.unwrap_or_else(|_| {
let command = crate::managed_agents::record_agent_command(record, personas);
let args = normalize_agent_args(&command, record.agent_args.clone());
EffectiveHarnessDescriptor {
command,
args,
env: Default::default(),
}
});
// Prompt, model, and provider all come from ONE `resolve_effective_config`
// call — the SAME resolve `spawn_agent_child` performs for the env write,
// so env write and this badge cannot disagree. An orphaned link (missing
// definition) resolves as if all three were absent: `spawn_agent_child`
// refuses to spawn an orphan regardless, and `eligible_restart_diff`
// suppresses the badge for one.
let (prompt, model, provider) = match resolve_effective_config(record, personas, global) {
EffectiveConfigResult::Resolved(cfg) => {
(cfg.system_prompt.value, cfg.model.value, cfg.provider.value)
}
EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None),
};
SpawnConfigSnapshot::from_inputs(SpawnConfigInputs {
record,
descriptor: &descriptor,
// Resolved, not stored: every record spawns on the workspace relay
// (legacy pins ignored), so a workspace relay change must badge.
relay_url: &crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay),
team_instructions: effective_team_instructions(record, teams).as_deref(),
system_prompt: prompt.as_deref(),
model: model.as_deref(),
provider: provider.as_deref(),
})
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,286 @@
//! Redacted field-by-field diff of two [`SpawnConfigSnapshot`]s.
//!
//! The walk is generic over the snapshot's canonical JSON: it compares leaves
//! by path and emits one entry per inequality. Adding a field to
//! [`SpawnConfigSnapshot`] therefore reaches the UI with no change here — the
//! only per-path knowledge in this module is [`policy_for`], which decides how
//! a leaf may be *shown*, never which leaves are compared.
//!
//! Raw values drive comparison; redaction happens strictly afterwards, when
//! the serializable entry is built. Comparing masked forms would let two
//! secrets with colliding suffixes read as "no drift".
use serde::Serialize;
use serde_json::{Map, Value};
use super::SpawnConfigSnapshot;
use crate::managed_agents::AcpAvailabilityStatus;
/// Synthetic field id for adapter-availability drift, which lives outside the
/// snapshot: it describes the environment around the process, not the config
/// the process was spawned with.
const ADAPTER_AVAILABILITY_FIELD: &str = "adapter_availability";
const MASK: &str = "••••";
/// One changed field. `field` is a dotted path built from serde field names,
/// with dynamic map keys appended verbatim (`env.OPENAI_API_KEY`). The UI
/// humanizes it generically and must never switch on its value.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RestartDiffEntry {
pub field: String,
pub change: RestartChange,
}
/// How a changed field is presented. The UI switches on `kind` — a closed set
/// — and renders any `field` path.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RestartChange {
/// Safe scalar or array shown verbatim. `null` means absent.
Value { before: Value, after: Value },
/// Large text shown as character counts only. `null` means absent.
Text {
before_chars: Option<usize>,
after_chars: Option<usize>,
},
/// Secret-bearing leaf. `null` means absent.
Masked {
before: Option<String>,
after: Option<String>,
},
/// Dynamic-map key present only on the new side. No payload — the value
/// would be secret-bearing and the key name alone is the useful signal.
Added,
/// Dynamic-map key present only on the old side.
Removed,
}
/// How a leaf at `path` may be displayed.
#[derive(Clone, Copy, PartialEq)]
enum MaskPolicy {
/// Shown verbatim.
Plain,
/// Character counts only.
Text,
/// `••••` plus the last four characters when longer than eight.
MaskedSuffix,
/// `••••` and nothing else.
MaskedBare,
}
/// The single redaction authority: the wire diff and the snapshot's `Debug`
/// both route every leaf through this.
///
/// A new snapshot field needs an arm here only if it can carry a credential or
/// is too large to render; everything else falls through to `Plain`.
fn policy_for(path: &str) -> MaskPolicy {
match path {
// Arbitrary user text — a rendered before/after would be unbounded as
// well as unreadable.
"system_prompt" | "team_instructions" => MaskPolicy::Text,
// Arbitrary CLI arguments: `--token=...` is legal, so no part of the
// value may be disclosed. Same for the relay URL — `normalize_relay_url`
// rejects userinfo but deliberately preserves query strings, so
// `wss://relay.example/ws?token=...` is a valid value.
"args" | "relay_url" => MaskPolicy::MaskedBare,
// NIP-OA auth tag: a credential, but a suffix tells the user which tag
// they are looking at.
"auth_tag" => MaskPolicy::MaskedSuffix,
// User/persona env values routinely carry API keys.
_ if path.starts_with("env.") => MaskPolicy::MaskedSuffix,
// Plain arm. Every path reaching it is already rendered verbatim in
// the runtime UI today:
// acp_command / command / mcp_command — resolved binary names
// session_title — display chrome
// model / provider — catalog ids
// respond_to / respond_to_allowlist — gate mode + pubkeys
// idle_timeout_seconds / max_turn_duration_seconds / parallelism
// — numeric limits
// adapter_availability — an enum variant name
_ => MaskPolicy::Plain,
}
}
/// `••••` plus the last four characters, or a bare `••••` when the value is
/// short enough that a suffix would disclose too much of it.
///
/// Character-based throughout: byte slicing can panic on a multi-byte value or
/// disclose the wrong suffix.
fn mask(value: &str) -> String {
let chars: Vec<char> = value.chars().collect();
match chars.len() {
len if len > 8 => format!("{MASK}{}", chars[len - 4..].iter().collect::<String>()),
_ => MASK.to_string(),
}
}
/// Character count of a text leaf; `None` when the leaf is absent.
fn char_count(value: &Value) -> Option<usize> {
match value {
Value::Null => None,
Value::String(text) => Some(text.chars().count()),
// Fail closed on an unexpected shape: count it, never show it.
other => Some(other.to_string().chars().count()),
}
}
/// Masked rendering of a leaf; `None` when the leaf is absent.
fn masked(policy: MaskPolicy, value: &Value) -> Option<String> {
match (policy, value) {
(_, Value::Null) => None,
(MaskPolicy::MaskedSuffix, Value::String(text)) => Some(mask(text)),
// Fail closed: an unexpected shape under a redacting policy still
// redacts rather than disclosing the raw value.
_ => Some(MASK.to_string()),
}
}
fn change_for(policy: MaskPolicy, before: &Value, after: &Value) -> RestartChange {
match policy {
MaskPolicy::Plain => RestartChange::Value {
before: before.clone(),
after: after.clone(),
},
MaskPolicy::Text => RestartChange::Text {
before_chars: char_count(before),
after_chars: char_count(after),
},
MaskPolicy::MaskedSuffix | MaskPolicy::MaskedBare => RestartChange::Masked {
before: masked(policy, before),
after: masked(policy, after),
},
}
}
/// Lexicographically sorted union of both maps' keys, so entry order — and
/// therefore the UI's "first N plus and-N-more" truncation — is stable.
fn key_union<'a>(before: &'a Map<String, Value>, after: &'a Map<String, Value>) -> Vec<&'a str> {
let mut keys: Vec<&str> = before
.keys()
.chain(after.keys())
.map(String::as_str)
.collect();
keys.sort_unstable();
keys.dedup();
keys
}
fn child_path(parent: &str, key: &str) -> String {
if parent.is_empty() {
key.to_string()
} else {
format!("{parent}.{key}")
}
}
fn walk(
path: &str,
before: Option<&Value>,
after: Option<&Value>,
out: &mut Vec<RestartDiffEntry>,
) {
match (before, after) {
(before, after) if before == after => {}
// Present on one side only. Struct fields are always present (`None`
// serializes as `null`), so this is dynamic-map membership.
(None, Some(_)) => out.push(RestartDiffEntry {
field: path.to_string(),
change: RestartChange::Added,
}),
(Some(_), None) => out.push(RestartDiffEntry {
field: path.to_string(),
change: RestartChange::Removed,
}),
(Some(Value::Object(before)), Some(Value::Object(after))) => {
for key in key_union(before, after) {
walk(&child_path(path, key), before.get(key), after.get(key), out);
}
}
// Everything else is a leaf: scalars, and arrays (atomic — `args`
// changed as a whole, never `args.0`).
(before, after) => out.push(RestartDiffEntry {
field: path.to_string(),
change: change_for(
policy_for(path),
before.unwrap_or(&Value::Null),
after.unwrap_or(&Value::Null),
),
}),
}
}
/// The redacted diff of two snapshots, in stable path order.
fn diff(before: &SpawnConfigSnapshot, after: &SpawnConfigSnapshot) -> Vec<RestartDiffEntry> {
let mut entries = Vec::new();
walk(
"",
Some(&before.canonical()),
Some(&after.canonical()),
&mut entries,
);
entries
}
fn availability_value(status: Option<&AcpAvailabilityStatus>) -> Value {
status
.and_then(|status| serde_json::to_value(status).ok())
.unwrap_or(Value::Null)
}
/// The final restart-diff for one tracked runtime — the single source of both
/// the wire field and the badge, which is `!result.is_empty()`.
///
/// Suppressed entirely for an orphaned instance: `spawn_agent_child` refuses
/// to spawn one before any side effect, so "Restart required" would offer an
/// action guaranteed to fail. The UI surfaces `persona_orphaned` instead.
pub(crate) fn eligible_restart_diff(
persona_orphaned: bool,
stamped: &SpawnConfigSnapshot,
current: &SpawnConfigSnapshot,
stamped_availability: Option<&AcpAvailabilityStatus>,
current_availability: Option<AcpAvailabilityStatus>,
) -> Vec<RestartDiffEntry> {
if persona_orphaned {
return Vec::new();
}
let mut entries = diff(stamped, current);
if crate::managed_agents::availability_drift(stamped_availability, current_availability.clone())
{
entries.push(RestartDiffEntry {
field: ADAPTER_AVAILABILITY_FIELD.to_string(),
change: RestartChange::Value {
before: availability_value(stamped_availability),
after: availability_value(current_availability.as_ref()),
},
});
}
entries
}
/// The canonical snapshot with every leaf passed through [`policy_for`],
/// rendered as JSON text. Backs `SpawnConfigSnapshot`'s manual `Debug` so a
/// log line can never disclose what the wire diff redacts.
pub(crate) fn redacted_canonical(value: &Value) -> String {
fn redact(path: &str, value: &Value) -> Value {
match value {
Value::Object(fields) => Value::Object(
fields
.iter()
.map(|(key, child)| (key.clone(), redact(&child_path(path, key), child)))
.collect(),
),
leaf => match policy_for(path) {
MaskPolicy::Plain => leaf.clone(),
MaskPolicy::Text => char_count(leaf).map_or(Value::Null, |count| {
Value::String(format!("<{count} chars>"))
}),
policy => masked(policy, leaf).map_or(Value::Null, Value::String),
},
}
}
redact("", value).to_string()
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,479 @@
use super::*;
use std::collections::{BTreeMap, BTreeSet};
const SECRET: &str = "sk-live-SENTINEL-0000";
const RELAY_WITH_TOKEN: &str = "wss://relay.example/ws?token=SENTINEL";
/// Every field populated, so mutating one to `None` is a real change and the
/// coverage guard below sees the full serialized key set.
fn base() -> SpawnConfigSnapshot {
SpawnConfigSnapshot {
acp_command: "buzz-acp".into(),
command: "goose".into(),
args: vec!["--mode".into(), "acp".into()],
mcp_command: "goose-mcp".into(),
env: BTreeMap::from([
("OPENAI_API_KEY".to_string(), SECRET.to_string()),
("BUZZ_LOG".to_string(), "info".to_string()),
]),
relay_url: "wss://relay.example".into(),
team_instructions: Some("Team says hello.".into()),
system_prompt: Some("You are a test agent.".into()),
model: Some("gpt-5".into()),
provider: Some("openai".into()),
session_title: Some("Fizz".into()),
auth_tag: Some("tag-abcdefgh".into()),
respond_to: "owner-only".into(),
respond_to_allowlist: Some(vec!["a".repeat(64)]),
idle_timeout_seconds: Some(600),
max_turn_duration_seconds: Some(7200),
parallelism: 1,
}
}
fn fields(entries: &[RestartDiffEntry]) -> Vec<&str> {
entries.iter().map(|entry| entry.field.as_str()).collect()
}
fn change_at<'a>(entries: &'a [RestartDiffEntry], field: &str) -> &'a RestartChange {
&entries
.iter()
.find(|entry| entry.field == field)
.unwrap_or_else(|| panic!("no entry for {field}; got {:?}", fields(entries)))
.change
}
/// One mutation per snapshot field, keyed by the diff path it must produce.
type Mutation = (&'static str, fn(&mut SpawnConfigSnapshot));
fn mutations() -> Vec<Mutation> {
vec![
("acp_command", |s| s.acp_command = "other-acp".into()),
("command", |s| s.command = "claude".into()),
("args", |s| s.args = vec!["--other".into()]),
("mcp_command", |s| s.mcp_command = String::new()),
("env.OPENAI_API_KEY", |s| {
s.env
.insert("OPENAI_API_KEY".into(), "sk-live-rotated-9999".into());
}),
("relay_url", |s| s.relay_url = "wss://other.example".into()),
("team_instructions", |s| s.team_instructions = None),
("system_prompt", |s| s.system_prompt = None),
("model", |s| s.model = None),
("provider", |s| s.provider = None),
("session_title", |s| s.session_title = None),
("auth_tag", |s| s.auth_tag = None),
("respond_to", |s| s.respond_to = "anyone".into()),
("respond_to_allowlist", |s| s.respond_to_allowlist = None),
("idle_timeout_seconds", |s| s.idle_timeout_seconds = None),
("max_turn_duration_seconds", |s| {
s.max_turn_duration_seconds = None
}),
("parallelism", |s| s.parallelism = 8),
]
}
#[test]
fn every_field_mutation_drifts_the_canonical_value_and_names_that_field() {
for (field, mutate) in mutations() {
let before = base();
let mut after = base();
mutate(&mut after);
assert_ne!(
before.canonical(),
after.canonical(),
"{field}: mutation must move the canonical value the badge compares"
);
assert_eq!(
fields(&diff(&before, &after)),
vec![field],
"{field}: mutation must produce exactly that field's entry"
);
// Both directions: `None -> Some` must be as visible as `Some -> None`.
assert_eq!(
fields(&diff(&after, &before)),
vec![field],
"{field}: reverse mutation must be equally visible"
);
}
}
#[test]
fn mutation_table_covers_every_serialized_field() {
let covered: BTreeSet<&str> = mutations()
.iter()
.map(|(field, _)| field.split('.').next().expect("non-empty path"))
.collect();
let canonical = base().canonical();
let serialized: BTreeSet<&str> = canonical
.as_object()
.expect("snapshot serializes as an object")
.keys()
.map(String::as_str)
.collect();
assert_eq!(
covered, serialized,
"add a mutation row for every new snapshot field"
);
}
#[test]
fn identical_snapshots_produce_no_entries() {
assert!(diff(&base(), &base()).is_empty());
}
#[test]
fn env_map_insertion_order_is_not_drift() {
let mut reordered = base();
reordered.env = base().env.into_iter().rev().collect();
assert!(diff(&base(), &reordered).is_empty());
}
#[test]
fn entries_are_ordered_lexicographically_by_path() {
let mut after = base();
after.parallelism = 4;
after.command = "claude".into();
after.env.insert("ZZZ".into(), "1".into());
after.env.insert("AAA".into(), "1".into());
assert_eq!(
fields(&diff(&base(), &after)),
vec!["command", "env.AAA", "env.ZZZ", "parallelism"]
);
}
// ── map membership vs. nullable struct fields ────────────────────────────
#[test]
fn env_key_insertion_is_added_without_a_payload() {
let mut after = base();
after.env.insert("NEW_KEY".into(), SECRET.into());
assert_eq!(
change_at(&diff(&base(), &after), "env.NEW_KEY"),
&RestartChange::Added
);
}
#[test]
fn env_key_removal_is_removed_without_a_payload() {
let mut after = base();
after.env.remove("BUZZ_LOG");
assert_eq!(
change_at(&diff(&base(), &after), "env.BUZZ_LOG"),
&RestartChange::Removed
);
}
#[test]
fn cleared_nullable_field_stays_a_value_change_not_a_removal() {
let mut after = base();
after.model = None;
assert_eq!(
change_at(&diff(&base(), &after), "model"),
&RestartChange::Value {
before: Value::String("gpt-5".into()),
after: Value::Null,
}
);
}
#[test]
fn array_field_changes_as_one_atomic_leaf() {
let mut after = base();
after.respond_to_allowlist = Some(vec!["b".repeat(64)]);
let entries = diff(&base(), &after);
assert_eq!(fields(&entries), vec!["respond_to_allowlist"]);
assert!(matches!(
change_at(&entries, "respond_to_allowlist"),
RestartChange::Value { .. }
));
}
// ── masking policy ───────────────────────────────────────────────────────
#[test]
fn env_value_longer_than_eight_chars_shows_a_four_char_suffix() {
let mut after = base();
after
.env
.insert("OPENAI_API_KEY".into(), "abcdefghi".into());
assert_eq!(
change_at(&diff(&base(), &after), "env.OPENAI_API_KEY"),
&RestartChange::Masked {
before: Some("••••0000".into()),
after: Some("••••fghi".into()),
}
);
}
#[test]
fn env_value_of_exactly_eight_chars_shows_no_suffix() {
let mut before = base();
before
.env
.insert("OPENAI_API_KEY".into(), "abcdefgh".into());
let mut after = before.clone();
after.env.insert("OPENAI_API_KEY".into(), "12345678".into());
assert_eq!(
change_at(&diff(&before, &after), "env.OPENAI_API_KEY"),
&RestartChange::Masked {
before: Some("••••".into()),
after: Some("••••".into()),
}
);
}
#[test]
fn masking_counts_characters_not_bytes() {
// Nine two-byte characters: a byte-based length test would call this
// short, and byte slicing the last four would split a code point.
let mut before = base();
before.env.insert("K".into(), "áéíóúàèìò".into());
let mut after = before.clone();
after.env.insert("K".into(), "áéíóúàèìá".into());
assert_eq!(
change_at(&diff(&before, &after), "env.K"),
&RestartChange::Masked {
before: Some("••••àèìò".into()),
after: Some("••••àèìá".into()),
}
);
}
#[test]
fn args_are_masked_without_any_suffix() {
let mut after = base();
after.args = vec![format!("--token={SECRET}")];
assert_eq!(
change_at(&diff(&base(), &after), "args"),
&RestartChange::Masked {
before: Some("••••".into()),
after: Some("••••".into()),
}
);
}
#[test]
fn relay_url_is_masked_without_any_suffix() {
let mut after = base();
after.relay_url = RELAY_WITH_TOKEN.into();
assert_eq!(
change_at(&diff(&base(), &after), "relay_url"),
&RestartChange::Masked {
before: Some("••••".into()),
after: Some("••••".into()),
}
);
}
#[test]
fn auth_tag_is_masked_with_a_suffix() {
let mut after = base();
after.auth_tag = Some("tag-ijklmnop".into());
assert_eq!(
change_at(&diff(&base(), &after), "auth_tag"),
&RestartChange::Masked {
before: Some("••••efgh".into()),
after: Some("••••mnop".into()),
}
);
}
#[test]
fn large_text_fields_report_character_counts_only() {
let mut after = base();
after.system_prompt = Some("Longer replacement prompt.".into());
after.team_instructions = None;
let entries = diff(&base(), &after);
assert_eq!(
change_at(&entries, "system_prompt"),
&RestartChange::Text {
before_chars: Some("You are a test agent.".chars().count()),
after_chars: Some("Longer replacement prompt.".chars().count()),
}
);
assert_eq!(
change_at(&entries, "team_instructions"),
&RestartChange::Text {
before_chars: Some("Team says hello.".chars().count()),
after_chars: None,
}
);
}
// ── secrecy sentinels ────────────────────────────────────────────────────
/// A snapshot whose every secret-bearing leaf carries a sentinel.
fn seeded_with_sentinels() -> SpawnConfigSnapshot {
let mut snapshot = base();
snapshot.relay_url = RELAY_WITH_TOKEN.into();
snapshot.args = vec![format!("--token={SECRET}")];
snapshot.auth_tag = Some(SECRET.into());
snapshot.env.insert("OPENAI_API_KEY".into(), SECRET.into());
snapshot
}
/// Every sentinel-bearing leaf changed, plus an added key, so each masking
/// arm has to redact a real value.
fn rotated_sentinels() -> SpawnConfigSnapshot {
let mut snapshot = seeded_with_sentinels();
snapshot.relay_url = format!("{RELAY_WITH_TOKEN}2");
snapshot.args = vec![format!("--token={SECRET}2")];
snapshot.auth_tag = Some(format!("{SECRET}2"));
snapshot
.env
.insert("OPENAI_API_KEY".into(), format!("{SECRET}2"));
snapshot.env.insert("ADDED".into(), SECRET.into());
snapshot
}
#[test]
fn no_sentinel_reaches_the_serialized_diff() {
let entries = diff(&seeded_with_sentinels(), &rotated_sentinels());
assert!(!entries.is_empty(), "fixture must actually drift");
let wire = serde_json::to_string(&entries).expect("diff serializes");
assert!(!wire.contains("SENTINEL"), "diff leaked a secret: {wire}");
assert!(
!wire.contains("token="),
"diff leaked a query token: {wire}"
);
}
#[test]
fn no_sentinel_reaches_snapshot_debug_output() {
let rendered = format!("{:?}", seeded_with_sentinels());
assert!(!rendered.contains("SENTINEL"), "Debug leaked: {rendered}");
assert!(!rendered.contains("token="), "Debug leaked: {rendered}");
// Large text is summarized rather than dumped.
assert!(!rendered.contains("You are a test agent."));
// Non-secret leaves stay legible, or the log line is useless.
assert!(rendered.contains("goose"));
}
#[test]
fn no_sentinel_reaches_the_owning_process_debug_output() {
// `ManagedAgentProcess` derives `Debug` and delegates to the snapshot's
// manual impl — this pins that the derive can never become the leak path.
#[cfg(unix)]
let program = "/usr/bin/true";
#[cfg(windows)]
let program = "true";
let child = std::process::Command::new(program)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn placeholder child");
let process = crate::managed_agents::ManagedAgentProcess {
child,
log_path: std::path::PathBuf::new(),
spawn_config: seeded_with_sentinels(),
setup_mode: false,
adapter_availability: None,
start_nonce: "test-nonce".to_string(),
#[cfg(windows)]
job: None,
};
let rendered = format!("{process:?}");
assert!(
!rendered.contains("SENTINEL"),
"process Debug leaked a secret"
);
assert!(
!rendered.contains("token="),
"process Debug leaked a query token"
);
}
// ── B1: the eligible vector is the single source of the badge ────────────
fn eligible(
orphaned: bool,
stamped: &SpawnConfigSnapshot,
current: &SpawnConfigSnapshot,
stamped_availability: Option<AcpAvailabilityStatus>,
current_availability: Option<AcpAvailabilityStatus>,
) -> (bool, Vec<RestartDiffEntry>) {
let entries = eligible_restart_diff(
orphaned,
stamped,
current,
stamped_availability.as_ref(),
current_availability,
);
(!entries.is_empty(), entries)
}
#[test]
fn no_drift_yields_no_badge_and_no_entries() {
let (needs_restart, entries) = eligible(false, &base(), &base(), None, None);
assert!(!needs_restart);
assert!(entries.is_empty());
}
#[test]
fn snapshot_drift_yields_a_badge_and_that_entry() {
let mut current = base();
current.model = Some("claude-4".into());
let (needs_restart, entries) = eligible(false, &base(), &current, None, None);
assert!(needs_restart);
assert_eq!(fields(&entries), vec!["model"]);
}
#[test]
fn availability_drift_alone_yields_a_badge_and_its_synthetic_entry() {
let (needs_restart, entries) = eligible(
false,
&base(),
&base(),
Some(AcpAvailabilityStatus::Available),
Some(AcpAvailabilityStatus::AdapterOutdated),
);
assert!(needs_restart);
assert_eq!(fields(&entries), vec!["adapter_availability"]);
assert_eq!(
change_at(&entries, "adapter_availability"),
&RestartChange::Value {
before: Value::String("available".into()),
after: Value::String("adapter_outdated".into()),
}
);
}
#[test]
fn orphan_with_snapshot_drift_yields_no_badge_and_no_entries() {
let mut current = base();
current.model = Some("claude-4".into());
let (needs_restart, entries) = eligible(true, &base(), &current, None, None);
assert!(!needs_restart);
assert!(entries.is_empty());
}
#[test]
fn orphan_with_availability_drift_yields_no_badge_and_no_entries() {
let (needs_restart, entries) = eligible(
true,
&base(),
&base(),
Some(AcpAvailabilityStatus::Available),
Some(AcpAvailabilityStatus::AdapterOutdated),
);
assert!(!needs_restart);
assert!(entries.is_empty());
}
#[test]
fn unstamped_availability_is_not_drift() {
// A runtime without a version gate stamps no availability; comparing that
// absence against a freshly cached value must not invent a badge.
let (needs_restart, entries) = eligible(
false,
&base(),
&base(),
None,
Some(AcpAvailabilityStatus::AdapterOutdated),
);
assert!(!needs_restart);
assert!(entries.is_empty());
}

View File

@@ -2,6 +2,19 @@ use super::*;
use crate::managed_agents::types::RespondTo;
use std::collections::BTreeMap;
/// Canonical projection of a prospective snapshot — the exact value the drift
/// comparison reads, so these tests assert on drift itself rather than on a
/// proxy for it.
fn snapshot(
record: &ManagedAgentRecord,
personas: &[AgentDefinition],
teams: &[TeamRecord],
workspace_relay: &str,
global: &GlobalAgentConfig,
) -> serde_json::Value {
prospective_spawn_config_snapshot(record, personas, teams, workspace_relay, global).canonical()
}
fn record() -> ManagedAgentRecord {
ManagedAgentRecord {
pubkey: "p".repeat(64),
@@ -86,22 +99,22 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition {
}
#[test]
fn hash_is_deterministic() {
fn snapshot_is_deterministic() {
let rec = record();
assert_eq!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default())
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn materializing_runtime_keeps_hash_stable() {
fn materializing_runtime_keeps_snapshot_stable() {
// Migration cutover invariant (Phase 1A): materializing the linked
// persona's runtime onto the record must NOT change the spawn hash —
// persona's runtime onto the record must NOT change the spawn snapshot —
// otherwise every running persona-linked agent would show a spurious
// restart badge right after migration. Pre-migration the command resolves
// through the persona fallback; post-migration through record.runtime.
// Same persona, same runtime, same command → same hash.
// Same persona, same runtime, same command → equal snapshots.
let personas = vec![persona("p1", Some("goose"), "Persona prompt.")];
let mut pre = record();
@@ -111,14 +124,14 @@ fn materializing_runtime_keeps_hash_stable() {
post.runtime = Some("goose".into());
assert_eq!(
spawn_config_hash(
snapshot(
&pre,
&personas,
&[],
"wss://ws.example",
&Default::default()
),
spawn_config_hash(
snapshot(
&post,
&personas,
&[],
@@ -129,31 +142,31 @@ fn materializing_runtime_keeps_hash_stable() {
}
#[test]
fn record_env_var_edit_changes_hash() {
fn record_env_var_edit_changes_snapshot() {
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", &Default::default()),
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&edited, &[], &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn record_prompt_edit_changes_hash() {
fn record_prompt_edit_changes_snapshot() {
let rec = record();
let mut edited = record();
edited.system_prompt = Some("Edited prompt.".into());
assert_ne!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&edited, &[], &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn persona_runtime_edit_changes_hash() {
fn persona_runtime_edit_changes_snapshot() {
// 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();
@@ -161,13 +174,13 @@ fn persona_runtime_edit_changes_hash() {
let before = [persona("pers", Some("goose"), "prompt")];
let after = [persona("pers", Some("claude"), "prompt")];
assert_ne!(
spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default())
snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &after, &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn persona_prompt_edit_changes_hash() {
fn persona_prompt_edit_changes_snapshot() {
// 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.
@@ -176,13 +189,13 @@ fn persona_prompt_edit_changes_hash() {
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", &Default::default()),
spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default())
snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &after, &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn workspace_relay_change_trips_hash_even_for_stored_record_relay() {
fn workspace_relay_change_trips_snapshot_even_for_stored_record_relay() {
// The legacy per-record relay pin is ignored (#2122): every record spawns
// against the active workspace relay, so a workspace relay change means a
// restart would change what runs — pinned records included.
@@ -192,13 +205,13 @@ fn workspace_relay_change_trips_hash_even_for_stored_record_relay() {
"fixture should carry a legacy pin"
);
assert_ne!(
spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()),
spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default())
snapshot(&rec, &[], &[], "wss://relay-a.example", &Default::default()),
snapshot(&rec, &[], &[], "wss://relay-b.example", &Default::default())
);
}
#[test]
fn stored_record_relay_does_not_affect_hash() {
fn stored_record_relay_does_not_affect_snapshot() {
// Editing the (ignored) stored pin must not badge a restart: what a
// restart would run is identical either way.
let mut a = record();
@@ -206,20 +219,20 @@ fn stored_record_relay_does_not_affect_hash() {
a.relay_url = String::new();
b.relay_url = "wss://legacy-pin.example".into();
assert_eq!(
spawn_config_hash(&a, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&b, &[], &[], "wss://ws.example", &Default::default())
snapshot(&a, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&b, &[], &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn respond_to_allowlist_edit_changes_hash() {
fn respond_to_allowlist_edit_changes_snapshot() {
let rec = record();
let mut edited = record();
edited.respond_to = RespondTo::Allowlist;
edited.respond_to_allowlist = vec!["a".repeat(64)];
assert_ne!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&edited, &[], &[], "wss://ws.example", &Default::default())
);
}
@@ -231,13 +244,13 @@ fn allowlist_ignored_when_mode_is_not_allowlist() {
let mut edited = record();
edited.respond_to_allowlist = vec!["a".repeat(64)];
assert_eq!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&edited, &[], &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn allowlist_normalization_equivalent_edits_do_not_change_hash() {
fn allowlist_normalization_equivalent_edits_do_not_change_snapshot() {
// The env receives the normalized list (trim/lowercase/dedup), so edits
// that normalize to the same value must not badge.
let mut rec = record();
@@ -249,48 +262,48 @@ fn allowlist_normalization_equivalent_edits_do_not_change_hash() {
"a".repeat(64), // duplicate
];
assert_eq!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&edited, &[], &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn allowlist_content_edit_still_changes_hash() {
fn allowlist_content_edit_still_changes_snapshot() {
let mut rec = record();
rec.respond_to = RespondTo::Allowlist;
rec.respond_to_allowlist = vec!["a".repeat(64)];
let mut edited = rec.clone();
edited.respond_to_allowlist = vec!["b".repeat(64)];
assert_ne!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&edited, &[], &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn explicit_max_turn_duration_changes_hash_from_none() {
fn explicit_max_turn_duration_changes_snapshot_from_none() {
let rec = record();
let mut edited = record();
edited.max_turn_duration_seconds = Some(7200);
assert_ne!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&edited, &[], &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn non_default_max_turn_duration_changes_hash() {
fn non_default_max_turn_duration_changes_snapshot() {
let rec = record();
let mut edited = record();
edited.max_turn_duration_seconds = Some(42);
assert_ne!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&edited, &[], &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn non_spawn_bookkeeping_fields_do_not_change_hash() {
fn non_spawn_bookkeeping_fields_do_not_change_snapshot() {
// updated_at / runtime_pid / last_* are lifecycle bookkeeping, not spawn
// inputs — routine record saves must not trip the badge.
let rec = record();
@@ -300,17 +313,17 @@ fn non_spawn_bookkeeping_fields_do_not_change_hash() {
edited.last_started_at = Some("later".into());
edited.last_exit_code = Some(0);
assert_eq!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&edited, &[], &[], "wss://ws.example", &Default::default())
);
}
#[test]
fn resnapshot_does_not_clobber_record_quad_with_definition_absent_quad() {
// B5 hash row 3: the prospective re-snapshot copies ONLY
// B5 drift row 3: the prospective re-snapshot copies ONLY
// prompt/model/provider/env from the linked definition. An instance
// whose owner hand-set respond_to/allowlist/parallelism must
// hash identically whether or not its definition carries a quad —
// snapshot identically whether or not its definition carries a quad —
// activation of the definition-level defaults must never reach through
// spawn and overwrite instance state.
let quadless_definition = vec![persona("p1", Some("goose"), "Persona prompt.")];
@@ -326,44 +339,44 @@ fn resnapshot_does_not_clobber_record_quad_with_definition_absent_quad() {
definition_with_quad[0].parallelism = Some(8);
assert_eq!(
spawn_config_hash(
snapshot(
&rec,
&quadless_definition,
&[],
"wss://ws.example",
&Default::default()
),
spawn_config_hash(
snapshot(
&rec,
&definition_with_quad,
&[],
"wss://ws.example",
&Default::default()
),
"definition quad must not leak into the spawn hash of an existing instance"
"definition quad must not leak into the spawn snapshot of an existing instance"
);
}
#[test]
fn empty_prompt_hashes_like_absent_prompt() {
// B5 hash row 2 foundation: Some("") and None spawn identically (env var
// absent either way), so they must hash equal — a backfilled prompt-less
fn empty_prompt_snapshots_like_absent_prompt() {
// B5 drift row 2 foundation: Some("") and None spawn identically (env var
// absent either way), so they must snapshot equal — a backfilled prompt-less
// record re-snapshots to Some("") and must not trip the badge.
let mut absent = record();
absent.system_prompt = None;
let mut empty = record();
empty.system_prompt = Some(String::new());
assert_eq!(
spawn_config_hash(&absent, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&empty, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&absent, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&empty, &[], &[], "wss://ws.example", &Default::default()),
);
}
/// (a) A definition-runtime edit must change spawn_config_hash for a
/// (a) A definition-runtime edit must change the snapshot for a
/// materialized, override-free record — the prospective re-snapshot now
/// copies the persona's runtime onto the record before hashing.
/// copies the persona's runtime onto the record before snapshotting.
#[test]
fn definition_runtime_edit_changes_hash_for_materialized_record() {
fn definition_runtime_edit_changes_snapshot_for_materialized_record() {
let mut rec = record();
rec.persona_id = Some("pers".into());
rec.runtime = Some("goose".into()); // materialized runtime on instance
@@ -371,8 +384,8 @@ fn definition_runtime_edit_changes_hash_for_materialized_record() {
let before = [persona("pers", Some("goose"), "prompt")];
let after = [persona("pers", Some("claude"), "prompt")];
assert_ne!(
spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()),
"definition runtime edit must badge a materialized, override-free instance"
);
}
@@ -389,8 +402,8 @@ fn known_runtime_pin_yields_to_definition_runtime_change() {
let before = [persona("pers", Some("goose"), "prompt")];
let after = [persona("pers", Some("claude"), "prompt")];
assert_ne!(
spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()),
"stale known-runtime pin must not shadow a definition runtime edit"
);
}
@@ -407,16 +420,16 @@ fn custom_command_override_beats_definition_runtime_change() {
let before = [persona("pers", Some("goose"), "prompt")];
let after = [persona("pers", Some("claude"), "prompt")];
assert_eq!(
spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()),
"custom command override must win regardless of definition runtime change"
);
}
/// (d) When the linked definition is absent the prospective re-snapshot is
/// skipped entirely: the materialized runtime must still affect the hash.
/// skipped entirely: the materialized runtime must still reach the snapshot.
#[test]
fn missing_definition_leaves_materialized_runtime_in_hash() {
fn missing_definition_leaves_materialized_runtime_in_snapshot() {
let mut rec = record();
rec.persona_id = Some("missing".into());
rec.runtime = Some("goose".into()); // materialized runtime
@@ -427,28 +440,28 @@ fn missing_definition_leaves_materialized_runtime_in_hash() {
no_runtime.runtime = None;
assert_ne!(
spawn_config_hash(
snapshot(
&rec,
no_personas,
&[],
"wss://ws.example",
&Default::default()
),
spawn_config_hash(
snapshot(
&no_runtime,
no_personas,
&[],
"wss://ws.example",
&Default::default()
),
"materialized runtime must still affect hash when definition is absent"
"materialized runtime must still reach the snapshot when definition is absent"
);
}
// ── Global default trips hash for linked inherited agents ─────────────────
// ── Global default trips drift for linked inherited agents ───────────────
#[test]
fn global_model_change_trips_hash_for_linked_inherited_agent() {
fn global_model_change_trips_snapshot_for_linked_inherited_agent() {
let mut rec = record();
rec.persona_id = Some("p1".into());
rec.model = Some("stale-record-model".into());
@@ -466,17 +479,17 @@ fn global_model_change_trips_hash_for_linked_inherited_agent() {
..Default::default()
};
let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a);
let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b);
let snapshot_a = snapshot(&rec, &personas, &[], "wss://ws.example", &global_a);
let snapshot_b = snapshot(&rec, &personas, &[], "wss://ws.example", &global_b);
assert_ne!(
hash_a, hash_b,
"changing the global default must trip the hash for a linked inherited agent"
snapshot_a, snapshot_b,
"changing the global default must drift a linked inherited agent"
);
}
#[test]
fn global_model_change_trips_hash_without_model_env_var() {
fn global_model_change_trips_snapshot_without_model_env_var() {
let mut rec = record();
rec.persona_id = Some("p1".into());
rec.agent_command = "some-harness-without-model-env".into();
@@ -497,26 +510,26 @@ fn global_model_change_trips_hash_without_model_env_var() {
..Default::default()
};
let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a);
let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b);
let snapshot_a = snapshot(&rec, &personas, &[], "wss://ws.example", &global_a);
let snapshot_b = snapshot(&rec, &personas, &[], "wss://ws.example", &global_b);
assert_ne!(
hash_a, hash_b,
"global model change must trip hash even without a model_env_var runtime"
snapshot_a, snapshot_b,
"global model change must drift even without a model_env_var runtime"
);
}
#[test]
fn linked_instance_stale_prompt_bytes_are_inert_at_hash_time() {
fn linked_instance_stale_prompt_bytes_are_inert_at_snapshot_time() {
// Regression for the split-resolve defect: prompt used to be read from
// the record's own (possibly Phase-A-snapshot-stale) bytes while
// model/provider were resolved live from the definition. A definition
// edit landing between a caller's snapshot apply and spawn could hand a
// fresh model/provider to a stale prompt, and the hash (which already
// fresh model/provider to a stale prompt, and the drift check (which already
// resolved model/provider live) would silently agree with a spawn that
// wrote the stale prompt. Now both come from one `resolve_effective_config`
// call, so a record whose own `system_prompt` bytes disagree with the
// live definition must hash exactly as if the record carried the
// live definition must snapshot exactly as if the record carried the
// definition's prompt verbatim — the record's prompt bytes are inert for
// a linked instance.
let mut rec = record();
@@ -529,26 +542,26 @@ fn linked_instance_stale_prompt_bytes_are_inert_at_hash_time() {
let personas = [persona("p1", Some("goose"), "live prompt")];
assert_eq!(
spawn_config_hash(
snapshot(
&rec,
&personas,
&[],
"wss://ws.example",
&Default::default()
),
spawn_config_hash(
snapshot(
&matching_bytes,
&personas,
&[],
"wss://ws.example",
&Default::default()
),
"record's own system_prompt bytes must not affect the hash of a linked instance"
"record's own system_prompt bytes must not affect the snapshot of a linked instance"
);
}
#[test]
fn display_name_edit_changes_hash() {
fn display_name_edit_changes_snapshot() {
// The spawn writes BUZZ_ACP_SESSION_TITLE from display_name-or-name, so a
// rename must trip the badge: the running process keeps the old title
// until it restarts, and the operator has to be told that.
@@ -556,32 +569,32 @@ fn display_name_edit_changes_hash() {
let mut renamed = record();
renamed.display_name = Some("Fizz".into());
assert_ne!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&renamed, &[], &[], "wss://ws.example", &Default::default()),
"a display-name rename changes the spawned session title and must badge"
);
}
#[test]
fn name_edit_changes_hash_when_display_name_is_absent() {
fn name_edit_changes_snapshot_when_display_name_is_absent() {
// With no display_name the title falls back to the unique handle, so the
// handle is what the env write carries and what must be hashed.
// handle is what the env write carries and what must be snapshotted.
let rec = record();
let mut renamed = record();
renamed.name = "agent-2".into();
assert_ne!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()),
"the fallback title source must reach the hash too"
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&renamed, &[], &[], "wss://ws.example", &Default::default()),
"the fallback title source must reach the snapshot too"
);
}
#[test]
fn display_name_edit_does_not_change_hash_under_an_explicit_title_override() {
fn display_name_edit_does_not_change_snapshot_under_an_explicit_title_override() {
// User env is written AFTER the Buzz-set title (last-wins), so an explicit
// BUZZ_ACP_SESSION_TITLE is what the child actually runs with. Renaming the
// record changes nothing about the spawned process, so badging it would be
// a false restart prompt. The override itself still reaches the hash
// a false restart prompt. The override itself still reaches the snapshot
// through the effective env.
let mut rec = record();
rec.env_vars
@@ -589,14 +602,14 @@ fn display_name_edit_does_not_change_hash_under_an_explicit_title_override() {
let mut renamed = rec.clone();
renamed.display_name = Some("Fizz".into());
assert_eq!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&renamed, &[], &[], "wss://ws.example", &Default::default()),
"a rename shadowed by an explicit title override must not badge"
);
}
#[test]
fn title_override_edit_changes_hash() {
fn title_override_edit_changes_snapshot() {
// Counterpart to the test above: the override is not inert — editing it
// changes what the child runs with and must badge.
let mut rec = record();
@@ -607,8 +620,8 @@ fn title_override_edit_changes_hash() {
.env_vars
.insert("BUZZ_ACP_SESSION_TITLE".into(), "Other Title".into());
assert_ne!(
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()),
"editing an explicit title override must badge"
);
}
@@ -616,7 +629,7 @@ fn title_override_edit_changes_hash() {
#[test]
fn linked_instance_prompt_model_provider_resolve_from_one_call() {
// The prompt for a linked instance must track the definition, exactly
// like model/provider — a definition prompt edit trips the hash even
// like model/provider — a definition prompt edit drifts the snapshot even
// though the record's own (stale) system_prompt bytes are unchanged.
let mut rec = record();
rec.persona_id = Some("p1".into());
@@ -626,25 +639,25 @@ fn linked_instance_prompt_model_provider_resolve_from_one_call() {
let after = [persona("p1", Some("goose"), "new definition prompt")];
assert_ne!(
spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()),
spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()),
snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()),
"linked instance prompt must resolve from the live definition, not stale record bytes"
);
}
// ── I2: definition args and env reach spawn_config_hash ──────────────────────
// ── I2: definition args and env reach the snapshot ───────────────────────────
//
// These tests prove that editing a custom harness definition's args or env
// changes spawn_config_hash, which trips the "restart required" badge.
// They would fail if spawn_config_hash used only record.agent_args without
// change the snapshot, which trips the "restart required" badge.
// They would fail if the snapshot used only record.agent_args without
// falling back to definition args, or if resolve_effective_agent_env did not
// include definition env.
/// When a record has no instance args but the definition has default args,
/// changing the definition args changes the spawn hash. This would fail if
/// spawn_config_hash used only record.agent_args.
/// changing the definition args changes the snapshot. This would fail if
/// the snapshot used only record.agent_args.
#[test]
fn spawn_hash_changes_when_definition_default_args_change() {
fn spawn_snapshot_changes_when_definition_default_args_change() {
use crate::managed_agents::custom_harnesses::{
registry_test_lock, warm_harness_registry_from_dir,
};
@@ -652,8 +665,8 @@ fn spawn_hash_changes_when_definition_default_args_change() {
use tempfile::tempdir;
// The loaded-harness registry is process-global: a parallel test re-warming
// it between the two hash computations makes both resolve to no-definition
// and h1 == h2 (observed on Windows CI).
// it between the two snapshots makes both resolve to no-definition
// and s1 == s2 (observed on Windows CI).
let _lock = registry_test_lock();
let dir = tempdir().unwrap();
@@ -669,7 +682,7 @@ fn spawn_hash_changes_when_definition_default_args_change() {
r.runtime = Some("my-def".into());
r.agent_args = vec![]; // no instance args → definition args are used
let h1 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default());
let s1 = snapshot(&r, &[], &[], "ws://relay", &Default::default());
// Update to v2 args and re-warm (simulating save + transactional refresh).
fs::write(
@@ -679,18 +692,18 @@ fn spawn_hash_changes_when_definition_default_args_change() {
.unwrap();
warm_harness_registry_from_dir(Some(dir.path()));
let h2 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default());
let s2 = snapshot(&r, &[], &[], "ws://relay", &Default::default());
assert_ne!(
h1, h2,
"changing definition default args must change the spawn hash"
s1, s2,
"changing definition default args must change the snapshot"
);
}
/// When a definition has env vars, adding them changes the spawn hash. This
/// When a definition has env vars, adding them changes the snapshot. This
/// proves resolve_effective_agent_env includes definition env in the layering.
#[test]
fn spawn_hash_changes_when_definition_env_changes() {
fn spawn_snapshot_changes_when_definition_env_changes() {
use crate::managed_agents::custom_harnesses::{
registry_test_lock, warm_harness_registry_from_dir,
};
@@ -712,7 +725,7 @@ fn spawn_hash_changes_when_definition_env_changes() {
let mut r = record();
r.runtime = Some("env-def".into());
let h1 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default());
let s1 = snapshot(&r, &[], &[], "ws://relay", &Default::default());
// Update to include env and re-warm.
fs::write(
@@ -722,16 +735,16 @@ fn spawn_hash_changes_when_definition_env_changes() {
.unwrap();
warm_harness_registry_from_dir(Some(dir.path()));
let h2 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default());
let s2 = snapshot(&r, &[], &[], "ws://relay", &Default::default());
assert_ne!(h1, h2, "adding definition env must change the spawn hash");
assert_ne!(s1, s2, "adding definition env must change the snapshot");
}
/// Instance-level args win over definition default args (non-empty instance
/// args must NOT be overridden by the definition). The hash must match a record
/// args must NOT be overridden by the definition). The snapshot must match a record
/// that has the same effective args from either source.
#[test]
fn spawn_hash_instance_args_win_over_definition_args() {
fn spawn_snapshot_instance_args_win_over_definition_args() {
use crate::managed_agents::custom_harnesses::{
registry_test_lock, warm_harness_registry_from_dir,
};
@@ -756,12 +769,12 @@ fn spawn_hash_instance_args_win_over_definition_args() {
r_no_instance.runtime = Some("arg-def".into());
r_no_instance.agent_args = vec![];
let h_instance = spawn_config_hash(&r_instance, &[], &[], "ws://relay", &Default::default());
let h_no_instance =
spawn_config_hash(&r_no_instance, &[], &[], "ws://relay", &Default::default());
let snapshot_instance = snapshot(&r_instance, &[], &[], "ws://relay", &Default::default());
let snapshot_no_instance =
snapshot(&r_no_instance, &[], &[], "ws://relay", &Default::default());
assert_ne!(
h_instance, h_no_instance,
"instance args and definition args must produce different hashes"
snapshot_instance, snapshot_no_instance,
"instance args and definition args must produce different snapshots"
);
}

View File

@@ -462,13 +462,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,
/// The effective spawn config this process was launched with (see
/// `spawn_snapshot::SpawnConfigSnapshot`). Runtime-only — never persisted.
/// The summary builder recomputes a prospective snapshot from current disk
/// state and reports each differing field via
/// `ManagedAgentSummary::restart_diff`. Agents adopted via a persisted
/// `runtime_pid` have none, so their config is unknown and no badge fires.
pub spawn_config: super::spawn_snapshot::SpawnConfigSnapshot,
/// 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
@@ -543,11 +543,16 @@ pub struct ManagedAgentSummary {
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).
/// change what runs. Complements `persona_out_of_date`: this means "a
/// restart would change what runs"; out-of-date means "a respawn would."
/// Derived from `restart_diff` alone, so it is lit exactly when there is
/// something to show — never for a stopped agent, an orphan, or a
/// `runtime_pid`-adopted process (its spawn config is unknown).
pub needs_restart: bool,
/// Each field whose effective spawn value drifted since launch, redacted
/// for display (see `spawn_snapshot::diff`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub restart_diff: Vec<super::spawn_snapshot::RestartDiffEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env_vars: BTreeMap<String, String>,
pub backend: BackendKind,

View File

@@ -26,7 +26,7 @@ use crate::managed_agents::{
/// `unwrap_or_default`, env COPIED so later instances inherit a working
/// config, quad copied to the definition defaults) and the record gains
/// `persona_source_version` = the new definition's content hash, so
/// neither `spawn_config_hash` nor the drift badge moves.
/// neither the spawn-config snapshot nor the drift badge moves.
///
/// The manufactured definition's slug is the agent's pubkey: 64-hex passes
/// the NIP-AP slug grammar on both relay and desktop ends, and agent pubkeys

View File

@@ -1,5 +1,5 @@
use super::backfill_standalone_agents_in_dir;
use crate::managed_agents::spawn_hash::spawn_config_hash;
use crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot;
use crate::managed_agents::{AgentDefinition, ManagedAgentRecord};
use crate::migration::test_support::{read_agents_json, write_agents_json};
use std::path::Path;
@@ -116,11 +116,11 @@ fn backfilled_definition_carries_prompt_present_even_if_empty() {
}
#[test]
fn backfill_of_promptless_record_keeps_spawn_hash_stable() {
// B5 hash row 2: pre-backfill the record hashes prompt None; post-backfill
fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() {
// B5 drift row 2: pre-backfill the record snapshots prompt None; post-backfill
// the prospective re-snapshot pulls Some("") from the manufactured
// definition. The spawn layer treats an empty prompt as no prompt (env
// absent either way), so the hash must not move — otherwise every
// absent either way), so the snapshot must not move — otherwise every
// prompt-less standalone agent lights the restart badge on upgrade.
let dir = tempfile::tempdir().unwrap();
let pubkey = "c".repeat(64);
@@ -131,7 +131,7 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() {
let pre_records = load_typed(dir.path());
let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap();
let hash_before = spawn_config_hash(
let before = prospective_spawn_config_snapshot(
pre_instance,
&[],
&[],
@@ -147,7 +147,7 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() {
.iter()
.filter_map(|r| r.to_definition_view())
.collect();
let hash_after = spawn_config_hash(
let after = prospective_spawn_config_snapshot(
post_instance,
&personas,
&[],
@@ -156,15 +156,16 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() {
);
assert_eq!(
hash_before, hash_after,
before.canonical(),
after.canonical(),
"backfill must not flip the restart badge for prompt-less agents"
);
}
#[test]
fn backfill_of_prompted_record_keeps_spawn_hash_stable() {
fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() {
// The general no-behavior-change rail: a standalone agent WITH config
// must also hash identically across backfill (the definition snapshots
// must also snapshot identically across backfill (the definition snapshots
// the record's own values, so the re-snapshot writes back what is
// already there).
let dir = tempfile::tempdir().unwrap();
@@ -180,7 +181,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() {
let pre_records = load_typed(dir.path());
let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap();
let hash_before = spawn_config_hash(
let before = prospective_spawn_config_snapshot(
pre_instance,
&[],
&[],
@@ -196,7 +197,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() {
.iter()
.filter_map(|r| r.to_definition_view())
.collect();
let hash_after = spawn_config_hash(
let after = prospective_spawn_config_snapshot(
post_instance,
&personas,
&[],
@@ -204,7 +205,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() {
&Default::default(),
);
assert_eq!(hash_before, hash_after);
assert_eq!(before.canonical(), after.canonical());
}
#[test]

View File

@@ -15,8 +15,8 @@ use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records};
/// persona (unified agent model, Phase 1A). After this, spawn resolution reads
/// the record's own runtime (`record_agent_command` step 2) instead of the
/// live persona — same effective command by construction, so the spawn-config
/// hash is unchanged and no running agent shows a spurious restart badge (see
/// `spawn_hash::tests::materializing_runtime_keeps_hash_stable`).
/// snapshot is unchanged and no running agent shows a spurious restart badge
/// (see `spawn_snapshot::tests::materializing_runtime_keeps_snapshot_stable`).
///
/// Idempotent: records that already carry `runtime` are untouched, as are
/// records with no linked persona or a persona without a runtime (both keep