From 26c5f616c739c49a5b30072074902a7b578168a3 Mon Sep 17 00:00:00 2001 From: Duke Jones <104690+dukejones@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:04:32 -0700 Subject: [PATCH] fix(desktop): derive remote agent liveness from presence, not backend_agent_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider-backed agent stayed "online" forever after shutting down, with the primary action pinned at "Shutdown" — so there was no way to redeploy it. Remote status is derived from `backend_agent_id`, which v1 never clears (there is no `undeploy` op). Every liveness-shaped UI decision keyed off that field, which contradicts invariant I3 ("Presence is the status", docs/remote-agents.md): the deployment axis is bookkeeping, not liveness. I3 also promises a *bounded* wrong dot (180s PRESENCE_TTL_SECS); this one survived app restart and reboot. Adds `isManagedAgentLive(agent, presence)` and routes the liveness-shaped call sites through it, keeping `isManagedAgentActive` for genuinely control-plane questions (e.g. the orphan warning in deleteManagedAgentWithRules, which already read presence and got this right): - primary action label + `agentActionLive` (profile panel) - `handleAgentPrimaryAction` — this is what made the deploy arm reachable again - runtime tab dot, status badge, members-sidebar badge/icon/action `get_presence` omits offline pubkeys, so an absent entry is indistinguishable from "not loaded yet". `ManagedAgentPresence` carries both axes and liveness falls back to the control-plane axis until presence resolves — otherwise every remote agent would flash "Deploy" on app start, trading one unbounded lie for another. Local agents are untouched: their status is a real pid probe. Also invalidates presence / relay-agents / managed-agents after a `!shutdown` send. It is a message, not a mutation, so nothing invalidated on its own and the roster lagged up to 5 minutes right when the user was watching for feedback. `useAgentLifecycleActions` now owns its presence subscription rather than taking it as a prop, so every caller gets the presence-aware branch; react-query dedupes it against the panel's existing query. Fixes #4730 Signed-off-by: Duke Jones <104690+dukejones@users.noreply.github.com> --- .../lib/managedAgentControlActions.test.mjs | 96 +++++++++++++++++++ .../agents/lib/managedAgentControlActions.ts | 75 ++++++++++++++- .../features/agents/ui/AgentStatusBadge.tsx | 13 ++- .../agents/ui/useManagedAgentActions.ts | 8 ++ .../features/channels/ui/MembersSidebar.tsx | 1 + .../channels/ui/MembersSidebarMemberCard.tsx | 32 +++++-- .../channels/ui/useMembersSidebarActions.ts | 23 ++++- .../features/profile/ui/UserProfilePanel.tsx | 2 +- .../profile/ui/UserProfilePanelSections.tsx | 29 +++--- .../profile/ui/useAgentLifecycleActions.ts | 19 +++- 10 files changed, 269 insertions(+), 29 deletions(-) diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2..a0585fe3fe 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, + managedAgentPresence, startManagedAgentWithRules, respawnManagedAgentWithRules, } from "./managedAgentControlActions.ts"; @@ -166,3 +169,96 @@ test("test_respawn_onStopped_fires_before_start_resolves", async () => { "onStopped must fire after stop resolves and before start is called", ); }); + +// ── Remote-agent liveness (I3: "Presence is the status") ───────────────────── +// Regression coverage for #4730: a provider-backed agent that has shut down kept reading as +// live because status is derived from backend_agent_id, which v1 never clears. + +const remote = (overrides = {}) => + agent({ + backend: { type: "provider", id: "mjolnir" }, + backendAgentId: "vm-1234", + status: "deployed", + ...overrides, + }); + +const presenceOf = (status, loaded = true) => ({ status, loaded }); + +test("remote agent with no presence reads as not live once presence has loaded", () => { + // get_presence omits offline pubkeys, so "shut down" arrives as an absent entry. + assert.equal(isManagedAgentLive(remote(), presenceOf(undefined)), false); + assert.equal(isManagedAgentLive(remote(), presenceOf("offline")), false); +}); + +test("remote agent that is online or away reads as live", () => { + assert.equal(isManagedAgentLive(remote(), presenceOf("online")), true); + assert.equal(isManagedAgentLive(remote(), presenceOf("away")), true); +}); + +test("remote agent does not flash dead while presence is still loading", () => { + // Falling back to the control-plane axis here keeps I3's promise of a *bounded* wrong + // signal instead of trading one unbounded lie for another. + assert.equal( + isManagedAgentLive(remote(), presenceOf(undefined, false)), + true, + ); +}); + +test("remote agent that was never deployed is not live regardless of presence", () => { + const undeployed = remote({ backendAgentId: null, status: "not_deployed" }); + assert.equal(isManagedAgentLive(undeployed, presenceOf("online")), false); +}); + +test("local agents keep using the pid-probed status, not presence", () => { + const running = agent({ status: "running" }); + // A local agent mid-start may have no presence yet; it is still running. + assert.equal(isManagedAgentLive(running, presenceOf(undefined)), true); + assert.equal( + isManagedAgentLive(agent({ status: "stopped" }), presenceOf("online")), + false, + ); +}); + +test("shut-down remote agent offers Deploy, not Shutdown", () => { + // The bug: this returned "Shutdown" forever, making the deploy arm unreachable. + assert.equal( + getManagedAgentPrimaryActionLabel(remote(), presenceOf(undefined)), + "Deploy", + ); + assert.equal( + getManagedAgentPrimaryActionLabel(remote(), presenceOf("online")), + "Shutdown", + ); +}); + +test("local agent action labels are unchanged", () => { + const p = presenceOf(undefined); + assert.equal( + getManagedAgentPrimaryActionLabel(agent({ status: "running" }), p), + "Stop", + ); + assert.equal( + getManagedAgentPrimaryActionLabel(agent({ status: "stopped" }), p), + "Restart Agent", + ); + assert.equal( + getManagedAgentPrimaryActionLabel(agent({ status: "not_deployed" }), p), + "Start Agent", + ); +}); + +test("managedAgentPresence distinguishes an unloaded lookup from an absent entry", () => { + const a = remote(); + assert.deepEqual(managedAgentPresence(a, undefined), { + status: undefined, + loaded: false, + }); + assert.deepEqual(managedAgentPresence(a, {}), { + status: undefined, + loaded: true, + }); + assert.deepEqual( + managedAgentPresence(a, { [a.pubkey.toLowerCase()]: "online" }), + { status: "online", loaded: true }, + ); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 50a92e4f17..a324506f91 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,83 @@ export type ManagedAgentActionResult = { noticeMessage?: string; }; +/** + * The **control-plane** axis: does infrastructure for this agent exist? + * + * For remote (provider) agents this is derived from `backend_agent_id` and is deliberately + * write-once — it stays `deployed` after `!shutdown`, because the provider may have allocated a + * VM or container that outlives the process. It is bookkeeping, **not liveness**. + * + * Use this only for genuinely control-plane questions ("would deleting orphan a deployment?"). + * For "is this thing alive right now", use {@link isManagedAgentLive}. + */ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } -export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { +/** Relay presence for an agent, plus whether presence has loaded at all yet. */ +export type ManagedAgentPresence = { + status: PresenceStatus | undefined; + loaded: boolean; +}; + +/** + * The **live** axis: is this agent's harness connected right now? + * + * Per invariant I3 ("Presence is the status", `docs/remote-agents.md`), a remote agent's live + * state is derived exclusively from relay presence self-signed by the agent key — never from the + * deployment axis, which never clears without a `undeploy` operation that v1 does not have. + * + * Local agents are unaffected: their status comes from a real pid probe, so the control-plane + * axis *is* liveness for them. + * + * While presence is still loading we fall back to the control-plane axis rather than reporting a + * live agent as dead — otherwise every remote agent would flash "Deploy" on app start. This keeps + * I3's promise of a *bounded* wrong signal rather than trading one unbounded lie for another. + */ +export function isManagedAgentLive( + agent: Pick, + presence: ManagedAgentPresence, +): boolean { + if (agent.backend.type !== "provider") { + return isManagedAgentActive(agent); + } + + // Nothing was ever deployed — no presence can make this live. + if (!isManagedAgentActive(agent)) { + return false; + } + + if (!presence.loaded) { + return true; + } + + return presence.status === "online" || presence.status === "away"; +} + +/** Resolve an agent's presence out of a lookup keyed by normalized pubkey. */ +export function managedAgentPresence( + agent: Pick, + presenceLookup: PresenceLookup | null | undefined, +): ManagedAgentPresence { + if (!presenceLookup) { + return { status: undefined, loaded: false }; + } + return { + status: presenceLookup[normalizePubkey(agent.pubkey)], + loaded: true, + }; +} + +export function getManagedAgentPrimaryActionLabel( + agent: ManagedAgent, + presence: ManagedAgentPresence, +) { if (agent.backend.type === "provider") { - return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; + // Deploy converges to at-most-one-live-instance (§Deploy State Machine), so offering + // "Deploy" for an agent whose deployment record still exists is safe: the provider + // re-adopts a live instance or recreates a reaped one. + return isManagedAgentLive(agent, presence) ? "Shutdown" : "Deploy"; } if (isManagedAgentActive(agent)) { diff --git a/desktop/src/features/agents/ui/AgentStatusBadge.tsx b/desktop/src/features/agents/ui/AgentStatusBadge.tsx index 15858b9ebd..63422bef2c 100644 --- a/desktop/src/features/agents/ui/AgentStatusBadge.tsx +++ b/desktop/src/features/agents/ui/AgentStatusBadge.tsx @@ -24,12 +24,15 @@ export function AgentStatusBadge({ return () => clearTimeout(timer); }, []); - const isActive = status === "running" || status === "deployed"; + // A deployed remote agent that is not present has shut down; the deployment record survives + // because v1 has no `undeploy`, so it must not keep reading as live (I3, docs/remote-agents.md). + const presenceSaysOffline = + presenceLoaded && (!presenceStatus || presenceStatus === "offline"); + const isActive = + (status === "running" || status === "deployed") && + !(status === "deployed" && !inGracePeriod && presenceSaysOffline); const isStarting = - !inGracePeriod && - presenceLoaded && - status === "running" && - (!presenceStatus || presenceStatus === "offline"); + !inGracePeriod && presenceSaysOffline && status === "running"; const variant: "default" | "warning" | "secondary" = isWorking ? "default" diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 6068ad1639..d04d1bb1a7 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -279,6 +279,14 @@ export function useManagedAgentActions() { }); if (agent.backend.type === "local") { clearActiveTurnsForAgentOnStop(pubkey); + } else { + // Remote stop is a `!shutdown` message, not a mutation, so nothing invalidates on its + // own. Without this the roster lags up to 5 minutes and presence up to 60s, right when + // the user is watching for feedback. (The live transition still arrives over the kind:20001 + // WS subscription; these refetches keep the other two axes from contradicting it.) + void managedPresenceQuery.refetch(); + void relayAgentsQuery.refetch(); + void managedAgentsQuery.refetch(); } if (result.noticeMessage) { setActionNoticeMessage(result.noticeMessage); diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 459e8f7776..2a0e8c0204 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -682,6 +682,7 @@ export function MembersSidebar({ : undefined } pairAction={pairAction} + presenceLoaded={memberPresenceQuery.data !== undefined} presenceStatus={ memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null } diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index b375649292..9452d6a53d 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -16,7 +16,8 @@ import { import { getManagedAgentPrimaryActionLabel, - isManagedAgentActive, + isManagedAgentLive, + type ManagedAgentPresence, } from "@/features/agents/lib/managedAgentControlActions"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -71,6 +72,8 @@ type MembersSidebarMemberCardProps = { onUnban: (member: ChannelMember) => void; onUntimeout: (member: ChannelMember) => void; onViewActivity?: (pubkey: string) => void; + /** Whether the presence query has resolved; an absent entry is only "offline" once it has. */ + presenceLoaded?: boolean; presenceStatus?: PresenceStatus | null; profileAvatarUrl?: string | null; viewerIsOwner: boolean; @@ -139,12 +142,17 @@ export function MembersSidebarMemberCard({ onUnban, onUntimeout, onViewActivity, + presenceLoaded = false, presenceStatus, profileAvatarUrl, viewerIsOwner, }: MembersSidebarMemberCardProps) { const roleLabel = formatRoleLabel(member, memberIsBot); const disabled = isActionPending || isArchived; + const agentPresence: ManagedAgentPresence = { + loaded: presenceLoaded, + status: presenceStatus ?? undefined, + }; const canViewActivity = memberIsBot && (viewerIsOwner || managedAgent?.backend.type === "local") && @@ -214,14 +222,15 @@ export function MembersSidebarMemberCard({ ? agentCommunityAvailability(managedAgentRuntime) === "Here" ? "default" : "secondary" - : managedAgent && isManagedAgentActive(managedAgent) + : managedAgent && + isManagedAgentLive(managedAgent, agentPresence) ? "default" : "secondary" } > {managedAgentRuntime ? agentCommunityAvailability(managedAgentRuntime) - : managedAgent && isManagedAgentActive(managedAgent) + : managedAgent && isManagedAgentLive(managedAgent, agentPresence) ? "Running" : "Stopped"} @@ -265,6 +274,7 @@ export function MembersSidebarMemberCard({ canRemoveMember={canRemoveMember} canViewActivity={canViewActivity} disabled={disabled} + agentPresence={agentPresence} managedAgent={managedAgent} member={member} memberIsBot={memberIsBot} @@ -288,6 +298,7 @@ export function MembersSidebarMemberCard({ const PEOPLE_ROLES = ["admin", "member", "guest"] as const; function MemberActionsMenu({ + agentPresence, canChangeRole, canModerateMember, canRemoveMember, @@ -308,6 +319,7 @@ function MemberActionsMenu({ onViewActivity, pairAction, }: { + agentPresence: ManagedAgentPresence; canChangeRole: boolean; canModerateMember: boolean; canRemoveMember: boolean; @@ -367,10 +379,13 @@ function MemberActionsMenu({ > {pairAction ? getPairActionIcon(pairAction) - : getManagedAgentActionIcon(managedAgent)} + : getManagedAgentActionIcon(managedAgent, agentPresence)} {pairAction ? MANAGED_AGENT_PAIR_ACTION_LABELS[pairAction] - : getManagedAgentPrimaryActionLabel(managedAgent)} + : getManagedAgentPrimaryActionLabel( + managedAgent, + agentPresence, + )} {onEditRespondTo ? ( ; } -function getManagedAgentActionIcon(agent: ManagedAgent) { - if (isManagedAgentActive(agent)) { +function getManagedAgentActionIcon( + agent: ManagedAgent, + presence: ManagedAgentPresence, +) { + if (isManagedAgentLive(agent, presence)) { return ; } diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index cc8f406221..f04ed40e43 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -2,12 +2,16 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { + managedAgentsQueryKey, + relayAgentsQueryKey, useStartManagedAgentMutation, useStopManagedAgentMutation, } from "@/features/agents/hooks"; import { respawnManagedAgentWithRules, isManagedAgentActive, + isManagedAgentLive, + managedAgentPresence, startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; @@ -16,6 +20,7 @@ import { useManagedAgentRuntimeAction, } from "@/features/agents/managedAgentRuntimeHooks"; import { managedAgentPairAction } from "@/features/agents/managedAgentRuntimeStatus"; +import { usePresenceQuery } from "@/features/presence/hooks"; import { channelsQueryKey, useRemoveChannelMemberMutation, @@ -56,6 +61,10 @@ export function useMembersSidebarActions({ relayUrl, }: UseMembersSidebarActionsOptions) { const queryClient = useQueryClient(); + // Remote-agent liveness is relay presence, not the deployment record (I3). + const managedPresenceQuery = usePresenceQuery( + controllableManagedBots.map((agent) => agent.pubkey), + ); const removeMemberMutation = useRemoveChannelMemberMutation(channelId); const startManagedAgentMutation = useStartManagedAgentMutation(); const stopManagedAgentMutation = useStopManagedAgentMutation(); @@ -170,7 +179,12 @@ export function useMembersSidebarActions({ return; } - if (isManagedAgentActive(agent)) { + if ( + isManagedAgentLive( + agent, + managedAgentPresence(agent, managedPresenceQuery.data), + ) + ) { await stopManagedAgentWithRules({ agent, ...EMPTY_AGENT_CONTEXT, @@ -179,6 +193,13 @@ export function useMembersSidebarActions({ }); if (agent.backend.type === "local") { clearActiveTurnsForAgentOnStop(agent.pubkey); + } else { + // `!shutdown` is a message, not a mutation — nothing invalidates on its own. + void queryClient.invalidateQueries({ queryKey: ["presence"] }); + void queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + void queryClient.invalidateQueries({ + queryKey: managedAgentsQueryKey, + }); } setActionNoticeMessage( agent.backend.type === "provider" diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index cb188dd008..b4ee369dc3 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -841,7 +841,7 @@ export function UserProfilePanel({ }) : undefined } - presenceStatus={presenceStatus} + presence={{ loaded: presenceQuery.isSuccess, status: presenceStatus }} profile={profile} pubkey={effectivePubkey} relayAgent={relayAgent} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 22647eb286..caef3c885d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -9,7 +9,11 @@ import { import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; -import { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions"; +import { + getManagedAgentPrimaryActionLabel, + isManagedAgentLive, + type ManagedAgentPresence, +} from "@/features/agents/lib/managedAgentControlActions"; import { RestartDiffBadge } from "@/features/agents/ui/RestartDiffBadge"; import { ManagedAgentLogPanel } from "@/features/agents/ui/ManagedAgentLogPanel"; import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; @@ -101,7 +105,9 @@ export type ProfileSummaryViewProps = { onOpenDm?: (pubkeys: string[]) => Promise | void; /** Mint an agent trading card. Present only for owner-managed personas. */ onCreateCard?: () => void; - presenceStatus: "online" | "away" | "offline" | undefined; + /** Relay presence for this profile: the status plus whether the query has resolved at all + * (get_presence omits offline pubkeys, so an absent entry only means "offline" once loaded). */ + presence: ManagedAgentPresence; profile: ReturnType["data"]; pubkey: string | null; relayAgent: RelayAgent | undefined; @@ -130,9 +136,11 @@ const PROFILE_HERO_PRESENCE_BADGE = { function resolveRuntimeTabStatus({ diagnosticsError, managedAgent, + presence, }: { diagnosticsError: boolean; managedAgent: ManagedAgent | undefined; + presence: ManagedAgentPresence; }): RuntimeTabStatus | undefined { if (diagnosticsError || managedAgent?.lastError) { return "error"; @@ -142,11 +150,9 @@ function resolveRuntimeTabStatus({ return undefined; } - if (managedAgent.status === "running" || managedAgent.status === "deployed") { - return "running"; - } - - return "stopped"; + // I3: for remote agents the green dot must follow relay presence, not the deployment record — + // `deployed` never clears without an `undeploy` op, which v1 does not have. + return isManagedAgentLive(managedAgent, presence) ? "running" : "stopped"; } function RuntimeTabStatusDot({ status }: { status: RuntimeTabStatus }) { @@ -215,7 +221,7 @@ export function ProfileSummaryView({ onTabChange, onOpenDm, onCreateCard, - presenceStatus, + presence, profile, pubkey, relayAgent, @@ -268,9 +274,11 @@ export function ProfileSummaryView({ ) : ( "View" ); + const presenceStatus = presence.status; const runtimeTabStatus = resolveRuntimeTabStatus({ diagnosticsError: diagnosticsErrorField !== undefined, managedAgent, + presence, }); const tabs = React.useMemo(() => { @@ -359,12 +367,11 @@ export function ProfileSummaryView({ agentActionDisabled={isAgentActionPending} agentActionLabel={ isOwner === true && managedAgent - ? getManagedAgentPrimaryActionLabel(managedAgent) + ? getManagedAgentPrimaryActionLabel(managedAgent, presence) : undefined } agentActionLive={ - managedAgent?.status === "running" || - managedAgent?.status === "deployed" + managedAgent ? isManagedAgentLive(managedAgent, presence) : false } onAgentPrimaryAction={ isOwner === true && managedAgent diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts index 62d0d3c7ad..c413277321 100644 --- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -2,12 +2,14 @@ import * as React from "react"; import { toast } from "sonner"; import { - isManagedAgentActive, + isManagedAgentLive, + managedAgentPresence, respawnManagedAgentWithRules, startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; +import { usePresenceQuery } from "@/features/presence/hooks"; import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; export function useAgentLifecycleActions({ @@ -23,11 +25,23 @@ export function useAgentLifecycleActions({ startManagedAgent: (pubkey: string) => Promise; stopManagedAgent: (pubkey: string) => Promise; }) { + // The live axis for remote agents (I3). Owned here rather than passed in, so every caller of + // this hook gets the presence-aware branch; react-query dedupes the subscription. + const presenceQuery = usePresenceQuery( + managedAgent ? [managedAgent.pubkey] : [], + ); + const presence = managedAgent + ? managedAgentPresence(managedAgent, presenceQuery.data) + : { status: undefined, loaded: false }; + const handleAgentPrimaryAction = React.useCallback(async () => { if (!managedAgent) return; try { - if (isManagedAgentActive(managedAgent)) { + // Remote agents: a shut-down agent must fall through to the deploy arm. Keying this on + // the deployment record instead of presence made that arm unreachable, so a remote agent + // could never be brought back from this surface. + if (isManagedAgentLive(managedAgent, presence)) { const result = await stopManagedAgentWithRules({ agent: managedAgent, channels: channels ?? [], @@ -58,6 +72,7 @@ export function useAgentLifecycleActions({ }, [ channels, managedAgent, + presence, relayAgents, startManagedAgent, stopManagedAgent,