Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
getManagedAgentPrimaryActionLabel,
isManagedAgentLive,
managedAgentPresence,
startManagedAgentWithRules,
respawnManagedAgentWithRules,
} from "./managedAgentControlActions.ts";
Expand Down Expand Up @@ -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 },
);
});
75 changes: 73 additions & 2 deletions desktop/src/features/agents/lib/managedAgentControlActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
Channel,
ManagedAgent,
PresenceLookup,
PresenceStatus,
RelayAgent,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
Expand Down Expand Up @@ -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<ManagedAgent, "status">) {
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<ManagedAgent, "status" | "backend">,
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<ManagedAgent, "pubkey">,
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)) {
Expand Down
13 changes: 8 additions & 5 deletions desktop/src/features/agents/ui/AgentStatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/features/agents/ui/useManagedAgentActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@ export function MembersSidebar({
: undefined
}
pairAction={pairAction}
presenceLoaded={memberPresenceQuery.data !== undefined}
presenceStatus={
memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null
}
Expand Down
32 changes: 25 additions & 7 deletions desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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") &&
Expand Down Expand Up @@ -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"}
</Badge>
Expand Down Expand Up @@ -265,6 +274,7 @@ export function MembersSidebarMemberCard({
canRemoveMember={canRemoveMember}
canViewActivity={canViewActivity}
disabled={disabled}
agentPresence={agentPresence}
managedAgent={managedAgent}
member={member}
memberIsBot={memberIsBot}
Expand All @@ -288,6 +298,7 @@ export function MembersSidebarMemberCard({
const PEOPLE_ROLES = ["admin", "member", "guest"] as const;

function MemberActionsMenu({
agentPresence,
canChangeRole,
canModerateMember,
canRemoveMember,
Expand All @@ -308,6 +319,7 @@ function MemberActionsMenu({
onViewActivity,
pairAction,
}: {
agentPresence: ManagedAgentPresence;
canChangeRole: boolean;
canModerateMember: boolean;
canRemoveMember: boolean;
Expand Down Expand Up @@ -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,
)}
</DropdownMenuItem>
{onEditRespondTo ? (
<DropdownMenuItem
Expand Down Expand Up @@ -501,8 +516,11 @@ function getPairActionIcon(action: ManagedAgentPairAction) {
return <Play className="h-4 w-4" />;
}

function getManagedAgentActionIcon(agent: ManagedAgent) {
if (isManagedAgentActive(agent)) {
function getManagedAgentActionIcon(
agent: ManagedAgent,
presence: ManagedAgentPresence,
) {
if (isManagedAgentLive(agent, presence)) {
return <Square className="h-4 w-4" />;
}

Expand Down
Loading