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:
Logan Johnson
2026-09-04 15:59:26 -04:00
parent d67471c60e
commit a5b36742cd
16 changed files with 326 additions and 64 deletions

View File

@@ -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,

View File

@@ -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;

View 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, &current_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());
}
}

View File

@@ -345,6 +345,7 @@ pub async fn restore_managed_agents_on_launch(
true,
owner_hex_ref,
None,
None,
)
}) {
Ok(process) => {

View File

@@ -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(())
}

View File

@@ -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>,

View File

@@ -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());
}
}

View File

@@ -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

View File

@@ -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[]>(

View File

@@ -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 -----------

View File

@@ -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({

View File

@@ -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

View File

@@ -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,

View File

@@ -176,6 +176,7 @@ export function useMembersSidebarActions({
action,
pubkey: agent.pubkey,
relayUrl,
explicitStart: action === "start",
});
setActionNoticeMessage(
action === "stop"

View File

@@ -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 () => {

View File

@@ -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(