mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
The "Restart required" badge reports that an agent's running config has
drifted from its spawn-time config, but never says what changed. This
ships the full feature: a typed Rust diff engine and a TS/UI layer that
renders it at every badge site.
## Rust core (spawn-snapshot diff engine)
Replaces the lossy `u64` `spawn_config_hash` with a typed
`SpawnConfigSnapshot`. The snapshot is stamped from the already-resolved
command/env/config values immediately before `spawn()`, closing the race
window where a mid-spawn config edit would suppress the badge.
`SpawnConfigSnapshot::canonical()` is the single JSON projection shared
by the badge and the diff. Drift is `to_value(stamped) !=
to_value(current)`; the diff is a generic leaf walk over those same two
values, so badge-on and diff-non-empty are structurally guaranteed.
Adding a snapshot field reaches the UI with no code change to the diff
engine — `mutation_table_covers_every_serialized_field` fails CI if a
new field arrives without a mutation row.
`eligible_restart_diff(persona_orphaned, Option<TrackedSpawnState>)`
returns the final vector — snapshot walk entries plus a synthetic
`adapter_availability` entry. It returns empty for an orphaned instance
(spawning one would fail) and for agents with no tracked spawn state
(never stamped, can never have drifted). `needs_restart =
!restart_diff.is_empty()` derives from that vector and nothing else.
Redaction policy (`policy_for(path)`) is shared by the wire diff and the
snapshot's manual `Debug` via `is_safe_to_reveal()` from
`managed_agents::env_vars` as the single authority for env-key masking:
| Policy | Paths | Rendering |
|---|---|---|
| `Text` | `system_prompt`, `team_instructions` | character counts only
|
| `MaskedBare` | `args`, `relay_url` | `••••`, no suffix |
| `MaskedSuffix` | non-allowlisted `env.*` | `••••` + last 4 chars when
longer than 8 |
| `Plain` | allowlisted `env.*` (`BUZZ_AGENT_THINKING_EFFORT`,
`BUZZ_AGENT_PROVIDER`, `BUZZ_AGENT_MODEL`, `DATABRICKS_HOST/MODEL`) and
everything else | verbatim |
Default-deny: every env key not in the explicit allowlist stays masked.
`is_safe_to_reveal()` is the single allowlist authority for both the
baked-env display and the diff.
`restart_diff` is omitted from the wire when empty
(`skip_serializing_if`).
## TypeScript / UI layer
New `restartDiff.ts` module defines `RestartDiffEntry`, `RestartChange`,
`JsonValue`; `tauri.ts` and `types.ts` re-export and add `restart_diff`
/ `restartDiff` fields (Rust omission → `restartDiff: []`).
**`RestartDiffBadge`** — hover tooltip capped at 6 entries + "and N
more", `asChild` span trigger (never inside a `<button>`), auto-restart
blurb below the diff list (on/off variant from `autoRestartEnabled`
prop; same `AUTO_RESTART_ON_BLURB` / `AUTO_RESTART_OFF_BLURB` constants
shared with the Runtime-tab banner). **`RestartDiffList`** renders the
full uncapped list for the Runtime-tab banner with `tooltip`/`inline`
presentation variants for correct foreground in both surfaces.
**`ManagedAgentRow` B4 fix** — badge moved to a sibling `div` of the row
expansion button; tooltip trigger has no `button` ancestor.
**`UnifiedAgentsSection`** — both badge sites render
`<RestartDiffBadge>` instead of a raw `<Badge>`, with
`autoRestartEnabled` threaded from `agent.autoRestartOnConfigChange`.
**Side-panel fix** — `RestartDiffBadge` rendered tab-independently in
the `ProfileSummaryView` hero area (was Runtime-tab only — root cause of
the ~50% inconsistency Will reported). Hero badge is `self-center` in
the flex column. `ProfileRuntimeTabContent` early-return checks
`needsRestart` so the banner is never dropped when all other content is
empty. Auto-restart blurb in the Runtime-tab banner uses the shared
constants.
## Wire shape
```jsonc
"restart_diff": [
{ "field": "model", "change": { "kind": "value", "before": "gpt-5", "after": "claude-4" } },
{ "field": "system_prompt", "change": { "kind": "text", "before_chars": 1234, "after_chars": 1410 } },
{ "field": "env.OPENAI_API_KEY", "change": { "kind": "masked", "before": "••••bc12", "after": "••••xyz9" } },
{ "field": "env.BUZZ_AGENT_THINKING_EFFORT", "change": { "kind": "value", "before": "medium", "after": "high" } }
]
```
`added`/`removed` occur only for dynamic-map keys; nullable struct
fields always serialize as `null`; arrays are atomic leaves (`args`,
never `args.0`).
## Tests
**Rust** — 1902 passing: snapshot mutation coverage, diff entry
serialization, allowlist-aware env masking
(`allowlisted_env_key_shows_plain_value`,
`allowlisted_env_key_is_case_insensitive`,
`non_allowlisted_env_key_stays_masked`),
`unstamped_agent_yields_no_badge_and_no_entries` (both orphan values),
`summary_without_drift_omits_restart_diff_from_the_wire`,
`unstamped_availability_is_not_drift`. Clippy clean, fmt clean.
**TypeScript** — `needs-restart-screenshots.spec.ts`: 11 E2E cases
registered in the smoke project — all three badge sites, tooltip +
keyboard focus, DOM no-button-ancestor assertion, 6+1 truncation,
uncapped Runtime list, unknown field humanisation, side-panel badge on
default Info tab, inactive/friendly-error Runtime opening path.
Consolidates [#3652](https://github.com/block/buzz/pull/3652)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
159 lines
6.3 KiB
Rust
159 lines
6.3 KiB
Rust
//! Windows process-tree lifecycle primitives for managed agents.
|
|
//!
|
|
//! The Unix teardown uses `process_group(0)` + group signals (in `runtime.rs`).
|
|
//! Windows has no process groups, so the harness's 24 agent workers + MCP
|
|
//! servers are reaped two ways here:
|
|
//! - [`JobHandle`] / [`create_job_for_child`] — the in-process stop path. A
|
|
//! Job Object owns the tree and `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` kills
|
|
//! it when the handle drops.
|
|
//! - [`taskkill_tree`] — the after-restart path, where only the PID survives
|
|
//! in the record and no job handle is available.
|
|
//!
|
|
//! This module is `#[cfg(windows)]`-only; nothing here compiles on other
|
|
//! platforms.
|
|
|
|
use windows_sys::Win32::Foundation::HANDLE;
|
|
|
|
/// Win32 Job Object that owns the harness process and (via Windows' default
|
|
/// child-inheritance) every process it spawns. Dropping the handle with
|
|
/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set kills the whole tree — the Windows
|
|
/// mirror of the Unix `process_group(0)` + group-signal teardown. This is what
|
|
/// guarantees the 24 agent workers + MCP servers die when we stop or when the
|
|
/// app exits, instead of being orphaned by a bare `Child::kill()`.
|
|
pub struct JobHandle(HANDLE);
|
|
|
|
// The handle is owned exclusively by this wrapper; moving it across threads is
|
|
// sound (the spawn path in restore.rs runs in a thread scope).
|
|
unsafe impl Send for JobHandle {}
|
|
|
|
impl std::fmt::Debug for JobHandle {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str("JobHandle(..)")
|
|
}
|
|
}
|
|
|
|
impl Drop for JobHandle {
|
|
fn drop(&mut self) {
|
|
// KILL_ON_JOB_CLOSE means the tree dies when the LAST handle closes.
|
|
// We hold the only handle (not inheritable), so this reaps the tree.
|
|
unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) };
|
|
}
|
|
}
|
|
|
|
/// Create a Job Object, assign `pid` to it, and configure it to kill the whole
|
|
/// tree when the returned handle is dropped. Returns `None` on any failure so
|
|
/// the caller can fall back to `Child::kill()` — a degraded teardown beats a
|
|
/// failed spawn.
|
|
///
|
|
/// Assignment happens immediately after spawn, on the same parent thread. The
|
|
/// child (buzz-acp) does spawn its 24 workers before it connects to the relay,
|
|
/// so the window between our spawn and our assignment is NOT structurally empty.
|
|
/// What closes it is assign-latency: `OpenProcess` + `AssignProcessToJobObject`
|
|
/// are a few synchronous Win32 calls (microseconds), while buzz-acp must init
|
|
/// tokio, parse its config, and spawn 24 children (tens-to-hundreds of ms), so
|
|
/// the assign reliably wins before any worker exists. Once assigned, Windows
|
|
/// places every subsequently-spawned descendant in the job automatically.
|
|
///
|
|
/// `CREATE_SUSPENDED` -> assign -> `ResumeThread` would make the window airtight
|
|
/// regardless of child timing, but it requires raw `CreateProcessW`/`ResumeThread`
|
|
/// (materially more unsafe Win32) to close a microsecond race, so it is
|
|
/// deliberately not used here.
|
|
fn create_job_for_child(pid: u32) -> Option<JobHandle> {
|
|
use std::ptr::null;
|
|
use windows_sys::Win32::Foundation::{CloseHandle, FALSE};
|
|
use windows_sys::Win32::System::JobObjects::{
|
|
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
|
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
|
};
|
|
use windows_sys::Win32::System::Threading::{
|
|
OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
|
|
};
|
|
|
|
unsafe {
|
|
let job = CreateJobObjectW(null(), null());
|
|
if job.is_null() {
|
|
return None;
|
|
}
|
|
|
|
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
|
|
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
let ok = SetInformationJobObject(
|
|
job,
|
|
JobObjectExtendedLimitInformation,
|
|
&info as *const _ as *const _,
|
|
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
|
);
|
|
if ok == FALSE {
|
|
CloseHandle(job);
|
|
return None;
|
|
}
|
|
|
|
let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, FALSE, pid);
|
|
if process.is_null() {
|
|
CloseHandle(job);
|
|
return None;
|
|
}
|
|
let assigned = AssignProcessToJobObject(job, process);
|
|
CloseHandle(process);
|
|
if assigned == FALSE {
|
|
CloseHandle(job);
|
|
return None;
|
|
}
|
|
|
|
Some(JobHandle(job))
|
|
}
|
|
}
|
|
|
|
/// Kill the entire process tree rooted at `pid` via `taskkill /T`, the closest
|
|
/// equivalent to the Unix process-group kill. Used on the after-restart path
|
|
/// where no job handle survived. `CREATE_NO_WINDOW` keeps taskkill's own
|
|
/// console from flashing.
|
|
pub fn taskkill_tree(pid: u32) -> Result<(), String> {
|
|
use std::os::windows::process::CommandExt;
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
let status = std::process::Command::new("taskkill")
|
|
.args(["/T", "/F", "/PID", &pid.to_string()])
|
|
.creation_flags(CREATE_NO_WINDOW)
|
|
.status()
|
|
.map_err(|error| format!("failed to run taskkill for pid {pid}: {error}"))?;
|
|
if status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"taskkill exited with status {status} for pid {pid}"
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Assign a freshly-spawned harness `child` to a Job Object and package it into
|
|
/// a [`ManagedAgentProcess`]. On job-assignment failure the process is still
|
|
/// returned with `job: None` — teardown then falls back to `Child::kill()`,
|
|
/// which kills only the harness (a degraded teardown beats a failed spawn).
|
|
pub fn finish_spawn(
|
|
child: std::process::Child,
|
|
log_path: std::path::PathBuf,
|
|
spawn_config: super::spawn_snapshot::SpawnConfigSnapshot,
|
|
setup_mode: bool,
|
|
adapter_availability: Option<super::AcpAvailabilityStatus>,
|
|
start_nonce: String,
|
|
agent_name: &str,
|
|
) -> super::ManagedAgentProcess {
|
|
let job = create_job_for_child(child.id());
|
|
if job.is_none() {
|
|
eprintln!(
|
|
"buzz-desktop: failed to assign agent {agent_name} to a Job Object; \
|
|
teardown will fall back to killing only the harness process"
|
|
);
|
|
}
|
|
super::ManagedAgentProcess {
|
|
child,
|
|
log_path,
|
|
spawn_config,
|
|
setup_mode,
|
|
adapter_availability,
|
|
start_nonce,
|
|
job,
|
|
}
|
|
}
|