From f884cd9828b72494e1bc131b5bca112629195ff6 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 21 Jul 2026 11:21:08 -0700 Subject: [PATCH 1/4] fix(managed-agents): clear stale harness pin when definition runtime changes Onboarding-created agents get agent_command_override pinned to the machine-preferred runtime at create time. The spawn ladder checks that pin before the definition's runtime, so later runtime/model edits were silently shadowed on every restart. Clear the pin when the definition explicitly names a different known runtime; custom command overrides are left untouched. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src/managed_agents/persona_events.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 21b5794995..2033003bc8 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -478,6 +478,25 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe record.model = snapshot.model; record.provider = snapshot.provider; record.runtime = snapshot.runtime; + // Drop a stale create-time harness pin when the definition names a + // different known runtime; custom commands stay pinned. + if let Some(def_runtime) = persona + .runtime + .as_deref() + .map(str::trim) + .filter(|r| !r.is_empty()) + .and_then(crate::managed_agents::known_acp_runtime_exact) + { + if let Some(pin_runtime) = record + .agent_command_override + .as_deref() + .and_then(crate::managed_agents::known_acp_runtime) + { + if !std::ptr::eq(pin_runtime, def_runtime) { + record.agent_command_override = None; + } + } + } // env_vars stay overrides-only. Self-heal records written before the env // refresh: persona env used to be baked into `record.env_vars`, turning // inherited values into pseudo-overrides that shadow later persona edits. From f9d18f81ba938bf3697650a028d1a3a1f442663f Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 21 Jul 2026 11:28:27 -0700 Subject: [PATCH 2/4] feat(desktop): add Restart quick action to agent profile panel Restarting an agent previously required Stop then Start. Add a Restart button next to the Stop action on the owner profile panel for running local agents, reusing respawnManagedAgentWithRules (stop + start). Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../features/profile/ui/UserProfilePanel.tsx | 23 +++++++++++++++++++ .../profile/ui/UserProfilePanelSections.tsx | 22 ++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 09e8d51487..28b8749958 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -34,6 +34,7 @@ import { } from "@/features/agents/lib/instanceInputForDefinition"; import { isManagedAgentActive, + respawnManagedAgentWithRules, startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; @@ -488,6 +489,27 @@ export function UserProfilePanel({ stopAgentMutation.mutateAsync, ]); + const handleAgentRestart = React.useCallback(async () => { + if (!managedAgent) return; + + try { + await respawnManagedAgentWithRules({ + agent: managedAgent, + startManagedAgent: startAgentMutation.mutateAsync, + stopManagedAgent: stopAgentMutation.mutateAsync, + }); + toast.success(`Restarted ${managedAgent.name}.`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Agent restart failed.", + ); + } + }, [ + managedAgent, + startAgentMutation.mutateAsync, + stopAgentMutation.mutateAsync, + ]); + const handleInstantiateAgent = React.useCallback(async () => { if (!resolvedPersona) return; @@ -828,6 +850,7 @@ export function UserProfilePanel({ followMutation={followMutation} agentInstruction={agentInstruction} handleAgentPrimaryAction={handleAgentPrimaryAction} + handleAgentRestart={handleAgentRestart} handleEditAgent={handleEditAgent} handleEditPersona={canEditPersona ? handleEditPersona : undefined} handleInstantiateAgent={handleInstantiateAgent} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 2a46dcc7a5..4514b62d95 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -8,6 +8,7 @@ import { MessageSquare, Pencil, Play, + RefreshCw, Square, UserMinus, UserPlus, @@ -77,6 +78,7 @@ export type ProfileSummaryViewProps = { canInstantiateAgent: boolean; agentInstruction: string | null; handleAgentPrimaryAction: () => void; + handleAgentRestart: () => void; handleEditAgent: () => void; handleEditPersona?: () => void; handleInstantiateAgent: () => void; @@ -189,6 +191,7 @@ export function ProfileSummaryView({ canInstantiateAgent, agentInstruction, handleAgentPrimaryAction, + handleAgentRestart, handleEditAgent, handleEditPersona, handleInstantiateAgent, @@ -369,6 +372,14 @@ export function ProfileSummaryView({ ? handleAgentPrimaryAction : undefined } + onAgentRestart={ + isOwner === true && + managedAgent?.backend.type === "local" && + (managedAgent.status === "running" || + managedAgent.status === "deployed") + ? handleAgentRestart + : undefined + } isFollowing={isFollowing} messagePending={isMessagePending} onMessage={onOpenDm ? handleMessage : undefined} @@ -629,6 +640,7 @@ function ProfilePrimaryActions({ isFollowing, messagePending, onAgentPrimaryAction, + onAgentRestart, onEditAgent, onMessage, pubkey, @@ -642,6 +654,7 @@ function ProfilePrimaryActions({ isFollowing: boolean; messagePending?: boolean; onAgentPrimaryAction?: () => void; + onAgentRestart?: () => void; onEditAgent: () => void; onMessage?: () => void; pubkey: string; @@ -698,6 +711,15 @@ function ProfilePrimaryActions({ testId="user-profile-agent-primary-action" /> ) : null} + {onAgentRestart ? ( + + ) : null} ); } From 894d611be074288bd0bed49dcabed08877a7de11 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 21 Jul 2026 11:37:30 -0700 Subject: [PATCH 3/4] refactor(desktop): extract agent lifecycle actions into a hook UserProfilePanel.tsx crossed the 1025-line file-size guard after the Restart action landed. Move the start/stop/restart handlers into useAgentLifecycleActions so the panel shrinks below the limit. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../features/profile/ui/UserProfilePanel.tsx | 72 +++-------------- .../profile/ui/useAgentLifecycleActions.ts | 80 +++++++++++++++++++ 2 files changed, 89 insertions(+), 63 deletions(-) create mode 100644 desktop/src/features/profile/ui/useAgentLifecycleActions.ts diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 28b8749958..c1f713d230 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -32,14 +32,9 @@ import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, } from "@/features/agents/lib/instanceInputForDefinition"; -import { - isManagedAgentActive, - respawnManagedAgentWithRules, - startManagedAgentWithRules, - stopManagedAgentWithRules, -} from "@/features/agents/lib/managedAgentControlActions"; import { describeLogFile } from "@/features/agents/ui/agentUi"; import { AgentDialog } from "@/features/agents/ui/AgentDialog"; +import { useAgentLifecycleActions } from "@/features/profile/ui/useAgentLifecycleActions"; import { consumePendingOpenEditAgent, type EditAgentFocusTarget, @@ -452,63 +447,14 @@ export function UserProfilePanel({ ], ); - const handleAgentPrimaryAction = React.useCallback(async () => { - if (!managedAgent) return; - - try { - if (isManagedAgentActive(managedAgent)) { - const result = await stopManagedAgentWithRules({ - agent: managedAgent, - channels: channelsQuery.data ?? [], - relayAgents: relayAgentsQuery.data ?? [], - stopManagedAgent: stopAgentMutation.mutateAsync, - }); - toast.success(result.noticeMessage ?? `Stopped ${managedAgent.name}.`); - return; - } - - await startManagedAgentWithRules({ - agent: managedAgent, - startManagedAgent: startAgentMutation.mutateAsync, - }); - toast.success( - managedAgent.backend.type === "provider" - ? `Deploying ${managedAgent.name}.` - : `Started ${managedAgent.name}.`, - ); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Agent action failed.", - ); - } - }, [ - channelsQuery.data, - managedAgent, - relayAgentsQuery.data, - startAgentMutation.mutateAsync, - stopAgentMutation.mutateAsync, - ]); - - const handleAgentRestart = React.useCallback(async () => { - if (!managedAgent) return; - - try { - await respawnManagedAgentWithRules({ - agent: managedAgent, - startManagedAgent: startAgentMutation.mutateAsync, - stopManagedAgent: stopAgentMutation.mutateAsync, - }); - toast.success(`Restarted ${managedAgent.name}.`); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Agent restart failed.", - ); - } - }, [ - managedAgent, - startAgentMutation.mutateAsync, - stopAgentMutation.mutateAsync, - ]); + const { handleAgentPrimaryAction, handleAgentRestart } = + useAgentLifecycleActions({ + channels: channelsQuery.data, + managedAgent, + relayAgents: relayAgentsQuery.data, + startManagedAgent: startAgentMutation.mutateAsync, + stopManagedAgent: stopAgentMutation.mutateAsync, + }); const handleInstantiateAgent = React.useCallback(async () => { if (!resolvedPersona) return; diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts new file mode 100644 index 0000000000..f84e149c11 --- /dev/null +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -0,0 +1,80 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + isManagedAgentActive, + respawnManagedAgentWithRules, + startManagedAgentWithRules, + stopManagedAgentWithRules, +} from "@/features/agents/lib/managedAgentControlActions"; +import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; + +export function useAgentLifecycleActions({ + channels, + managedAgent, + relayAgents, + startManagedAgent, + stopManagedAgent, +}: { + channels: readonly Channel[] | undefined; + managedAgent: ManagedAgent | undefined; + relayAgents: readonly RelayAgent[] | undefined; + startManagedAgent: (pubkey: string) => Promise; + stopManagedAgent: (pubkey: string) => Promise; +}) { + const handleAgentPrimaryAction = React.useCallback(async () => { + if (!managedAgent) return; + + try { + if (isManagedAgentActive(managedAgent)) { + const result = await stopManagedAgentWithRules({ + agent: managedAgent, + channels: channels ?? [], + relayAgents: relayAgents ?? [], + stopManagedAgent, + }); + toast.success(result.noticeMessage ?? `Stopped ${managedAgent.name}.`); + return; + } + + await startManagedAgentWithRules({ + agent: managedAgent, + startManagedAgent, + }); + toast.success( + managedAgent.backend.type === "provider" + ? `Deploying ${managedAgent.name}.` + : `Started ${managedAgent.name}.`, + ); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Agent action failed.", + ); + } + }, [ + channels, + managedAgent, + relayAgents, + startManagedAgent, + stopManagedAgent, + ]); + + const handleAgentRestart = React.useCallback(async () => { + if (!managedAgent) return; + + try { + await respawnManagedAgentWithRules({ + agent: managedAgent, + startManagedAgent, + stopManagedAgent, + }); + toast.success(`Restarted ${managedAgent.name}.`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Agent restart failed.", + ); + } + }, [managedAgent, startManagedAgent, stopManagedAgent]); + + return { handleAgentPrimaryAction, handleAgentRestart }; +} From cdf46c23b7fc03fea1bc75f603ef456c16e54743 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 21 Jul 2026 11:48:19 -0700 Subject: [PATCH 4/4] test(spawn-hash): update pin-vs-runtime-edit contract agent_command_override_beats_definition_runtime_change asserted the old behavior the harness-pin fix removes. Replace it with the new contract: a stale known-runtime pin yields to a definition runtime edit (badge fires), while a custom command override still wins. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src/managed_agents/spawn_hash/tests.rs | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 1f53a365f3..203863b0ed 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -366,21 +366,39 @@ fn definition_runtime_edit_changes_hash_for_materialized_record() { ); } -/// (c) An explicit agent_command_override (ladder step 1) must beat a -/// changed definition runtime — the badge must NOT fire for a pinned instance. +/// (c) A pin naming a KNOWN runtime no longer beats a changed definition +/// runtime — apply_persona_snapshot clears the stale pin, so the badge fires. #[test] -fn agent_command_override_beats_definition_runtime_change() { +fn known_runtime_pin_yields_to_definition_runtime_change() { let mut rec = record(); rec.persona_id = Some("pers".into()); rec.runtime = Some("goose".into()); // materialized runtime - rec.agent_command_override = Some("goose".into()); // explicit per-instance pin + rec.agent_command_override = Some("goose".into()); // create-time pin + + let before = [persona("pers", Some("goose"), "prompt")]; + let after = [persona("pers", Some("claude"), "prompt")]; + assert_ne!( + spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + "stale known-runtime pin must not shadow a definition runtime edit" + ); +} + +/// (c2) A custom-command override (no matching known runtime) still beats a +/// changed definition runtime — the badge must NOT fire for such a pin. +#[test] +fn custom_command_override_beats_definition_runtime_change() { + let mut rec = record(); + rec.persona_id = Some("pers".into()); + rec.runtime = Some("goose".into()); // materialized runtime + rec.agent_command_override = Some("/opt/custom/my-agent".into()); let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_eq!( spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), - "explicit override must win regardless of definition runtime change" + "custom command override must win regardless of definition runtime change" ); }