From 3f6a437208f40a70a03dc04106e44401ff8e05fd Mon Sep 17 00:00:00 2001 From: Mike Cecconello Date: Mon, 3 Aug 2026 16:34:51 +0200 Subject: [PATCH] fix(desktop): report agent presence from the relay, not the profile default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agents_from_events` fills in `status: "offline"` whenever a kind:10100 agent profile does not carry a status string. Nothing in-tree ever writes that field — the only in-tree writer of kind:10100 sets `channel_add_policy` — so the fallback is taken for every relay-discovered agent, and the Pulse roster reported agents as offline whether or not they were running. Meanwhile the relay already tracks real presence: kind:20001 events backed by a Redis TTL, exposed to the client through `usePresenceQuery`. The two were never joined; the roster built its status map straight off the profile field. Join them, preferring presence where it has an entry. Agents absent from the lookup keep their incoming status, so locally-managed agents continue to use the live process handle — authoritative for the machine running them, and not something the relay can contradict. The merge is a pure function in `agents/lib/agentPresenceStatus.ts` with unit tests covering the offline-fallback override, away, missing entries, unrelated pubkeys, an unloaded lookup, and presence correcting a stale "online" handle. Refs #3996, #4169 Co-Authored-By: Claude Signed-off-by: Mike Cecconello --- .../agents/lib/agentPresenceStatus.test.mjs | 55 +++++++++++++++++++ .../agents/lib/agentPresenceStatus.ts | 39 +++++++++++++ desktop/src/features/pulse/ui/PulseView.tsx | 17 +++--- 3 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 desktop/src/features/agents/lib/agentPresenceStatus.test.mjs create mode 100644 desktop/src/features/agents/lib/agentPresenceStatus.ts diff --git a/desktop/src/features/agents/lib/agentPresenceStatus.test.mjs b/desktop/src/features/agents/lib/agentPresenceStatus.test.mjs new file mode 100644 index 0000000000..9941571d22 --- /dev/null +++ b/desktop/src/features/agents/lib/agentPresenceStatus.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mergeAgentPresenceStatus } from "./agentPresenceStatus.ts"; + +test("mergeAgentPresenceStatus — live presence overrides the kind:10100 offline fallback", () => { + // The bug this fixes: `agents_from_events` defaults every relay-discovered + // agent to "offline" because nothing writes `status` into kind:10100, so a + // running agent was reported offline. + const map = mergeAgentPresenceStatus([{ pubkey: "a", status: "offline" }], { + a: "online", + }); + assert.equal(map.a, "online"); +}); + +test("mergeAgentPresenceStatus — presence reporting away is preserved", () => { + const map = mergeAgentPresenceStatus([{ pubkey: "a", status: "offline" }], { + a: "away", + }); + assert.equal(map.a, "away"); +}); + +test("mergeAgentPresenceStatus — agents with no presence entry keep their own status", () => { + // Locally-managed agents derive status from the live process handle, which + // the relay cannot contradict for the machine running them. + const map = mergeAgentPresenceStatus([{ pubkey: "a", status: "online" }], {}); + assert.equal(map.a, "online"); +}); + +test("mergeAgentPresenceStatus — presence for an unrelated pubkey does not leak in", () => { + const map = mergeAgentPresenceStatus([{ pubkey: "a", status: "offline" }], { + b: "online", + }); + assert.deepEqual(map, { a: "offline" }); +}); + +test("mergeAgentPresenceStatus — a presence lookup that has not loaded yet is a no-op", () => { + const map = mergeAgentPresenceStatus( + [ + { pubkey: "a", status: "online" }, + { pubkey: "b", status: "offline" }, + ], + undefined, + ); + assert.deepEqual(map, { a: "online", b: "offline" }); +}); + +test("mergeAgentPresenceStatus — presence can also move an agent to offline", () => { + // A managed agent whose process died still reports "online" from a stale + // handle; relay presence expiring is what corrects it. + const map = mergeAgentPresenceStatus([{ pubkey: "a", status: "online" }], { + a: "offline", + }); + assert.equal(map.a, "offline"); +}); diff --git a/desktop/src/features/agents/lib/agentPresenceStatus.ts b/desktop/src/features/agents/lib/agentPresenceStatus.ts new file mode 100644 index 0000000000..8b7fa7a181 --- /dev/null +++ b/desktop/src/features/agents/lib/agentPresenceStatus.ts @@ -0,0 +1,39 @@ +import type { PresenceLookup, PresenceStatus } from "@/shared/api/types"; + +/** + * The status fields this merge reads off an agent, whatever its source. + * + * Kept structural rather than importing `RelayAgent` so locally-managed agents + * projected into the same shape can be merged without a cast. + */ +export type AgentStatusInput = { + pubkey: string; + status: PresenceStatus; +}; + +/** + * Build the pubkey → status map for an agent roster, preferring live relay + * presence over the agent's self-declared `kind:10100` status. + * + * Why presence wins: the `status` field on a `kind:10100` agent profile has no + * producer in-tree, so `agents_from_events` falls back to `"offline"` for every + * relay-discovered agent. Rendering that directly reports agents as offline + * whether or not they are actually running. Relay presence (kind:20001, backed + * by a 180s Redis TTL) is the only source that reflects the process's real + * state, so it takes precedence wherever it has an entry. + * + * Agents absent from `presence` keep their incoming status: locally-managed + * agents already derive theirs from the live process handle, which is + * authoritative for the machine running them and is not something the relay + * can contradict. + */ +export function mergeAgentPresenceStatus( + agents: readonly AgentStatusInput[], + presence: PresenceLookup | undefined, +): Record { + const map: Record = {}; + for (const agent of agents) { + map[agent.pubkey] = presence?.[agent.pubkey] ?? agent.status; + } + return map; +} diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 3009076aa8..b6edfdfc8a 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -5,6 +5,8 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { mergeAgentPresenceStatus } from "@/features/agents/lib/agentPresenceStatus"; +import { usePresenceQuery } from "@/features/presence/hooks"; import { useContactListQuery, useUsersBatchQuery, @@ -126,13 +128,14 @@ export function PulseView({ currentPubkey }: PulseViewProps) { () => new Set(agentPubkeys), [agentPubkeys], ); - const agentStatusMap = React.useMemo(() => { - const map: Record = {}; - for (const a of relayAgents) { - map[a.pubkey] = a.status; - } - return map; - }, [relayAgents]); + // Relay presence is the only source that reflects whether an agent process is + // actually up: nothing writes `status` into kind:10100, so every + // relay-discovered agent otherwise renders as offline regardless of state. + const agentPresenceQuery = usePresenceQuery(agentPubkeys); + const agentStatusMap = React.useMemo( + () => mergeAgentPresenceStatus(relayAgents, agentPresenceQuery.data), + [relayAgents, agentPresenceQuery.data], + ); const mentionPubkeys = React.useMemo( () =>