diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 2893027b0b..22a54a0171 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -505,9 +505,8 @@ async fn restart_single_agent_after_install( managed_agents::{ agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, load_personas, record_agent_command, - resolve_effective_agent_env, stop_managed_agent_process, - storage::save_runtime_metadata_batch, sync_managed_agent_processes, AgentReadiness, - BackendKind, + resolve_effective_agent_env, stop_managed_agent_process, storage, + sync_managed_agent_processes, AgentReadiness, BackendKind, }, }; use tauri::Manager; @@ -537,7 +536,7 @@ async fn restart_single_agent_after_install( ¤t_instance_id(&app_for_stop), ); if sync_changed { - save_runtime_metadata_batch(&app_for_stop, &records)?; + storage::save_runtime_metadata_batch(&app_for_stop, &records)?; } // Re-verify eligibility under lock. @@ -591,7 +590,7 @@ async fn restart_single_agent_after_install( // Stop the process. let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; - save_runtime_metadata_batch(&app_for_stop, &records)?; + storage::save_runtime_metadata_batch(&app_for_stop, &records)?; Ok(runtime_keys) }) @@ -645,9 +644,7 @@ fn persist_last_error_on_install( ) -> Result<(), String> { use crate::{ app_state::AppState, - managed_agents::{ - find_managed_agent_mut, load_managed_agents, storage::save_runtime_metadata_batch, - }, + managed_agents::{find_managed_agent_mut, load_managed_agents, storage}, }; use tauri::Manager; let state = app.state::(); @@ -659,7 +656,7 @@ fn persist_last_error_on_install( let record = find_managed_agent_mut(&mut records, pubkey)?; record.last_error = Some(error.to_string()); record.updated_at = crate::util::now_iso(); - save_runtime_metadata_batch(app, &records) + storage::save_runtime_metadata_batch(app, &records) } /// Build the `-l -c` argument list for the install shell. diff --git a/desktop/tests/e2e/desktop-stop.spec.ts b/desktop/tests/e2e/desktop-stop.spec.ts index 21df14154d..87e0de3c99 100644 --- a/desktop/tests/e2e/desktop-stop.spec.ts +++ b/desktop/tests/e2e/desktop-stop.spec.ts @@ -9,6 +9,8 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as }) => { test.setTimeout(60_000); const agent = "a7".repeat(32); + const remoteDesktop = "22222222-2222-4222-8222-222222222222"; + const standardRuntime = { id: "standard-runtime", revision: "3" }; await installMockBridge(page, { managedAgents: [], relayAgents: [ @@ -25,7 +27,7 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as }); await page.goto("/"); await expect(page.getByTestId("open-agents-view")).toBeVisible(); - await page.evaluate(() => { + await page.evaluate((configuration: { id: string; revision: string }) => { const w = window as typeof window & { __STOP_FIXTURE__: { confirmed: boolean; @@ -49,6 +51,22 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as const now = Math.floor(Date.now() / 1000); const local = "11111111-1111-4111-8111-111111111111"; const remote = "22222222-2222-4222-8222-222222222222"; + // The mounted control issues status and catalog actions over the same + // lifecycle IPC before any launch. Identify each signed request by its + // event id so launch accounting stays scoped to the destination Start. + const lifecycleRequests = new Map< + string, + { action: string; desktop: string } + >(); + const eligibleCatalogEntry = { + configuration, + name: "Standard runtime", + host: remote, + runtime: "acp", + model: "claude-opus-4-6", + provider: null, + eligible: true, + }; const sign = async (kind: number, tags: string[][] = []) => JSON.parse( await original("sign_event", { @@ -87,14 +105,44 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as case "read_desktop_placement": case "receive_desktop_lifecycle": return null; - case "prepare_desktop_lifecycle": - w.__STOP_FIXTURE__.lifecyclePrepared++; - return sign(50182, [ + case "prepare_desktop_lifecycle": { + const request = await sign(50182, [ ["p", payload.owner], ["d", payload.desktop], ]); - case "read_desktop_lifecycle_results": - return "provisioning_unavailable"; + lifecycleRequests.set(request.id, { + action: payload.action, + desktop: payload.desktop, + }); + if (payload.action === "start") + w.__STOP_FIXTURE__.lifecyclePrepared++; + return request; + } + case "read_desktop_lifecycle_results": { + const request = lifecycleRequests.get(payload.request.id); + if (request?.action === "preflight") + return { + outcome: "ready", + observation: { + valid_until: now + 600, + running_configuration: null, + catalog: null, + }, + }; + if (request?.action !== "catalog") + return { outcome: "provisioning_unavailable" }; + return { + outcome: "ready", + observation: { + valid_until: now + 600, + running_configuration: null, + catalog: { + entry: request.desktop === remote ? eligibleCatalogEntry : null, + next: null, + }, + }, + }; + } case "prepare_desktop_stop": w.__STOP_FIXTURE__.prepared++; return sign(50180, [ @@ -109,14 +157,18 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as const wire = JSON.parse(payload.message.data); if (wire[0] === "EVENT" && wire[1]?.kind === 50180) w.__STOP_FIXTURE__.sends.push(JSON.stringify(wire[1])); - if (wire[0] === "EVENT" && wire[1]?.kind === 50182) + if ( + wire[0] === "EVENT" && + wire[1]?.kind === 50182 && + lifecycleRequests.get(wire[1].id)?.action === "start" + ) w.__STOP_FIXTURE__.lifecycleSends.push(JSON.stringify(wire[1])); break; } } return original(command, payload, options); }; - }); + }, standardRuntime); await page.getByTestId("open-agents-view").click(); const desktops = page.getByRole("region", { name: "Known Desktops" }); await desktops.getByRole("button", { name: "Refresh", exact: true }).click(); @@ -184,22 +236,32 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as path: "test-results/desktop-stop/04-confirmed.png", }); - // The mounted lifecycle selector shares host labels with the Stop rows. + // The mounted lifecycle controls share host labels with the Stop rows. // IPC explicitly refuses launch; no native process is created by this fixture. const controls = desktops.getByRole("region", { name: "Agent placement controls", }); + // Operation outcomes are announced in the unnamed status region; its + // labeled sibling reports the observed running configuration. + const outcome = controls + .getByRole("status") + .and(controls.locator("p:not([aria-label])")); await controls .getByRole("combobox", { name: "Agent to place" }) .selectOption(agent); + await expect(outcome).toHaveText( + "Choose a configuration for the next Start. This does not change an existing process.", + ); await controls - .getByRole("combobox", { name: "Destination Desktop" }) - .selectOption("22222222-2222-4222-8222-222222222222"); + .getByRole("combobox", { name: "Runtime configuration" }) + .selectOption( + `${remoteDesktop}:${standardRuntime.id}:${standardRuntime.revision}`, + ); await controls .getByRole("button", { name: "Start on destination", exact: true }) .click(); - await expect(controls.getByRole("status")).toHaveText( - "Destination keyless launch provisioning is unavailable. No new process was started.", + await expect(outcome).toHaveText( + "Destination cannot launch this agent. No new process was started.", ); await waitForAnimations(page); await desktops.screenshot({ @@ -208,8 +270,8 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as await controls .getByRole("button", { name: "Retry same request", exact: true }) .click(); - await expect(controls.getByRole("status")).toHaveText( - "Destination keyless launch provisioning is unavailable. No new process was started.", + await expect(outcome).toHaveText( + "Destination cannot launch this agent. No new process was started.", ); const lifecycle = await page.evaluate( () =>