mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
feat(desktop): fence automatic launches after remote Stop
Bind explicit Start permission before preflight and release it only after child registration. Reuse ordinary platform-specific pair Stop and refuse success for an unscoped live legacy child. Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -160,15 +160,35 @@ pub(super) async fn start_local_agent_pairs_with_preflight(
|
||||
summarize_from_disk(app, record, &runtimes)
|
||||
}
|
||||
|
||||
pub(super) async fn start_local_agent_with_preflight(
|
||||
enum LocalStartIntent {
|
||||
Create,
|
||||
Explicit,
|
||||
Automatic,
|
||||
}
|
||||
|
||||
async fn start_local_agent_with_preflight(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
allow_fresh_create_start: bool,
|
||||
intent: LocalStartIntent,
|
||||
expected_relay_url: Option<&str>,
|
||||
expected_signer_pubkey: Option<&str>,
|
||||
replay_floor_unix: Option<u64>,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
let launch_owner = workspace_owner_hex(state)?;
|
||||
let launch_key = crate::managed_agents::ManagedAgentRuntimeKey::new(
|
||||
pubkey,
|
||||
&relay_ws_url_with_override(state),
|
||||
)?;
|
||||
let resume = if matches!(intent, LocalStartIntent::Explicit) {
|
||||
Some(crate::managed_agents::remote_stop::capture_resume(
|
||||
app,
|
||||
&launch_key,
|
||||
&launch_owner,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let record_snapshot = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
@@ -201,7 +221,12 @@ pub(super) async fn start_local_agent_with_preflight(
|
||||
&personas,
|
||||
&global,
|
||||
);
|
||||
ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?;
|
||||
ensure_relay_mesh_for_record(
|
||||
app,
|
||||
mesh_model_id.as_deref(),
|
||||
matches!(intent, LocalStartIntent::Create),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The mesh preflight above is the suspension window Projects callbacks
|
||||
// capture their scope against: a community switch during that await
|
||||
@@ -212,15 +237,21 @@ pub(super) async fn start_local_agent_with_preflight(
|
||||
// point can no longer retarget the spawn (it only changes state this
|
||||
// call no longer consults).
|
||||
let workspace_relay_url = crate::relay::bind_expected_relay_scope(
|
||||
expected_relay_url,
|
||||
expected_relay_url.or(Some(launch_key.relay_url.as_str())),
|
||||
crate::relay::relay_ws_url_with_override(state),
|
||||
)?;
|
||||
// Bind the active owner after the same final await as the relay. A
|
||||
// same-relay identity replacement during mesh preflight must not release
|
||||
// the stale preflight owner to spawn.
|
||||
let workspace_owner =
|
||||
crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?;
|
||||
let workspace_owner = crate::relay::bind_expected_signer(
|
||||
expected_signer_pubkey.or(Some(launch_owner.as_str())),
|
||||
workspace_owner_hex(state)?,
|
||||
)?;
|
||||
|
||||
let _transition = state
|
||||
.managed_agent_runtime_transition
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
@@ -262,6 +293,7 @@ pub(super) async fn start_local_agent_with_preflight(
|
||||
Some(workspace_owner.as_str()),
|
||||
&workspace_relay_url,
|
||||
replay_floor_unix,
|
||||
resume.as_ref(),
|
||||
)?;
|
||||
save_managed_agents(app, &records)?;
|
||||
if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) {
|
||||
@@ -713,7 +745,16 @@ pub async fn create_managed_agent(
|
||||
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
|
||||
let mut spawn_error = None;
|
||||
let agent = if input.spawn_after_create && input.backend == BackendKind::Local {
|
||||
match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None, None).await
|
||||
match start_local_agent_with_preflight(
|
||||
&app,
|
||||
&state,
|
||||
&pubkey,
|
||||
LocalStartIntent::Create,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(agent) => agent,
|
||||
Err(error) => {
|
||||
@@ -824,6 +865,7 @@ pub async fn start_managed_agent(
|
||||
expected_relay_url: Option<String>,
|
||||
expected_signer_pubkey: Option<String>,
|
||||
replay_floor_unix: Option<u64>,
|
||||
explicit_start: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
@@ -920,7 +962,11 @@ pub async fn start_managed_agent(
|
||||
&app,
|
||||
&state,
|
||||
&pubkey,
|
||||
false,
|
||||
if explicit_start.unwrap_or(false) {
|
||||
LocalStartIntent::Explicit
|
||||
} else {
|
||||
LocalStartIntent::Automatic
|
||||
},
|
||||
expected_relay_url.as_deref(),
|
||||
expected_signer_pubkey.as_deref(),
|
||||
replay_floor_unix,
|
||||
|
||||
@@ -32,6 +32,7 @@ mod process_lifecycle;
|
||||
pub(crate) mod readiness;
|
||||
pub(crate) mod reconcile;
|
||||
mod relay_mesh;
|
||||
pub(crate) mod remote_stop;
|
||||
mod repos;
|
||||
mod restore;
|
||||
pub mod retention;
|
||||
|
||||
131
desktop/src-tauri/src/managed_agents/remote_stop.rs
Normal file
131
desktop/src-tauri/src/managed_agents/remote_stop.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! Durable no-auto-start fence shared by ordinary Desktop launch paths.
|
||||
use super::retention::{open_retention_db, scoped_retention_db_path};
|
||||
use super::ManagedAgentRuntimeKey;
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
fn schema(conn: &Connection) -> Result<(), String> {
|
||||
conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_stop_fence (
|
||||
agent TEXT PRIMARY KEY, stamp INTEGER NOT NULL, event_id TEXT NOT NULL, blocked INTEGER NOT NULL);")
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Explicit local Start captures the Stop fence before its asynchronous preflight.
|
||||
/// Automatic starts and Restart continuations never receive this permission.
|
||||
pub(crate) struct ResumeTicket {
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn capture_resume(
|
||||
app: &AppHandle,
|
||||
key: &ManagedAgentRuntimeKey,
|
||||
owner: &str,
|
||||
) -> Result<ResumeTicket, String> {
|
||||
let conn = connection(app, key, owner)?;
|
||||
schema(&conn)?;
|
||||
let previous = conn
|
||||
.query_row(
|
||||
"SELECT event_id FROM desktop_stop_fence WHERE agent=?1",
|
||||
[&key.pubkey],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(ResumeTicket { previous })
|
||||
}
|
||||
|
||||
fn connection(
|
||||
app: &AppHandle,
|
||||
key: &ManagedAgentRuntimeKey,
|
||||
owner: &str,
|
||||
) -> Result<Connection, String> {
|
||||
let path =
|
||||
scoped_retention_db_path(&super::managed_agents_base_dir(app)?, &key.relay_url, owner);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
open_retention_db(&path)
|
||||
}
|
||||
|
||||
/// Every ordinary spawn passes here, including restore/config/reconcile.
|
||||
/// Caller holds the existing transition lock through child registration.
|
||||
pub(crate) fn check_launch(
|
||||
app: &AppHandle,
|
||||
key: &ManagedAgentRuntimeKey,
|
||||
owner: Option<&str>,
|
||||
resume: Option<&ResumeTicket>,
|
||||
) -> Result<(), String> {
|
||||
let state = app.state::<crate::app_state::AppState>();
|
||||
let current_owner = state.signing_keys()?.public_key().to_hex();
|
||||
if owner != Some(current_owner.as_str()) {
|
||||
return Err("Desktop launch owner changed".into());
|
||||
}
|
||||
let conn = connection(app, key, ¤t_owner)?;
|
||||
schema(&conn)?;
|
||||
let row: Option<(String, bool)> = conn
|
||||
.query_row(
|
||||
"SELECT event_id, blocked FROM desktop_stop_fence WHERE agent=?1",
|
||||
[&key.pubkey],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
allow_launch(row.as_ref(), resume)
|
||||
}
|
||||
|
||||
fn allow_launch(row: Option<&(String, bool)>, resume: Option<&ResumeTicket>) -> Result<(), String> {
|
||||
if let Some(ticket) = resume {
|
||||
if ticket.previous.as_ref() != row.map(|(id, _)| id) {
|
||||
return Err("A newer Stop interrupted this Start".into());
|
||||
}
|
||||
} else if row.is_some_and(|(_, blocked)| *blocked) {
|
||||
return Err(
|
||||
"Stopped from another Desktop. Use Start agent to start it again explicitly.".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A failed spawn must not unblock config/restore. Commit only after the child
|
||||
/// has its ordinary receipt and tracked handle, still under the transition lock.
|
||||
pub(crate) fn finish_resume(
|
||||
app: &AppHandle,
|
||||
key: &ManagedAgentRuntimeKey,
|
||||
owner: Option<&str>,
|
||||
ticket: Option<&ResumeTicket>,
|
||||
) -> Result<(), String> {
|
||||
if ticket.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let owner = owner.ok_or("Desktop launch owner unavailable")?;
|
||||
check_launch(app, key, Some(owner), ticket)?;
|
||||
connection(app, key, owner)?
|
||||
.execute(
|
||||
"UPDATE desktop_stop_fence SET blocked=0 WHERE agent=?1",
|
||||
[&key.pubkey],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn launch_fence_requires_explicit_start_and_rejects_delayed_preflight() {
|
||||
let stopped = ("stop-a".to_owned(), true);
|
||||
let resumed = ("stop-a".to_owned(), false);
|
||||
let new_stop = ("stop-b".to_owned(), true);
|
||||
assert!(allow_launch(None, None).is_ok());
|
||||
assert!(allow_launch(Some(&stopped), None).is_err());
|
||||
assert!(allow_launch(Some(&resumed), None).is_ok());
|
||||
let ticket = ResumeTicket {
|
||||
previous: Some("stop-a".to_owned()),
|
||||
};
|
||||
assert!(allow_launch(Some(&stopped), Some(&ticket)).is_ok());
|
||||
assert!(allow_launch(Some(&new_stop), Some(&ticket)).is_err());
|
||||
let before_any_stop = ResumeTicket { previous: None };
|
||||
assert!(allow_launch(Some(&stopped), Some(&before_any_stop)).is_err());
|
||||
assert!(allow_launch(None, Some(&before_any_stop)).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -345,6 +345,7 @@ pub async fn restore_managed_agents_on_launch(
|
||||
true,
|
||||
owner_hex_ref,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}) {
|
||||
Ok(process) => {
|
||||
|
||||
@@ -31,7 +31,7 @@ mod setup_payload;
|
||||
use setup_payload::apply_setup_payload_env;
|
||||
|
||||
mod stop;
|
||||
pub(crate) use stop::managed_agent_runtime_keys;
|
||||
pub(crate) use stop::{managed_agent_runtime_keys, stop_managed_agent_pair};
|
||||
pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair};
|
||||
|
||||
mod sweep;
|
||||
@@ -451,7 +451,10 @@ pub fn spawn_agent_child(
|
||||
lazy: bool,
|
||||
owner_hex: Option<&str>,
|
||||
replay_floor_unix: Option<u64>,
|
||||
resume: Option<&super::remote_stop::ResumeTicket>,
|
||||
) -> Result<crate::managed_agents::ManagedAgentProcess, String> {
|
||||
let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?;
|
||||
super::remote_stop::check_launch(app, &key, owner_hex, resume)?;
|
||||
if let Some(error) = spawn_key_refusal(record) {
|
||||
return Err(error);
|
||||
}
|
||||
@@ -881,6 +884,7 @@ pub fn start_managed_agent_process(
|
||||
owner_hex: Option<&str>,
|
||||
workspace_relay: &crate::relay::ScopedWorkspaceRelay,
|
||||
replay_floor_unix: Option<u64>,
|
||||
resume: Option<&super::remote_stop::ResumeTicket>,
|
||||
) -> Result<(), String> {
|
||||
let key = bound_runtime_key(record, workspace_relay)?;
|
||||
if let Some(runtime) = runtimes.get_mut(&key) {
|
||||
@@ -907,6 +911,7 @@ pub fn start_managed_agent_process(
|
||||
false,
|
||||
owner_hex,
|
||||
replay_floor_unix,
|
||||
resume,
|
||||
)?;
|
||||
let now = now_iso();
|
||||
let receipt = super::ManagedAgentRuntimeReceipt {
|
||||
@@ -928,7 +933,8 @@ pub fn start_managed_agent_process(
|
||||
record.last_error = None;
|
||||
record.last_error_code = None;
|
||||
|
||||
runtimes.insert(key, ManagedAgentPairRuntime::starting(process));
|
||||
runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process));
|
||||
super::remote_stop::finish_resume(app, &key, owner_hex, resume)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(crate) fn managed_agent_runtime_relay_urls<T>(
|
||||
/// runtime is reinserted so the pair stays visible and stoppable instead of
|
||||
/// becoming an invisible orphan. Touches no other pair for the agent and
|
||||
/// does no record-level stop bookkeeping — callers own that.
|
||||
fn stop_managed_agent_pair<R: tauri::Runtime>(
|
||||
pub(crate) fn stop_managed_agent_pair<R: tauri::Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
record: &mut ManagedAgentRecord,
|
||||
runtimes: &mut HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>,
|
||||
|
||||
@@ -3,13 +3,12 @@ use std::sync::atomic::Ordering;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use super::{
|
||||
agent_readiness, append_log_marker, current_instance_id, find_managed_agent_mut,
|
||||
load_global_agent_config, load_managed_agents, load_personas, managed_agent_runtime_log_path,
|
||||
process_is_running, record_agent_command, resolve_effective_agent_env, save_managed_agents,
|
||||
spawn_agent_child, terminate_process, terminate_untracked_pair_runtime,
|
||||
write_agent_runtime_receipt, AgentReadiness, BackendKind, ManagedAgentPairRuntime,
|
||||
ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt,
|
||||
ManagedAgentRuntimeStatus,
|
||||
agent_readiness, current_instance_id, find_managed_agent_mut, load_global_agent_config,
|
||||
load_managed_agents, load_personas, managed_agent_runtime_log_path, process_is_running,
|
||||
record_agent_command, resolve_effective_agent_env, save_managed_agents, spawn_agent_child,
|
||||
terminate_process, terminate_untracked_pair_runtime, write_agent_runtime_receipt,
|
||||
AgentReadiness, BackendKind, ManagedAgentPairRuntime, ManagedAgentRuntimeKey,
|
||||
ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus,
|
||||
};
|
||||
use crate::app_state::AppState;
|
||||
|
||||
@@ -229,16 +228,24 @@ pub(crate) fn start_managed_agent_runtime_pair_lazy(
|
||||
relay_url: String,
|
||||
app: AppHandle,
|
||||
) -> Result<ManagedAgentRuntimeStatus, String> {
|
||||
start_pair(pubkey, relay_url, true, None, app)
|
||||
start_pair(pubkey, relay_url, true, None, false, app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_managed_agent_runtime(
|
||||
pubkey: String,
|
||||
relay_url: String,
|
||||
explicit_start: Option<bool>,
|
||||
app: AppHandle,
|
||||
) -> Result<ManagedAgentRuntimeStatus, String> {
|
||||
start_managed_agent_runtime_pair_lazy(pubkey, relay_url, app)
|
||||
start_pair(
|
||||
pubkey,
|
||||
relay_url,
|
||||
true,
|
||||
None,
|
||||
explicit_start.unwrap_or(false),
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn start_pair(
|
||||
@@ -246,6 +253,7 @@ fn start_pair(
|
||||
relay_url: String,
|
||||
lazy: bool,
|
||||
expected_updated_at: Option<&str>,
|
||||
explicit_start: bool,
|
||||
app: AppHandle,
|
||||
) -> Result<ManagedAgentRuntimeStatus, String> {
|
||||
let state = app.state::<AppState>();
|
||||
@@ -288,8 +296,24 @@ fn start_pair(
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|keys| keys.public_key().to_hex());
|
||||
let mut process =
|
||||
spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref(), None)?;
|
||||
let resume = if explicit_start {
|
||||
Some(super::remote_stop::capture_resume(
|
||||
&app,
|
||||
&key,
|
||||
owner.as_deref().ok_or("Desktop owner unavailable")?,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut process = spawn_agent_child(
|
||||
&app,
|
||||
record,
|
||||
&key.relay_url,
|
||||
lazy,
|
||||
owner.as_deref(),
|
||||
None,
|
||||
resume.as_ref(),
|
||||
)?;
|
||||
let now = crate::util::now_iso();
|
||||
let receipt = ManagedAgentRuntimeReceipt {
|
||||
key: key.clone(),
|
||||
@@ -308,6 +332,7 @@ fn start_pair(
|
||||
record.last_stopped_at = None;
|
||||
record.last_error = None;
|
||||
runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process));
|
||||
super::remote_stop::finish_resume(&app, &key, owner.as_deref(), resume.as_ref())?;
|
||||
let status = status_for(&app, record, &key, runtimes.get(&key), None);
|
||||
drop(runtimes);
|
||||
save_managed_agents(&app, &records)?;
|
||||
@@ -326,6 +351,16 @@ pub fn stop_managed_agent_runtime(
|
||||
.managed_agent_runtime_transition
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
stop_pair_locked(pubkey, relay_url, app.clone())
|
||||
}
|
||||
|
||||
// Caller owns managed_agent_runtime_transition for the whole admission/effect.
|
||||
pub(crate) fn stop_pair_locked(
|
||||
pubkey: String,
|
||||
relay_url: String,
|
||||
app: AppHandle,
|
||||
) -> Result<ManagedAgentRuntimeStatus, String> {
|
||||
let state = app.state::<AppState>();
|
||||
let _store = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
@@ -337,42 +372,27 @@ pub fn stop_managed_agent_runtime(
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(mut runtime) = runtimes.remove(&key) {
|
||||
let stop_result = if process_is_running(runtime.child.id()) {
|
||||
terminate_process(runtime.child.id())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
.and_then(|()| runtime.child.wait().map_err(|e| e.to_string()));
|
||||
match stop_result {
|
||||
Ok(status) => {
|
||||
record.last_exit_code = status.code();
|
||||
let _ = append_log_marker(&runtime.log_path, "=== stopped pair runtime ===");
|
||||
}
|
||||
Err(error) => {
|
||||
// Keep failed teardown visible/manageable instead of
|
||||
// orphaning it: the child stays tracked and the receipt
|
||||
// stays on disk until a stop actually succeeds.
|
||||
runtimes.insert(key, runtime);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
if runtimes.contains_key(&key) {
|
||||
// Use ordinary Desktop Stop, including its platform-specific child/job
|
||||
// ownership. Remote control must not grow a second teardown contract.
|
||||
super::stop_managed_agent_pair(&app, record, &mut runtimes, &key)?;
|
||||
} else {
|
||||
// No runtime is tracked at this key, but a valid prior-session
|
||||
// receipt may still point at a live child (e.g. the crash-recovery
|
||||
// window for a non-auto-start agent). Terminate that orphan before
|
||||
// erasing its receipt — otherwise this "stop" leaves the harness
|
||||
// running yet deletes the one artifact sweeps and
|
||||
// terminate_untracked_pair_runtime use to find it, and a follow-up
|
||||
// start would spawn a duplicate harness for the same pair. On
|
||||
// failure the receipt stays on disk (terminate_untracked_pair_runtime
|
||||
// only removes it after the child exits), mirroring the tracked
|
||||
// path's keep-until-success invariant.
|
||||
terminate_untracked_pair_runtime(&app, &key)?;
|
||||
}
|
||||
// Old scalar records have no community-bound receipt. Do not erase a live
|
||||
// child or claim success for it when this request cannot establish scope.
|
||||
reject_unscoped_live_child(
|
||||
record.runtime_pid.filter(|pid| process_is_running(*pid)),
|
||||
runtimes.values().map(|runtime| runtime.child.id()),
|
||||
)?;
|
||||
super::remove_agent_runtime_receipt(&app, &key);
|
||||
state.clear_agent_session_cache(&key);
|
||||
record.runtime_pid = None;
|
||||
if record
|
||||
.runtime_pid
|
||||
.is_some_and(|pid| !process_is_running(pid))
|
||||
{
|
||||
record.runtime_pid = None;
|
||||
}
|
||||
record.updated_at = crate::util::now_iso();
|
||||
record.last_stopped_at = Some(record.updated_at.clone());
|
||||
let status = status_for(&app, record, &key, None, None);
|
||||
@@ -382,6 +402,16 @@ pub fn stop_managed_agent_runtime(
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn reject_unscoped_live_child(
|
||||
live_pid: Option<u32>,
|
||||
tracked: impl Iterator<Item = u32>,
|
||||
) -> Result<(), String> {
|
||||
if live_pid.is_some_and(|pid| !tracked.into_iter().any(|other| other == pid)) {
|
||||
return Err("Legacy runtime is not bound to this community; use local Desktop Stop".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restart_managed_agent_runtime(
|
||||
pubkey: String,
|
||||
@@ -389,7 +419,7 @@ pub fn restart_managed_agent_runtime(
|
||||
app: AppHandle,
|
||||
) -> Result<ManagedAgentRuntimeStatus, String> {
|
||||
stop_managed_agent_runtime(pubkey.clone(), relay_url.clone(), app.clone())?;
|
||||
start_pair(pubkey, relay_url, true, None, app)
|
||||
start_pair(pubkey, relay_url, true, None, false, app)
|
||||
}
|
||||
|
||||
/// Probe whether this agent can operate on `requested_relay_url`.
|
||||
@@ -513,6 +543,7 @@ pub async fn reconcile_managed_agent_runtimes(
|
||||
key.relay_url.clone(),
|
||||
true,
|
||||
Some(&record.updated_at),
|
||||
false,
|
||||
app.clone(),
|
||||
) {
|
||||
Ok(mut status) => {
|
||||
@@ -732,3 +763,16 @@ mod tests {
|
||||
assert!(observer_lifecycle_key(&ready_with_error.pubkey, &ready_with_error).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stop_scope_tests {
|
||||
use super::reject_unscoped_live_child;
|
||||
|
||||
#[test]
|
||||
fn live_legacy_child_cannot_be_erased_or_reported_stopped() {
|
||||
assert!(reject_unscoped_live_child(Some(12), [].into_iter()).is_err());
|
||||
assert!(reject_unscoped_live_child(Some(12), [13].into_iter()).is_err());
|
||||
assert!(reject_unscoped_live_child(Some(12), [12].into_iter()).is_ok());
|
||||
assert!(reject_unscoped_live_child(None, [13].into_iter()).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,6 +308,14 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
|
||||
17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label.
|
||||
|
||||
## Desktop Stop launch fence
|
||||
|
||||
All local spawn paths consume the durable Stop fence at the shared native
|
||||
spawn boundary. Only a deliberate **Start agent** action can supersede that
|
||||
fence; config/restore/reconcile and Restart continuations cannot. Explicit Start
|
||||
captures its fence before preflight and fails if a newer Stop arrives. Fence
|
||||
release happens only after the new child has its receipt and tracked handle.
|
||||
|
||||
## Channel-only runtime controls
|
||||
|
||||
Desktop observer controls identify a channel, not a thread session. The harness
|
||||
|
||||
@@ -595,6 +595,7 @@ export function useStartManagedAgentMutation() {
|
||||
expectedRelayUrl?: string;
|
||||
expectedSignerPubkey?: string;
|
||||
replayFloorUnix?: number;
|
||||
explicitStart?: boolean;
|
||||
},
|
||||
) =>
|
||||
typeof input === "string"
|
||||
@@ -603,6 +604,7 @@ export function useStartManagedAgentMutation() {
|
||||
expectedRelayUrl: input.expectedRelayUrl,
|
||||
expectedSignerPubkey: input.expectedSignerPubkey,
|
||||
replayFloorUnix: input.replayFloorUnix,
|
||||
explicitStart: input.explicitStart,
|
||||
}),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData<ManagedAgent[]>(
|
||||
|
||||
@@ -56,7 +56,10 @@ test("relay-mesh agents delegate start to the backend preflight", async () => {
|
||||
calledWith = pubkey;
|
||||
},
|
||||
});
|
||||
assert.equal(calledWith, meshAgent.pubkey);
|
||||
assert.deepEqual(calledWith, {
|
||||
pubkey: meshAgent.pubkey,
|
||||
explicitStart: true,
|
||||
});
|
||||
|
||||
// Backend preflight failures (e.g. no live serve target) propagate as-is.
|
||||
await assert.rejects(
|
||||
@@ -78,7 +81,10 @@ test("ordinary local agents still start normally", async () => {
|
||||
calledWith = pubkey;
|
||||
},
|
||||
});
|
||||
assert.equal(calledWith, "deadbeef".repeat(8));
|
||||
assert.deepEqual(calledWith, {
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
explicitStart: true,
|
||||
});
|
||||
});
|
||||
|
||||
// --- respawnManagedAgentWithRules: stop→clear→start boundary tests -----------
|
||||
|
||||
@@ -8,7 +8,10 @@ type DeleteManagedAgentInput = {
|
||||
forceRemoteDelete?: boolean;
|
||||
};
|
||||
|
||||
type StartManagedAgent = (pubkey: string) => Promise<unknown>;
|
||||
export type ManagedAgentStartInput =
|
||||
| string
|
||||
| { pubkey: string; explicitStart: true };
|
||||
type StartManagedAgent = (input: ManagedAgentStartInput) => Promise<unknown>;
|
||||
type StopManagedAgent = (pubkey: string) => Promise<unknown>;
|
||||
type DeleteManagedAgent = (input: DeleteManagedAgentInput) => Promise<unknown>;
|
||||
|
||||
@@ -82,7 +85,7 @@ export async function startManagedAgentWithRules({
|
||||
// Relay-mesh agents are no longer blocked here: the backend start preflight
|
||||
// (ensure_relay_mesh_for_record) re-resolves a live serve target and dials
|
||||
// it, failing with an actionable error when no peer serves the model.
|
||||
await startManagedAgent(agent.pubkey);
|
||||
await startManagedAgent({ pubkey: agent.pubkey, explicitStart: true });
|
||||
}
|
||||
|
||||
export async function respawnManagedAgentWithRules({
|
||||
|
||||
@@ -185,10 +185,12 @@ export function useManagedAgentRuntimeAction() {
|
||||
action,
|
||||
pubkey,
|
||||
relayUrl,
|
||||
explicitStart = false,
|
||||
}: {
|
||||
action: "start" | "stop" | "restart";
|
||||
pubkey: string;
|
||||
relayUrl: string;
|
||||
explicitStart?: boolean;
|
||||
}) => {
|
||||
if (action === "stop") return stopManagedAgentRuntime(pubkey, relayUrl);
|
||||
if (action === "restart") {
|
||||
@@ -200,7 +202,7 @@ export function useManagedAgentRuntimeAction() {
|
||||
startManagedAgentRuntime,
|
||||
);
|
||||
}
|
||||
return startManagedAgentRuntime(pubkey, relayUrl);
|
||||
return startManagedAgentRuntime(pubkey, relayUrl, explicitStart);
|
||||
},
|
||||
onSuccess: (runtime, { action }) => {
|
||||
// For stop-only: clear stale working badges immediately. The restart
|
||||
|
||||
@@ -433,10 +433,11 @@ export function useManagedAgentActions() {
|
||||
stopMutation.isPending ||
|
||||
startOnLaunchMutation.isPending ||
|
||||
deleteMutation.isPending;
|
||||
const startingAgentPubkey =
|
||||
startMutation.isPending && typeof startMutation.variables === "string"
|
||||
const startingAgentPubkey = startMutation.isPending
|
||||
? typeof startMutation.variables === "string"
|
||||
? startMutation.variables
|
||||
: null;
|
||||
: (startMutation.variables?.pubkey ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
relayAgentsQuery,
|
||||
|
||||
@@ -176,6 +176,7 @@ export function useMembersSidebarActions({
|
||||
action,
|
||||
pubkey: agent.pubkey,
|
||||
relayUrl,
|
||||
explicitStart: action === "start",
|
||||
});
|
||||
setActionNoticeMessage(
|
||||
action === "stop"
|
||||
|
||||
@@ -28,7 +28,9 @@ export function useAgentLifecycleActions({
|
||||
channels: readonly Channel[] | undefined;
|
||||
managedAgent: ManagedAgent | undefined;
|
||||
relayAgents: readonly RelayAgent[] | undefined;
|
||||
startManagedAgent: (pubkey: string) => Promise<unknown>;
|
||||
startManagedAgent: (
|
||||
input: import("@/features/agents/lib/managedAgentControlActions").ManagedAgentStartInput,
|
||||
) => Promise<unknown>;
|
||||
stopManagedAgent: (pubkey: string) => Promise<unknown>;
|
||||
}) {
|
||||
const handleAgentPrimaryAction = React.useCallback(async () => {
|
||||
|
||||
@@ -24,6 +24,8 @@ export async function startManagedAgent(
|
||||
* long the spawn takes. Local spawns receive it as process env; provider
|
||||
* deploys carry it in the payload's launch.policy_env. */
|
||||
replayFloorUnix?: number;
|
||||
/** Only a deliberate Start button may supersede a remote Stop. */
|
||||
explicitStart?: boolean;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
const response = await invokeTauri<RawManagedAgent>("start_managed_agent", {
|
||||
@@ -31,6 +33,7 @@ export async function startManagedAgent(
|
||||
expectedRelayUrl: options?.expectedRelayUrl ?? null,
|
||||
expectedSignerPubkey: options?.expectedSignerPubkey ?? null,
|
||||
replayFloorUnix: options?.replayFloorUnix ?? null,
|
||||
explicitStart: options?.explicitStart ?? false,
|
||||
});
|
||||
return fromRawManagedAgent(response);
|
||||
}
|
||||
@@ -81,8 +84,13 @@ export async function listManagedAgentRuntimes(): Promise<
|
||||
export async function startManagedAgentRuntime(
|
||||
pubkey: string,
|
||||
relayUrl: string,
|
||||
explicitStart = false,
|
||||
): Promise<ManagedAgentRuntimeStatus> {
|
||||
return invokeTauri("start_managed_agent_runtime", { pubkey, relayUrl });
|
||||
return invokeTauri("start_managed_agent_runtime", {
|
||||
pubkey,
|
||||
relayUrl,
|
||||
explicitStart,
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopManagedAgentRuntime(
|
||||
|
||||
Reference in New Issue
Block a user