Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 54 additions & 8 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
131 changes: 131 additions & 0 deletions desktop/src-tauri/src/managed_agents/remote_stop.rs
Original file line number Diff line number Diff line change
@@ -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());
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ pub async fn restore_managed_agents_on_launch(
true,
owner_hex_ref,
None,
None,
)
}) {
Ok(process) => {
Expand Down
10 changes: 8 additions & 2 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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(())
}

Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/runtime/stop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down
Loading
Loading