Skip to content

fix(desktop): derive remote agent liveness from presence, not backend_agent_id - #5138

Open
dukedorje wants to merge 1 commit into
block:mainfrom
dukedorje:fix/remote-agent-presence-liveness
Open

fix(desktop): derive remote agent liveness from presence, not backend_agent_id#5138
dukedorje wants to merge 1 commit into
block:mainfrom
dukedorje:fix/remote-agent-presence-liveness

Conversation

@dukedorje

Copy link
Copy Markdown

Fixes #4730.

The problem

A provider-backed agent stays online with a live Shutdown button forever after it has shut down — and because the primary action never flips back to Deploy, there is no way to bring it back from that surface.

The agent is not at fault: it publishes kind:10100 with "status":"offline" and kind:20001 presence offline, and the relay applies both. The desktop just never looks. build_managed_agent_summary() derives remote status exclusively from record.backend_agent_id.is_some(), which is write-once — there is no undeploy in v1 — so status is pinned at "deployed" for the life of the record, and every liveness-shaped UI decision keys off it.

This 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); today's wrong dot survives shutdown, app restart, and reboot, because it is not a presence dot at all.

The fix

Follows suggestion (b) from the issue — keep the Rust summary purely control-plane and add a frontend liveness helper — so deployed/not_deployed stay honest as bookkeeping, per I3.

isManagedAgentLive(agent, presence) is now the "is this thing alive" predicate. isManagedAgentActive keeps its name and its meaning, and is reserved for genuinely control-plane questions — e.g. the orphan warning in deleteManagedAgentWithRules, which already read presenceLookup and got this right.

Routed through it:

Surface Was
getManagedAgentPrimaryActionLabel provider + active → "Shutdown", never "Deploy"
handleAgentPrimaryAction branched on isManagedAgentActive → the startManagedAgentWithRules arm was unreachable
agentActionLive (profile) inlined status === "running" || "deployed"
resolveRuntimeTabStatus deployed"running" → green
AgentStatusBadge de-escalation branch tested status === "running", so it could never fire for a remote agent
Members sidebar badge / icon / action same predicate

Once the label is honest, Deploy reaches startManagedAgentWithRulesdeploy, which is already converge-to-at-most-one-live-instance (§Deploy State Machine) and safely re-adopts or recreates. That closes the "cannot bring it back" half without needing the v2 undeploy.

On the loading window

get_presence omits offline/unknown pubkeys (noted in presence.ts), so an absent entry is indistinguishable from "the query hasn't resolved". A bare presence !== undefined test would make every remote agent flash Deploy on app start.

So ManagedAgentPresence carries both axes — { status, loaded } — and liveness falls back to the control-plane axis until presence resolves. That keeps I3's promise of a bounded wrong signal instead of trading one unbounded lie for another. AgentStatusBadge's existing 15s grace period is reused for the same reason.

Local agents are untouched: their status comes from a real pid probe, so the control-plane axis is liveness for them. isManagedAgentLive returns isManagedAgentActive unchanged for backend.type !== "provider".

Secondary: nothing was refetched after !shutdown

handleStop invalidated nothing on the provider branch (unlike handleStart), and the same held for useMembersSidebarActions. With useRelayAgentsQuery polling at 5 minutes and usePresenceQuery backstopping at 60s, fixing the root cause alone would still have left a visible multi-minute lie right when the user is watching for feedback on an action they just took. Both paths now invalidate ["presence"], relay-agents, and managed-agents. (The live transition itself still arrives over the kind:20001 WS subscription; these keep the other two axes from contradicting it.)

One structural note

useAgentLifecycleActions now owns its presence subscription instead of taking it as a prop, so every caller gets the presence-aware branch rather than each having to remember to pass it. react-query dedupes it against the panel's existing query. This also keeps UserProfilePanel.tsx at its 1000-line ratchet — the two presence props on ProfileSummaryView were merged into one presence object for the same reason.

Testing

  • pnpm test4488 pass, 0 fail, including 8 new cases in managedAgentControlActions.test.mjs covering: absent-vs-loaded presence, online/away, the never-deployed case, local-agent fallback, the Deploy/Shutdown label flip, and unchanged local labels.
  • npx tsc --noEmit — clean.
  • pnpm check — clean (biome + file-size ratchet + px-text + pubkey-truncation).

Manual: deploy a provider-backed agent, !shutdown, confirm the dot goes grey, the badge de-escalates, the runtime tab reads stopped, and the button becomes Deploy — then press it and confirm the agent comes back under the same identity.

Related

Searched open PRs; none found addressing this. #2798 (provider config saves don't redeploy) and #4605 (no management surface on other desktops) both cite the missing undeploy, but are different defects. #4537 is presence for relay-discovered agents — opposite direction, different files.

…_agent_id

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 block#4730

Signed-off-by: Duke Jones <104690+dukejones@users.noreply.github.com>
@dukedorje
dukedorje requested a review from a team as a code owner August 7, 2026 02:05
@dukedorje

Copy link
Copy Markdown
Author

Correcting my own "Related" section — I said "none found", and on a closer read that isn't quite right for one PR.

#3449 (buzz-backend-ssh, open) touches managedAgentControlActions.ts and reaches the same diagnosis independently. Its commit 8 adds managedAgentPresenceStatus(agent, presenceLookup) with a comment that reads almost like this PR's: backend_agent_id is write-once, the protocol has no undeploy, so a remote agent that died hours ago is still "deployed" forever.

The two are complementary rather than duplicate:

Only two files overlap (managedAgentControlActions.ts and its test), so whichever lands second is a small rebase. Happy to do that rebase in either direction, or to reshape this on top of their managedAgentPresenceStatus if you'd rather have one helper — just say which you prefer.

One design difference worth surfacing for whoever reconciles them, since it's a real tradeoff rather than an oversight. get_presence omits offline pubkeys, so an absent entry means both "offline" and "not loaded yet". #3449 resolves that to "offline" — correct for a dot, where silence honestly reads as "not known to be alive". This PR carries a loaded flag and falls back to the control-plane axis until presence resolves, because for a button the same resolution makes every remote agent flash Deploy on app start and invites a redundant deploy press. Both readings are right for their own surface; a merged helper would want to keep the distinction rather than pick one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant