diff --git a/desktop/src/features/settings/lib/experimentAgentRestart.test.mjs b/desktop/src/features/settings/lib/experimentAgentRestart.test.mjs new file mode 100644 index 000000000..03a4d5058 --- /dev/null +++ b/desktop/src/features/settings/lib/experimentAgentRestart.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyExperimentAndRestartAgents, + describeRestartOutcome, + experimentRequiresAgentRestart, + restartAgentsForExperiment, + selectAgentsToRestart, +} from "./experimentAgentRestart.ts"; + +function agent(overrides = {}) { + return { + pubkey: "deadbeef".repeat(8), + name: "Agent", + status: "running", + backend: { type: "local" }, + ...overrides, + }; +} + +test("only spawn-env experiments require a restart confirmation", () => { + assert.equal(experimentRequiresAgentRestart("acpToolSummaries"), true); + assert.equal(experimentRequiresAgentRestart("pulse"), false); +}); + +test("selectAgentsToRestart picks local running agents only", () => { + const running = agent({ pubkey: "a".repeat(64), name: "Running" }); + const stopped = agent({ + pubkey: "b".repeat(64), + name: "Stopped", + status: "stopped", + }); + const deployed = agent({ + pubkey: "c".repeat(64), + name: "Deployed", + status: "deployed", + backend: { type: "relay-mesh" }, + }); + const remoteRunning = agent({ + pubkey: "d".repeat(64), + name: "Remote", + backend: { type: "relay-mesh" }, + }); + + assert.deepEqual( + selectAgentsToRestart([running, stopped, deployed, remoteRunning]), + [running], + ); +}); + +test("confirm restarts the snapshot after toggle + mirror, in order", async () => { + const calls = []; + const agents = [ + agent({ pubkey: "a".repeat(64), name: "One" }), + agent({ pubkey: "b".repeat(64), name: "Two" }), + ]; + + const outcome = await applyExperimentAndRestartAgents({ + applyToggle: () => calls.push("toggle"), + mirrorExperiments: async () => calls.push("mirror"), + agents, + startAgent: async (pubkey) => calls.push(`start:${pubkey.slice(0, 1)}`), + stopAgent: async (pubkey) => calls.push(`stop:${pubkey.slice(0, 1)}`), + }); + + // Toggle then mirror strictly precede any restart traffic — restarted + // agents must spawn against the NEW mirrored env. + assert.deepEqual(calls.slice(0, 2), ["toggle", "mirror"]); + const restartCalls = calls.slice(2); + assert.equal(restartCalls.filter((c) => c.startsWith("stop:")).length, 2); + assert.equal(restartCalls.filter((c) => c.startsWith("start:")).length, 2); + // Each agent stops before it starts. + assert.ok(restartCalls.indexOf("stop:a") < restartCalls.indexOf("start:a")); + assert.ok(restartCalls.indexOf("stop:b") < restartCalls.indexOf("start:b")); + assert.deepEqual(outcome, { restarted: 2, failures: [] }); +}); + +test("a failed mirror write aborts the restart but keeps the toggle applied", async () => { + const calls = []; + + await assert.rejects( + applyExperimentAndRestartAgents({ + applyToggle: () => calls.push("toggle"), + mirrorExperiments: async () => { + throw new Error("ipc down"); + }, + agents: [agent()], + startAgent: async () => calls.push("start"), + stopAgent: async () => calls.push("stop"), + }), + /ipc down/, + ); + + // Toggle applied (no rollback), zero restart traffic. + assert.deepEqual(calls, ["toggle"]); +}); + +test("cancel path: no orchestration call means no toggle, no restarts", () => { + // The dialog's cancel/dismiss handler only clears local pending state and + // never invokes applyExperimentAndRestartAgents — modeled here as: with no + // running agents selected, there is nothing to restart and outcome is empty. + assert.deepEqual(selectAgentsToRestart([]), []); +}); + +test("partial failure: other agents still restart, failures are collected", async () => { + const one = agent({ pubkey: "a".repeat(64), name: "One" }); + const two = agent({ pubkey: "b".repeat(64), name: "Two" }); + const three = agent({ pubkey: "c".repeat(64), name: "Three" }); + + const outcome = await restartAgentsForExperiment({ + agents: [one, two, three], + stopAgent: async (pubkey) => { + if (pubkey === two.pubkey) throw new Error("stop failed"); + }, + startAgent: async (pubkey) => { + if (pubkey === three.pubkey) throw new Error("spawn failed"); + }, + }); + + assert.equal(outcome.restarted, 1); + assert.deepEqual(outcome.failures, [ + { name: "Two", error: "stop failed" }, + { name: "Three", error: "spawn failed" }, + ]); +}); + +test("describeRestartOutcome messaging covers success and partial failure", () => { + assert.deepEqual(describeRestartOutcome({ restarted: 1, failures: [] }), { + kind: "success", + message: "Restarted 1 agent.", + }); + assert.deepEqual(describeRestartOutcome({ restarted: 3, failures: [] }), { + kind: "success", + message: "Restarted 3 agents.", + }); + + const partial = describeRestartOutcome({ + restarted: 1, + failures: [ + { name: "Two", error: "stop failed" }, + { name: "Three", error: "spawn failed" }, + ], + }); + assert.equal(partial.kind, "error"); + assert.match(partial.message, /Restarted 1 of 3 agents/); + assert.match(partial.message, /Two, Three/); + assert.match(partial.message, /still applied/); +}); diff --git a/desktop/src/features/settings/lib/experimentAgentRestart.ts b/desktop/src/features/settings/lib/experimentAgentRestart.ts new file mode 100644 index 000000000..294561482 --- /dev/null +++ b/desktop/src/features/settings/lib/experimentAgentRestart.ts @@ -0,0 +1,139 @@ +/** + * Restart plumbing for preview experiments whose effect is pinned at agent + * spawn time (env vars set in `spawn_agent_child`). Toggling such an + * experiment repaints the UI immediately, but running agents keep their + * spawn-time env — so the settings card confirms with the user and restarts + * running agents after the toggle is applied. + * + * Pure logic lives here so node tests can cover cancel/confirm/partial- + * failure without a DOM. + */ +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * Preview experiments that gate agent behavior via spawn-time env. + * Toggling these prompts for an agent restart. Keep in sync with the + * spawn gates in `desktop/src-tauri/src/managed_agents/runtime.rs`. + */ +const EXPERIMENTS_REQUIRING_AGENT_RESTART: ReadonlySet = new Set([ + "acpToolSummaries", +]); + +export function experimentRequiresAgentRestart(featureId: string): boolean { + return EXPERIMENTS_REQUIRING_AGENT_RESTART.has(featureId); +} + +type RestartCandidate = Pick & { + backend: { type: string }; +}; + +/** + * Only locally spawned, currently running agents carry the spawn-time env + * this restart exists to refresh. Stopped agents stay stopped; provider + * deployments are not spawned through the local env path. + */ +export function selectAgentsToRestart( + agents: readonly T[], +): T[] { + return agents.filter( + (agent) => agent.backend.type === "local" && agent.status === "running", + ); +} + +export type AgentRestartOutcome = { + restarted: number; + failures: { name: string; error: string }[]; +}; + +/** + * Confirm-time orchestration, in the order that matters: + * + * 1. `applyToggle()` — flip the localStorage override (UI updates now). + * 2. `await mirrorExperiments()` — push the override to the Rust side + * BEFORE any agent respawns, otherwise a restarted agent could read + * the stale mirror and spawn with the old env (the exact confusion + * this modal exists to fix). The passive `useDesktopExperimentsMirror` + * effect also fires, but it's async and unordered — hence the + * explicit await here. + * 3. Restart the agents snapshotted at confirmation time. + * + * A failed mirror write aborts the restart (agents would respawn with the + * old env anyway) but does NOT roll back the toggle — matching the + * best-effort mirror semantics elsewhere; the mirror retries on next boot. + */ +export async function applyExperimentAndRestartAgents({ + applyToggle, + mirrorExperiments, + agents, + startAgent, + stopAgent, +}: { + applyToggle: () => void; + mirrorExperiments: () => Promise; + agents: readonly RestartCandidate[]; + startAgent: (pubkey: string) => Promise; + stopAgent: (pubkey: string) => Promise; +}): Promise { + applyToggle(); + await mirrorExperiments(); + return restartAgentsForExperiment({ agents, startAgent, stopAgent }); +} + +/** + * Stop→start each agent (mirrors `respawnManagedAgentWithRules` semantics). + * One agent's failure never blocks the others; failures are collected for + * the caller's messaging. The experiment toggle is NOT rolled back on + * failure — the setting applied, only the process refresh lagged. + */ +export async function restartAgentsForExperiment({ + agents, + startAgent, + stopAgent, +}: { + agents: readonly RestartCandidate[]; + startAgent: (pubkey: string) => Promise; + stopAgent: (pubkey: string) => Promise; +}): Promise { + const results = await Promise.allSettled( + agents.map(async (agent) => { + await stopAgent(agent.pubkey); + await startAgent(agent.pubkey); + }), + ); + + const outcome: AgentRestartOutcome = { restarted: 0, failures: [] }; + results.forEach((result, index) => { + if (result.status === "fulfilled") { + outcome.restarted += 1; + return; + } + const reason = result.reason; + outcome.failures.push({ + name: agents[index]?.name ?? "unknown agent", + error: reason instanceof Error ? reason.message : String(reason), + }); + }); + return outcome; +} + +/** Human-readable toast copy for a restart outcome. */ +export function describeRestartOutcome(outcome: AgentRestartOutcome): { + kind: "success" | "error"; + message: string; +} { + const total = outcome.restarted + outcome.failures.length; + if (outcome.failures.length === 0) { + return { + kind: "success", + message: + outcome.restarted === 1 + ? "Restarted 1 agent." + : `Restarted ${outcome.restarted} agents.`, + }; + } + const names = outcome.failures.map((failure) => failure.name).join(", "); + return { + kind: "error", + message: `Restarted ${outcome.restarted} of ${total} agents. Failed to restart: ${names}. The experiment setting was still applied — restart these agents manually to pick it up.`, + }; +} diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx index 4684829d3..5f540e41e 100644 --- a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx +++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx @@ -1,12 +1,106 @@ -import { desktopFeatures, useFeatureToggle } from "@/shared/features"; +import * as React from "react"; +import { toast } from "sonner"; + +import { + useStartManagedAgentMutation, + useStopManagedAgentMutation, + useManagedAgentsQuery, +} from "@/features/agents/hooks"; +import { + applyExperimentAndRestartAgents, + describeRestartOutcome, + experimentRequiresAgentRestart, + selectAgentsToRestart, +} from "@/features/settings/lib/experimentAgentRestart"; +import { setDesktopExperiments } from "@/shared/api/tauri"; +import { + desktopFeatures, + getOverrides, + useFeatureToggle, +} from "@/shared/features"; import type { FeatureDefinition } from "@/shared/features"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; import { Switch } from "@/shared/ui/switch"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; function FeatureRow({ feature }: { feature: FeatureDefinition }) { const [enabled, toggle] = useFeatureToggle(feature.id); + const [pendingValue, setPendingValue] = React.useState(null); + const requiresRestart = experimentRequiresAgentRestart(feature.id); + // Only fetch agent state for rows that can trigger a restart. + const agentsQuery = useManagedAgentsQuery({ enabled: requiresRestart }); + const startMutation = useStartManagedAgentMutation(); + const stopMutation = useStopManagedAgentMutation(); const switchId = `feature-toggle-${feature.id}`; + const runningAgents = selectAgentsToRestart(agentsQuery.data ?? []); + + const handleToggle = (value: boolean) => { + // Spawn-env experiments confirm first: the toggle is applied only on + // Confirm, then agents running at confirmation time are restarted so + // their spawn-time env picks up the change. No running agents → nothing + // to restart, apply directly. + if (requiresRestart && runningAgents.length > 0) { + setPendingValue(value); + return; + } + toggle(value); + }; + + const handleConfirm = () => { + if (pendingValue === null) { + return; + } + const value = pendingValue; + setPendingValue(null); + // Snapshot at confirmation time: stopped agents stay stopped. + const agentsToRestart = runningAgents; + // Ordering matters: apply the toggle, await the explicit mirror write to + // the Rust side, THEN restart — so respawned agents read the NEW env. + // (The passive useDesktopExperimentsMirror effect also fires, but it is + // unordered relative to the respawn; see applyExperimentAndRestartAgents.) + void applyExperimentAndRestartAgents({ + applyToggle: () => toggle(value), + mirrorExperiments: () => setDesktopExperiments(getOverrides()), + agents: agentsToRestart, + startAgent: (pubkey) => startMutation.mutateAsync(pubkey), + stopAgent: (pubkey) => stopMutation.mutateAsync(pubkey), + }).then( + (outcome) => { + const { kind, message } = describeRestartOutcome(outcome); + if (kind === "success") { + toast.success(message); + } else { + toast.error(message); + } + }, + (error) => { + // Mirror write failed: the toggle stayed applied (best-effort mirror + // convention), but agents were NOT restarted — tell the user. + toast.error( + `Setting applied, but syncing it to the agent runtime failed (${ + error instanceof Error ? error.message : String(error) + }). Agents were not restarted — restart them manually to pick it up.`, + ); + }, + ); + }; + + const agentCountLabel = + runningAgents.length === 1 + ? "1 running agent" + : `${runningAgents.length} running agents`; + return (
@@ -19,8 +113,46 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) { aria-labelledby={`${switchId}-label`} checked={enabled} data-testid={switchId} - onCheckedChange={toggle} + onCheckedChange={handleToggle} /> + { + if (!open) { + // Cancel / dismiss: leave the setting unchanged. + setPendingValue(null); + } + }} + open={pendingValue !== null} + > + + + + {pendingValue + ? `Enable ${feature.name}?` + : `Disable ${feature.name}?`} + + + This setting takes effect when agents start. Applying it will + restart {agentCountLabel} so they pick up the change; stopped + agents stay stopped. Cancel to leave the setting unchanged. + + + + + + + + + + + +
); }