From 58d2dfc5d8d544f01e076a80e693ea439da566ff Mon Sep 17 00:00:00 2001 From: Ivan Itzcovich Date: Wed, 29 Jul 2026 01:00:14 +0000 Subject: [PATCH] fix(desktop): prompt immediate restart when respond-to gate changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing "Who can talk to this agent" on a running managed agent instance already persisted respond_to/respond_to_allowlist correctly, but the harness process keeps the old gate baked into its env until restarted. update_managed_agent never auto-restarts by design, and the only signal was a passive "Restart required" badge — easy to miss, especially for an agent that's continuously busy and never hits the idle-gated auto-restart window (buzz#2501, buzz#2950). Offer an immediate "Restart now" action in the save toast when the save changed the gate and the backend confirms drift, reusing existing respawn/restart primitives (respawnManagedAgentWithRules, startManagedAgent/stopManagedAgent, clearActiveTurnsForAgentOnStop). Rebased onto main's #3352 (stateless file-size ratchet against the base tree, replacing the static override ledger): AgentInstanceEditDialog.tsx was already over the line cap and can no longer grow at all, so the pre-existing "saved while stopped -> Start now" toast is extracted alongside the new restart prompt into agentSaveOutcomePrompts.ts (renamed from respondToRestartPrompt.ts), leaving the call site as a net line reduction instead of a net addition. No behavior change to either toast. Signed-off-by: Ivan Itzcovich --- .../lib/managedAgentControlActions.test.mjs | 32 +++++++ .../agents/lib/managedAgentControlActions.ts | 22 +++++ .../agents/ui/AgentInstanceEditDialog.tsx | 29 ++----- .../agents/ui/agentSaveOutcomePrompts.ts | 87 +++++++++++++++++++ 4 files changed, 148 insertions(+), 22 deletions(-) create mode 100644 desktop/src/features/agents/ui/agentSaveOutcomePrompts.ts diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2..d8903f2966 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { startManagedAgentWithRules, respawnManagedAgentWithRules, + shouldOfferImmediateRespondToRestart, } from "./managedAgentControlActions.ts"; function agent(overrides = {}) { @@ -166,3 +167,34 @@ test("test_respawn_onStopped_fires_before_start_resolves", async () => { "onStopped must fire after stop resolves and before start is called", ); }); + +test("test_shouldOfferImmediateRespondToRestart_fires_only_when_gate_changed_and_needs_restart", () => { + assert.equal( + shouldOfferImmediateRespondToRestart({ respondTo: "anyone" }, true), + true, + "respond-to changed + backend confirms drift → offer restart", + ); + assert.equal( + shouldOfferImmediateRespondToRestart( + { respondToAllowlist: ["a".repeat(64)] }, + true, + ), + true, + "allowlist-only change + drift → offer restart", + ); + assert.equal( + shouldOfferImmediateRespondToRestart({ respondTo: "anyone" }, false), + false, + "backend says no drift (e.g. agent already stopped) → never prompt", + ); + assert.equal( + shouldOfferImmediateRespondToRestart({ name: "New Name" }, true), + false, + "unrelated field changed (name) → never prompt for a respond-to reason", + ); + assert.equal( + shouldOfferImmediateRespondToRestart({}, true), + false, + "no fields changed at all → never prompt", + ); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index dbaaaba803..c0b2583c1b 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -35,6 +35,28 @@ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } +/** + * Decide whether a just-saved edit should offer an immediate restart because + * it changed the inbound author gate ("Who can talk to this agent"). + * + * `respond_to`/`respond_to_allowlist` control who may address the agent, but + * a running process keeps its OLD gate until restarted — `update_managed_agent` + * never auto-restarts, and the passive "Restart required" badge is easy to + * miss for a setting whose whole point is to take effect promptly (buzz#2501, + * buzz#2950: agents set to `anyone`/`allowlist` stayed unreachable to + * non-owners because the running harness never picked up the change). Only + * fires when the save actually touched the gate AND the backend confirms the + * running instance's config now differs from what it's live with. + */ +export function shouldOfferImmediateRespondToRestart( + input: { respondTo?: unknown; respondToAllowlist?: unknown }, + needsRestart: boolean, +) { + const respondToChanged = + input.respondTo !== undefined || input.respondToAllowlist !== undefined; + return respondToChanged && needsRestart; +} + export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { if (agent.backend.type === "provider") { return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index f3c410e2ff..a21896e9e9 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { toast } from "sonner"; import { useAcpRuntimesQuery, @@ -83,6 +82,10 @@ import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { resolveModelFieldStatusMessage } from "./agentConfigControls"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; import { showAgentProfileSyncWarning } from "./agentProfileSyncWarning"; +import { + offerRespondToRestartIfNeeded, + offerStartNowPrompt, +} from "./agentSaveOutcomePrompts"; import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog"; import { ADD_CUSTOM_HARNESS_OPTION, @@ -737,28 +740,10 @@ export function AgentInstanceEditDialog({ showAgentProfileSyncWarning(result.agent.name, result.profileSyncError); handleOpenChange(false); onUpdated?.(result.agent); - // The auto-restart policy deliberately never fires for a stopped or - // failing agent (a broken agent must not auto-loop), so an edit meant - // to FIX one silently waits for a manual start. Offer that start - // explicitly instead of relying on the user to know the policy. if (!isManagedAgentActive(result.agent)) { - const startedName = result.agent.name; - toast(`${startedName} saved while stopped.`, { - action: { - label: "Start now", - onClick: () => { - startMutation.mutate(result.agent.pubkey, { - onSuccess: () => toast.success(`${startedName} started.`), - onError: (error) => - toast.error( - error instanceof Error - ? `${startedName} failed to start: ${error.message}` - : `${startedName} failed to start.`, - ), - }); - }, - }, - }); + offerStartNowPrompt(result.agent, startMutation); + } else { + offerRespondToRestartIfNeeded(input, result.agent); } } catch { // React Query stores the error; keep dialog open and render it inline. diff --git a/desktop/src/features/agents/ui/agentSaveOutcomePrompts.ts b/desktop/src/features/agents/ui/agentSaveOutcomePrompts.ts new file mode 100644 index 0000000000..f77b37acc9 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSaveOutcomePrompts.ts @@ -0,0 +1,87 @@ +import { toast } from "sonner"; + +import type { useStartManagedAgentMutation } from "@/features/agents/hooks"; +import { + respawnManagedAgentWithRules, + shouldOfferImmediateRespondToRestart, +} from "@/features/agents/lib/managedAgentControlActions"; +import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; +import { + startManagedAgent, + stopManagedAgent, +} from "@/shared/api/tauriManagedAgents"; +import type { ManagedAgent, UpdateManagedAgentInput } from "@/shared/api/types"; + +/** + * After a managed-agent edit saves, offer an immediate restart if the save + * changed "Who can talk to this agent" on a still-running instance. + * + * `respond_to`/`respond_to_allowlist` is a security-relevant gate: the + * running process keeps its OLD env until restarted (`update_managed_agent` + * never auto-restarts), and the passive "Restart required" badge is easy to + * miss for a setting whose whole point is to take effect promptly. This + * mirrors the existing "saved while stopped → Start now" toast pattern in + * `AgentInstanceEditDialog`, but for the running/needs-restart case (buzz#2501, + * buzz#2950: agents set to `anyone`/`allowlist` stayed unreachable to + * non-owners because the running harness never picked up the change). + */ +export function offerRespondToRestartIfNeeded( + input: Pick, + agent: ManagedAgent, +) { + if (!shouldOfferImmediateRespondToRestart(input, agent.needsRestart)) { + return; + } + + const restartedName = agent.name; + toast(`${restartedName} saved — restart to apply the new setting.`, { + action: { + label: "Restart now", + onClick: () => { + respawnManagedAgentWithRules({ + agent, + startManagedAgent, + stopManagedAgent, + onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey), + }) + .then(() => toast.success(`${restartedName} restarted.`)) + .catch((error: unknown) => + toast.error( + error instanceof Error + ? `${restartedName} failed to restart: ${error.message}` + : `${restartedName} failed to restart.`, + ), + ); + }, + }, + }); +} + +/** + * The auto-restart policy deliberately never fires for a stopped or failing + * agent (a broken agent must not auto-loop), so an edit meant to FIX one + * silently waits for a manual start. Offer that start explicitly instead of + * relying on the user to know the policy. + */ +export function offerStartNowPrompt( + agent: ManagedAgent, + startMutation: ReturnType, +) { + const startedName = agent.name; + toast(`${startedName} saved while stopped.`, { + action: { + label: "Start now", + onClick: () => { + startMutation.mutate(agent.pubkey, { + onSuccess: () => toast.success(`${startedName} started.`), + onError: (error) => + toast.error( + error instanceof Error + ? `${startedName} failed to start: ${error.message}` + : `${startedName} failed to start.`, + ), + }); + }, + }, + }); +}