From b26c74a779634dd78800b3685fa85481e6a8415b Mon Sep 17 00:00:00 2001 From: Olympusbuildz Date: Sat, 15 Aug 2026 09:08:50 -0700 Subject: [PATCH 1/2] fix(desktop): use relay presence for remote agent Deploy/Shutdown Provider-backed agents stay status=deployed while backend_agent_id exists, so profile and sidebar primary actions treated dead pods as live Shutdown with no Deploy path. Prefer relay presence for provider liveness; keep infrastructure status for local agents. Fixes #5938 Signed-off-by: Olympusbuildz Co-authored-by: Olympusbuildz Signed-off-by: Olympusbuildz --- .../lib/managedAgentControlActions.test.mjs | 69 +++++++++++++++++++ .../agents/lib/managedAgentControlActions.ts | 41 ++++++++++- .../features/channels/ui/MembersSidebar.tsx | 6 +- .../channels/ui/MembersSidebarMemberCard.tsx | 18 +++-- .../channels/ui/useMembersSidebarActions.ts | 5 +- .../features/profile/ui/UserProfilePanel.tsx | 1 + .../profile/ui/UserProfilePanelSections.tsx | 16 ++--- .../profile/ui/useAgentLifecycleActions.ts | 14 +++- 8 files changed, 150 insertions(+), 20 deletions(-) diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2e..4dbfaac55d8 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -2,6 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + getManagedAgentPrimaryActionLabel, + isManagedAgentLive, + resolveManagedAgentDisplayPresence, startManagedAgentWithRules, respawnManagedAgentWithRules, } from "./managedAgentControlActions.ts"; @@ -166,3 +169,69 @@ test("test_respawn_onStopped_fires_before_start_resolves", async () => { "onStopped must fire after stop resolves and before start is called", ); }); + +// --- presence-aware primary action / liveness --------------------------------- + +test("provider deployed + presence offline → Deploy, not live", () => { + const remote = agent({ + backend: { type: "provider", id: "blox", config: {} }, + backendAgentId: "remote-1", + status: "deployed", + }); + + assert.equal(isManagedAgentLive(remote, "offline"), false); + assert.equal(getManagedAgentPrimaryActionLabel(remote, "offline"), "Deploy"); +}); + +test("provider deployed + presence online/away → Shutdown, live", () => { + const remote = agent({ + backend: { type: "provider", id: "blox", config: {} }, + backendAgentId: "remote-1", + status: "deployed", + }); + + assert.equal(isManagedAgentLive(remote, "online"), true); + assert.equal(getManagedAgentPrimaryActionLabel(remote, "online"), "Shutdown"); + assert.equal(isManagedAgentLive(remote, "away"), true); + assert.equal(getManagedAgentPrimaryActionLabel(remote, "away"), "Shutdown"); +}); + +test("provider stopped + presence online → Shutdown (presence wins)", () => { + const remote = agent({ + backend: { type: "provider", id: "blox", config: {} }, + backendAgentId: "remote-1", + status: "stopped", + }); + + assert.equal(isManagedAgentLive(remote, "online"), true); + assert.equal(getManagedAgentPrimaryActionLabel(remote, "online"), "Shutdown"); +}); + +test("local running/stopped labels unchanged without presence", () => { + const running = agent({ status: "running" }); + const stopped = agent({ status: "stopped" }); + + assert.equal(isManagedAgentLive(running), true); + assert.equal(getManagedAgentPrimaryActionLabel(running), "Stop"); + assert.equal(isManagedAgentLive(stopped), false); + assert.equal(getManagedAgentPrimaryActionLabel(stopped), "Start agent"); +}); + +test("missing presence on provider → not live / Deploy", () => { + const remote = agent({ + backend: { type: "provider", id: "blox", config: {} }, + backendAgentId: "remote-1", + status: "deployed", + }); + + assert.equal(isManagedAgentLive(remote), false); + assert.equal(isManagedAgentLive(remote, null), false); + assert.equal(isManagedAgentLive(remote, undefined), false); + assert.equal(getManagedAgentPrimaryActionLabel(remote), "Deploy"); + assert.equal(getManagedAgentPrimaryActionLabel(remote, null), "Deploy"); + assert.equal( + resolveManagedAgentDisplayPresence(remote, undefined), + "offline", + ); + assert.equal(resolveManagedAgentDisplayPresence(remote, "online"), "online"); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 8a4a6898cce..7b18b82fc91 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -3,6 +3,7 @@ import type { Channel, ManagedAgent, PresenceLookup, + PresenceStatus, RelayAgent, } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -31,13 +32,49 @@ export type ManagedAgentActionResult = { noticeMessage?: string; }; +/** Infrastructure axis — local process running or remote record still deployed. */ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } -export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { +/** Relay presence axis — agent appears live to peers. */ +export function isManagedAgentPresenceLive( + presence?: PresenceStatus | null, +): boolean { + return presence === "online" || presence === "away"; +} + +/** + * Liveness for primary Deploy/Shutdown (and Stop/Start) affordances. + * Provider-backed agents use relay presence; local agents use infrastructure status. + */ +export function isManagedAgentLive( + agent: Pick, + presence?: PresenceStatus | null, +): boolean { + if (agent.backend.type === "provider") { + return isManagedAgentPresenceLive(presence); + } + return isManagedAgentActive(agent); +} + +/** + * Avatar / presence-dot display for bots and managed agents. + * Prefer real relay presence; never map provider `deployed` → online. + */ +export function resolveManagedAgentDisplayPresence( + _agent: ManagedAgent | undefined, + presence?: PresenceStatus | null, +): PresenceStatus { + return presence ?? "offline"; +} + +export function getManagedAgentPrimaryActionLabel( + agent: ManagedAgent, + presence?: PresenceStatus | null, +) { if (agent.backend.type === "provider") { - return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; + return isManagedAgentLive(agent, presence) ? "Shutdown" : "Deploy"; } if (isManagedAgentActive(agent)) { diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 9ec3151bb09..ec6af722863 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -655,7 +655,11 @@ export function MembersSidebar({ }} onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined} onManagedAgentAction={(agent) => { - void handleAgentLifecycleAction(agent, managedAgentRuntime); + void handleAgentLifecycleAction( + agent, + managedAgentRuntime, + memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null, + ); }} onOpenProfile={handleOpenProfile} onRemoveMember={handleRemoveMember} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index b375649292d..ae1dd54c80e 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -17,6 +17,7 @@ import { import { getManagedAgentPrimaryActionLabel, isManagedAgentActive, + isManagedAgentLive, } from "@/features/agents/lib/managedAgentControlActions"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -279,6 +280,7 @@ export function MembersSidebarMemberCard({ onUntimeout={onUntimeout} onViewActivity={onViewActivity} pairAction={pairAction} + presenceStatus={presenceStatus} /> ) : null} @@ -307,6 +309,7 @@ function MemberActionsMenu({ onUntimeout, onViewActivity, pairAction, + presenceStatus, }: { canChangeRole: boolean; canModerateMember: boolean; @@ -327,6 +330,7 @@ function MemberActionsMenu({ onUntimeout: (member: ChannelMember) => void; onViewActivity?: (pubkey: string) => void; pairAction?: ManagedAgentPairAction; + presenceStatus?: PresenceStatus | null; }) { const showChangeRole = canChangeRole && !memberIsBot && member.role !== "owner"; @@ -367,10 +371,13 @@ function MemberActionsMenu({ > {pairAction ? getPairActionIcon(pairAction) - : getManagedAgentActionIcon(managedAgent)} + : getManagedAgentActionIcon(managedAgent, presenceStatus)} {pairAction ? MANAGED_AGENT_PAIR_ACTION_LABELS[pairAction] - : getManagedAgentPrimaryActionLabel(managedAgent)} + : getManagedAgentPrimaryActionLabel( + managedAgent, + presenceStatus, + )} {onEditRespondTo ? ( ; } -function getManagedAgentActionIcon(agent: ManagedAgent) { - if (isManagedAgentActive(agent)) { +function getManagedAgentActionIcon( + agent: ManagedAgent, + presence?: PresenceStatus | null, +) { + if (isManagedAgentLive(agent, presence)) { return ; } diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index cc8f4062210..95d60fabf49 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -8,6 +8,7 @@ import { import { respawnManagedAgentWithRules, isManagedAgentActive, + isManagedAgentLive, startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; @@ -25,6 +26,7 @@ import type { ChannelMember, ManagedAgent, ManagedAgentRuntimeStatus, + PresenceStatus, } from "@/shared/api/types"; type UseMembersSidebarActionsOptions = { @@ -144,6 +146,7 @@ export function useMembersSidebarActions({ async function handleLifecycleAction( agent: ManagedAgent, runtime?: ManagedAgentRuntimeStatus, + presence?: PresenceStatus | null, ) { clearActionFeedback(); setActiveActionKey(`agent:${agent.pubkey}`); @@ -170,7 +173,7 @@ export function useMembersSidebarActions({ return; } - if (isManagedAgentActive(agent)) { + if (isManagedAgentLive(agent, presence)) { await stopManagedAgentWithRules({ agent, ...EMPTY_AGENT_CONTEXT, diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 998ea3232a8..670f3722776 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -453,6 +453,7 @@ export function UserProfilePanel({ useAgentLifecycleActions({ channels: channelsQuery.data, managedAgent, + presenceStatus, relayAgents: relayAgentsQuery.data, startManagedAgent: startAgentMutation.mutateAsync, stopManagedAgent: stopAgentMutation.mutateAsync, diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index dbe17473f65..54a4379cbac 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -4,7 +4,8 @@ import { ChevronDown, ChevronUp, Pencil } from "lucide-react"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { getManagedAgentPrimaryActionLabel, - isManagedAgentActive, + isManagedAgentLive, + resolveManagedAgentDisplayPresence, } from "@/features/agents/lib/managedAgentControlActions"; import { RestartDiffBadge } from "@/features/agents/ui/RestartDiffBadge"; import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; @@ -190,11 +191,7 @@ export function ProfileSummaryView({ }: ProfileSummaryViewProps) { const activeTurns = useAgentWorking(isBot ? pubkey : null).channels; const avatarStatus = isBot - ? managedAgent - ? isManagedAgentActive(managedAgent) - ? "online" - : "offline" - : (presenceStatus ?? "offline") + ? resolveManagedAgentDisplayPresence(managedAgent, presenceStatus) : presenceStatus; const stickyLayoutRef = React.useRef(null); const [primaryActionsConcealed, setPrimaryActionsConcealed] = @@ -428,12 +425,13 @@ export function ProfileSummaryView({ agentActionDisabled={isAgentActionPending} agentActionLabel={ isOwner === true && managedAgent - ? getManagedAgentPrimaryActionLabel(managedAgent) + ? getManagedAgentPrimaryActionLabel(managedAgent, presenceStatus) : undefined } agentActionLive={ - managedAgent?.status === "running" || - managedAgent?.status === "deployed" + managedAgent + ? isManagedAgentLive(managedAgent, presenceStatus) + : false } onAgentPrimaryAction={ isOwner === true && managedAgent diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts index 62d0d3c7ad6..6d8a0759c1d 100644 --- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -2,23 +2,30 @@ import * as React from "react"; import { toast } from "sonner"; import { - isManagedAgentActive, + isManagedAgentLive, respawnManagedAgentWithRules, startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; -import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; +import type { + Channel, + ManagedAgent, + PresenceStatus, + RelayAgent, +} from "@/shared/api/types"; export function useAgentLifecycleActions({ channels, managedAgent, + presenceStatus, relayAgents, startManagedAgent, stopManagedAgent, }: { channels: readonly Channel[] | undefined; managedAgent: ManagedAgent | undefined; + presenceStatus?: PresenceStatus | null; relayAgents: readonly RelayAgent[] | undefined; startManagedAgent: (pubkey: string) => Promise; stopManagedAgent: (pubkey: string) => Promise; @@ -27,7 +34,7 @@ export function useAgentLifecycleActions({ if (!managedAgent) return; try { - if (isManagedAgentActive(managedAgent)) { + if (isManagedAgentLive(managedAgent, presenceStatus)) { const result = await stopManagedAgentWithRules({ agent: managedAgent, channels: channels ?? [], @@ -58,6 +65,7 @@ export function useAgentLifecycleActions({ }, [ channels, managedAgent, + presenceStatus, relayAgents, startManagedAgent, stopManagedAgent, From d0ab6fa1a66e86d6fa79450c836b77cc69f07842 Mon Sep 17 00:00:00 2001 From: Olympusbuildz Date: Sun, 16 Aug 2026 04:49:25 -0700 Subject: [PATCH 2/2] fix(desktop): local infra presence + keep UserProfilePanel under ratchet Address review on #5967: - resolveManagedAgentDisplayPresence uses infrastructure status for local agents and relay presence only for provider-backed agents. - Keep the presenceStatus wire without growing UserProfilePanel past the 1000-line file-size ratchet. Signed-off-by: Olympusbuildz Co-authored-by: Olympusbuildz Signed-off-by: Olympusbuildz --- .../lib/managedAgentControlActions.test.mjs | 16 ++++++++++++++++ .../agents/lib/managedAgentControlActions.ts | 14 ++++++++++++-- .../src/features/profile/ui/UserProfilePanel.tsx | 3 +-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index 4dbfaac55d8..27ffc0bfb8f 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -235,3 +235,19 @@ test("missing presence on provider → not live / Deploy", () => { ); assert.equal(resolveManagedAgentDisplayPresence(remote, "online"), "online"); }); + +test("local running without presence row stays online (infra, not relay)", () => { + const running = agent({ status: "running" }); + const stopped = agent({ status: "stopped" }); + + assert.equal(resolveManagedAgentDisplayPresence(running, undefined), "online"); + assert.equal(resolveManagedAgentDisplayPresence(running, null), "online"); + assert.equal(resolveManagedAgentDisplayPresence(running, "offline"), "online"); + assert.equal(resolveManagedAgentDisplayPresence(stopped, undefined), "offline"); + assert.equal(resolveManagedAgentDisplayPresence(stopped, "online"), "offline"); +}); + +test("undefined agent falls back to relay presence", () => { + assert.equal(resolveManagedAgentDisplayPresence(undefined, "away"), "away"); + assert.equal(resolveManagedAgentDisplayPresence(undefined, undefined), "offline"); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 7b18b82fc91..9f822140329 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -60,12 +60,22 @@ export function isManagedAgentLive( /** * Avatar / presence-dot display for bots and managed agents. - * Prefer real relay presence; never map provider `deployed` → online. + * Provider-backed: relay presence only (never map infrastructure `deployed` → online). + * Local / non-provider: infrastructure status (running/deployed → online). */ export function resolveManagedAgentDisplayPresence( - _agent: ManagedAgent | undefined, + agent: ManagedAgent | undefined, presence?: PresenceStatus | null, ): PresenceStatus { + if (agent?.backend.type === "provider") { + return presence ?? "offline"; + } + if (agent && isManagedAgentActive(agent)) { + return "online"; + } + if (agent) { + return "offline"; + } return presence ?? "offline"; } diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 670f3722776..947fa3d9805 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -452,8 +452,7 @@ export function UserProfilePanel({ const { handleAgentPrimaryAction, handleAgentRestart } = useAgentLifecycleActions({ channels: channelsQuery.data, - managedAgent, - presenceStatus, + managedAgent, presenceStatus, relayAgents: relayAgentsQuery.data, startManagedAgent: startAgentMutation.mutateAsync, stopManagedAgent: stopAgentMutation.mutateAsync,