Skip to content
Merged
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
153 changes: 85 additions & 68 deletions desktop/src-tauri/src/managed_agents/runtime_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,86 +137,91 @@ pub fn put_managed_agent_runtime_lifecycle(
Ok(status)
}

// Keep disk, process, and mutex work off the main thread so opening members cannot stall the UI.
#[tauri::command]
pub fn list_managed_agent_runtimes(
pub async fn list_managed_agent_runtimes(
app: AppHandle,
) -> Result<Vec<ManagedAgentRuntimeStatus>, String> {
// This command is polled whenever the members sidebar opens and refetched
// on every status event — load the per-row status inputs once, outside
// the locks, instead of hitting disk per row while holding them.
let personas = load_personas(&app).unwrap_or_default();
let global = load_global_agent_config(&app).unwrap_or_default();
let state = app.state::<AppState>();
let _transition = state
.managed_agent_runtime_transition
.lock()
.map_err(|e| e.to_string())?;
let _store = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let exited_keys: Vec<_> = runtimes
.iter_mut()
.filter_map(|(key, runtime)| match runtime.child.try_wait() {
Ok(Some(_)) | Err(_) => Some(key.clone()),
Ok(None) => None,
})
.collect();
let records_changed = !exited_keys.is_empty();
let mut statuses = Vec::new();
for key in exited_keys {
runtimes.remove(&key);
super::remove_agent_runtime_receipt(&app, &key);
state.clear_agent_session_cache(&key);
if let Some(record) = records
tokio::task::spawn_blocking(move || {
// This command is polled whenever the members sidebar opens and refetched
// on every status event — load the per-row status inputs once, outside
// the locks, instead of hitting disk per row while holding them.
let personas = load_personas(&app).unwrap_or_default();
let global = load_global_agent_config(&app).unwrap_or_default();
let state = app.state::<AppState>();
let _transition = state
.managed_agent_runtime_transition
.lock()
.map_err(|e| e.to_string())?;
let _store = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let exited_keys: Vec<_> = runtimes
.iter_mut()
.find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))
{
record.updated_at = crate::util::now_iso();
record.last_stopped_at = Some(record.updated_at.clone());
let status = status_for_with(
.filter_map(|(key, runtime)| match runtime.child.try_wait() {
Ok(Some(_)) | Err(_) => Some(key.clone()),
Ok(None) => None,
})
.collect();
let records_changed = !exited_keys.is_empty();
let mut statuses = Vec::new();
for key in exited_keys {
runtimes.remove(&key);
super::remove_agent_runtime_receipt(&app, &key);
state.clear_agent_session_cache(&key);
if let Some(record) = records
.iter_mut()
.find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))
{
record.updated_at = crate::util::now_iso();
record.last_stopped_at = Some(record.updated_at.clone());
let status = status_for_with(
&app,
record,
&key,
None,
None,
StatusInputs {
personas: &personas,
global: &global,
},
);
emit_status(&app, &status);
statuses.push(status);
}
}
statuses.extend(runtimes.iter().filter_map(|(key, runtime)| {
let record = records
.iter()
.find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))?;
Some(status_for_with(
&app,
record,
&key,
None,
key,
Some(runtime),
None,
StatusInputs {
personas: &personas,
global: &global,
},
);
emit_status(&app, &status);
statuses.push(status);
))
}));
drop(runtimes);
// Records are only mutated above when a runtime exited — skip the store
// rewrite on the common nothing-changed poll.
if records_changed {
save_managed_agents(&app, &records)?;
}
}
statuses.extend(runtimes.iter().filter_map(|(key, runtime)| {
let record = records
.iter()
.find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))?;
Some(status_for_with(
&app,
record,
key,
Some(runtime),
None,
StatusInputs {
personas: &personas,
global: &global,
},
))
}));
drop(runtimes);
// Records are only mutated above when a runtime exited — skip the store
// rewrite on the common nothing-changed poll.
if records_changed {
save_managed_agents(&app, &records)?;
}
Ok(statuses)
Ok(statuses)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}

pub(crate) fn start_managed_agent_runtime_pair_lazy(
Expand Down Expand Up @@ -572,6 +577,18 @@ pub async fn reconcile_managed_agent_runtimes(
mod tests {
use super::*;

#[test]
fn list_managed_agent_runtimes_returns_a_future() {
fn assert_async_command<F, Fut>(_command: F)
where
F: Fn(AppHandle) -> Fut,
Fut: std::future::Future<Output = Result<Vec<ManagedAgentRuntimeStatus>, String>>,
{
}

assert_async_command(list_managed_agent_runtimes);
}

fn payload(
relay_url: &str,
lifecycle: ManagedAgentRuntimeLifecycle,
Expand Down
15 changes: 12 additions & 3 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,6 @@ export function MembersSidebar({
relayUrl,
}: MembersSidebarProps) {
const channelId = channel?.id ?? null;
const managedAgentRuntimesQuery = useManagedAgentRuntimesQuery({
enabled: open,
});
const queryClient = useQueryClient();
const searchInputRef = React.useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = React.useState("");
Expand Down Expand Up @@ -470,6 +467,18 @@ export function MembersSidebar({
),
[managedAgentsQuery.data],
);
const hasLocalManagedMember = React.useMemo(
() =>
[...bots, ...archived].some(
(member) =>
managedAgentByPubkey.get(normalizePubkey(member.pubkey))?.backend
.type === "local",
),
[archived, bots, managedAgentByPubkey],
);
const managedAgentRuntimesQuery = useManagedAgentRuntimesQuery({
enabled: open && Boolean(relayUrl) && hasLocalManagedMember,
});
const controllableManagedBots = React.useMemo(
() =>
bots.flatMap((member) => {
Expand Down
28 changes: 28 additions & 0 deletions desktop/tests/e2e/channels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4017,6 +4017,25 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => {
).toBeVisible();
});

test("opening a human-only members sidebar skips managed runtime discovery", async ({
page,
}) => {
await page.goto("/");
const baselineCommands = await readCommandLog(page);
const baselineRuntimeListCount = commandCount(
baselineCommands,
"list_managed_agent_runtimes",
);

await openMembersSidebar(page, "random");
await expect(page.getByTestId("members-sidebar-people")).toBeVisible();

const commands = await readCommandLog(page);
expect(commandCount(commands, "list_managed_agent_runtimes")).toBe(
baselineRuntimeListCount,
);
});

test("members sidebar can invite relay-authorized agents", async ({ page }) => {
await installMockBridge(page, {
relayAgents: [
Expand Down Expand Up @@ -4528,8 +4547,17 @@ test("members sidebar can stop and start a managed bot in this community", async
baselineCommands,
"stop_managed_agent",
);
const baselineRuntimeListCount = commandCount(
baselineCommands,
"list_managed_agent_runtimes",
);

await openMembersSidebar(page, "general");
await expect
.poll(async () =>
commandCount(await readCommandLog(page), "list_managed_agent_runtimes"),
)
.toBe(baselineRuntimeListCount + 1);

const agentStatus = page.getByTestId(
`sidebar-managed-agent-status-${agentPubkey}`,
Expand Down
Loading