fix(managed-agents): stale harness pin shadows runtime edits; add Restart quick action (#2252)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-21 12:18:25 -07:00
committed by GitHub
co-authored by Brain
parent 083f6d6257
commit ec0e450f1d
5 changed files with 154 additions and 46 deletions
@@ -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.
@@ -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"
);
}
@@ -32,13 +32,9 @@ import {
buildInstanceInputForDefinition,
resolveStartRuntimeForDefinition,
} from "@/features/agents/lib/instanceInputForDefinition";
import {
isManagedAgentActive,
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,
@@ -451,42 +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 { handleAgentPrimaryAction, handleAgentRestart } =
useAgentLifecycleActions({
channels: channelsQuery.data,
managedAgent,
relayAgents: relayAgentsQuery.data,
startManagedAgent: startAgentMutation.mutateAsync,
stopManagedAgent: stopAgentMutation.mutateAsync,
});
const handleInstantiateAgent = React.useCallback(async () => {
if (!resolvedPersona) return;
@@ -828,6 +796,7 @@ export function UserProfilePanel({
followMutation={followMutation}
agentInstruction={agentInstruction}
handleAgentPrimaryAction={handleAgentPrimaryAction}
handleAgentRestart={handleAgentRestart}
handleEditAgent={handleEditAgent}
handleEditPersona={canEditPersona ? handleEditPersona : undefined}
handleInstantiateAgent={handleInstantiateAgent}
@@ -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 ? (
<ProfileQuickAction
disabled={agentActionDisabled}
icon={RefreshCw}
label="Restart"
onClick={onAgentRestart}
testId="user-profile-agent-restart"
/>
) : null}
</div>
);
}
@@ -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<unknown>;
stopManagedAgent: (pubkey: string) => Promise<unknown>;
}) {
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 };
}