fix(desktop): guard stopped agent startup with authored presence

Show exact-key Online/Away independently of local lifecycle. Replace card Start with presence and guard profile/member/callback startup without inventing Stop authority. Cover rendered controls, keyboard activation and presence transitions.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-08-31 18:47:37 -04:00
parent fb7c88955f
commit 07f3a16acb
13 changed files with 417 additions and 11 deletions

View File

@@ -207,7 +207,10 @@ with a TypeScript lookup table or an id comparison in a component.
[the identity contract](../../../../docs/agent-profile-identity.md).
Availability dots read relay presence, never a saved deployment
receipt or runtime status. Failed/disconnected reads are unknown; lifecycle
actions retain their separate routing. See
actions retain their separate routing. Current exact-key Online/Away presence
suppresses Start for an inactive local record without granting Stop authority;
list/profile/member startup guards must not interpret Offline as proof of safe
startup. See
[the availability contract](../../../../docs/agent-availability.md).
14. **Thinking effort has two surfaces: a local-only WRITE control and a
read-only two-facts DISPLAY.** The write control is `EffortPickerField`

View File

@@ -89,7 +89,31 @@ for (const lifecycle of ["running", "stopped"]) {
onStart() {},
}),
);
assert.equal(html.includes('data-testid="start"'), !isActive);
assert.equal(html.includes('data-testid="active"'), isActive);
assert.equal(html.includes('data-testid="start"'), false);
assert.equal(html.includes('data-testid="active"'), true);
});
}
for (const availability of ["online", "away"]) {
test(`stale restart and runtime error cannot hide stopped ${availability} presence`, () => {
const html = renderToStaticMarkup(
createElement(AgentRuntimeAvatarControl, {
activeTestId: "active",
startTestId: "start",
errorTestId: "error",
isActive: false,
isStarting: false,
requiresRestart: true,
errorLabel: "Previous startup failed",
availability,
label: "Agent",
onStart() {},
}),
);
assert.match(html, /data-testid="active"/);
assert.doesNotMatch(
html,
/data-testid="start"|data-testid="error"|<button/,
);
});
}

View File

@@ -14,6 +14,19 @@ export function resolveAgentAvailability(
return presenceLoaded && connected ? (status ?? "offline") : undefined;
}
/** Positive presence blocks another start, but never grants lifecycle control.
* Missing/offline presence is not proof that starting another body is safe.
*/
export function agentPresenceStartBlockReason(
isLifecycleActive: boolean,
availability: PresenceStatus | undefined,
): string | undefined {
return !isLifecycleActive &&
(availability === "online" || availability === "away")
? "This agent is present on the relay. Starting another instance is unavailable."
: undefined;
}
/** Share the existing presence query/subscription; no separate status cache. */
export function useAgentAvailability(pubkey: string | null | undefined) {
const query = usePresenceQuery(pubkey ? [pubkey] : []);

View File

@@ -14,6 +14,7 @@ import {
import type { PresenceStatus } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Spinner } from "@/shared/ui/spinner";
import { agentPresenceStartBlockReason } from "../lib/useAgentAvailability";
import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar";
type AgentRuntimeAvatarControlProps = {
@@ -162,7 +163,14 @@ export function AgentRuntimeAvatarControl({
const availabilityLabel = availability
? getPresenceLabel(availability)
: "Availability unknown";
const showStatusDot = isActive && !isRestartAction;
const startBlockReason = agentPresenceStartBlockReason(
isActive,
availability,
);
// A present identity need not be a process this supervisor owns. Replace
// Start (even a stale Restart/error badge) without inventing Stop authority.
const showStatusDot =
Boolean(startBlockReason) || (isActive && !isRestartAction);
const hasError = !isActive && !isPending && Boolean(errorLabel);
const errorActionLabel = `${label} has a runtime error. Open runtime details.`;
const transition = shouldReduceMotion ? { duration: 0 } : MASK_TRANSITION;
@@ -187,7 +195,7 @@ export function AgentRuntimeAvatarControl({
className="h-full w-full rounded-full"
data-testid={activeTestId}
role="img"
title={`${label}: ${availabilityLabel}`}
title={startBlockReason ?? `${label}: ${availabilityLabel}`}
/>
) : (
<button

View File

@@ -293,3 +293,89 @@ test("errored avatar affordance still opens the explicit pubkey on the runtime t
assert.deepEqual(opened, [{ pubkey: LIVE_PK, options: { tab: "runtime" } }]);
});
for (const kind of ["persona", "custom", "unknown"]) {
test(`${kind} stopped card uses exact-key presence without inventing lifecycle controls`, async () => {
installFailOpenIpc();
const { relayClient } = await import("../../../shared/api/relayClient.ts");
const originalConnection = relayClient.getConnectionState;
const originalSubscribe = relayClient.subscribeToConnectionState;
relayClient.getConnectionState = () => "connected";
relayClient.subscribeToConnectionState = () => () => {};
let snapshot = { [ARCHIVED_PK]: "online" };
ipcHandlers.set("get_presence", () => Promise.resolve(snapshot));
const starts = [];
const props = baseProps({
agents: [
agent({
personaId:
kind === "custom"
? null
: kind === "unknown"
? "missing"
: "persona-1",
}),
],
personas: kind === "persona" ? [persona()] : [],
onStartAgent: (key) => starts.push(key),
onRestartAgent: () => {
throw new Error("presence must not cause Restart");
},
});
try {
await act(async () => renderSection(props));
const client = clients.at(-1);
const refresh = async () => {
await act(async () => {
await client.invalidateQueries({ queryKey: ["presence"] });
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
};
await refresh();
// Different-key Online must not suppress this identity's ordinary Start.
fireEvent.click(screen.getByTestId(`agent-runtime-start-${LIVE_PK}`));
assert.deepEqual(starts, [LIVE_PK]);
starts.length = 0;
for (const status of ["online", "away", "offline", "online"]) {
snapshot = { [LIVE_PK]: status };
await refresh();
const start = screen.queryByTestId(`agent-runtime-start-${LIVE_PK}`);
if (status === "offline") {
assert.ok(start);
fireEvent.click(start);
assert.deepEqual(starts, [LIVE_PK]);
starts.length = 0;
} else {
assert.equal(
Boolean(start),
false,
"active exact-key presence must remove Start",
);
const dot = screen.getByTestId(`agent-runtime-active-${LIVE_PK}`);
assert.match(
dot.getAttribute("aria-label"),
new RegExp(status === "online" ? "Online$" : "Away$"),
);
fireEvent.click(dot);
fireEvent.keyDown(dot, { key: "Enter" });
fireEvent.keyDown(dot, { key: " " });
assert.deepEqual(starts, []);
assert.equal(
Boolean(screen.queryByRole("button", { name: /Stop/ })),
false,
);
}
}
// A successful omitted entry is the existing relay expiry/missing path.
snapshot = {};
await refresh();
fireEvent.click(screen.getByTestId(`agent-runtime-start-${LIVE_PK}`));
assert.deepEqual(starts, [LIVE_PK]);
} finally {
relayClient.getConnectionState = originalConnection;
relayClient.subscribeToConnectionState = originalSubscribe;
}
});
}

View File

@@ -14,6 +14,11 @@ import {
useStopManagedAgentMutation,
useDeleteManagedAgentMutation,
} from "@/features/agents/hooks";
import {
agentPresenceStartBlockReason,
resolveAgentAvailability,
} from "../lib/useAgentAvailability";
import { useRelayConnection } from "@/shared/api/useRelayConnection";
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { useChannelsQuery } from "@/features/channels/hooks";
import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness";
@@ -102,6 +107,19 @@ export function useManagedAgentActions() {
);
const managedPresenceQuery = usePresenceQuery(managedPubkeyList);
const relayConnection = useRelayConnection();
function assertStartNotBlockedByPresence(agent: ManagedAgent) {
const reason = agentPresenceStartBlockReason(
isManagedAgentActive(agent),
resolveAgentAvailability(
managedPresenceQuery.data?.[normalizePubkey(agent.pubkey)],
managedPresenceQuery.isSuccess,
relayConnection === "connected",
),
);
if (reason) throw new Error(reason);
}
const channelsByPubkey = React.useMemo(() => {
const map: Record<string, { id: string; name: string }[]> = {};
@@ -164,6 +182,7 @@ export function useManagedAgentActions() {
try {
const agent = managedAgents.find((c) => c.pubkey === pubkey);
if (!agent) return;
assertStartNotBlockedByPresence(agent);
await startManagedAgentWithRules({
agent,
startManagedAgent: startMutation.mutateAsync,
@@ -184,6 +203,7 @@ export function useManagedAgentActions() {
(candidate) => candidate.pubkey === pubkey,
);
if (!agent) return;
assertStartNotBlockedByPresence(agent);
await respawnManagedAgentWithRules({
agent,
startManagedAgent: startMutation.mutateAsync,

View File

@@ -1,3 +1,7 @@
import {
agentPresenceStartBlockReason,
useAgentAvailability,
} from "@/features/agents/lib/useAgentAvailability";
import {
Activity,
Ban,
@@ -347,6 +351,14 @@ function MemberActionsMenu({
const isBanned = moderationState?.banned ?? false;
const isTimedOut = moderationState?.timedOut ?? false;
const { status: availability } = useAgentAvailability(managedAgent?.pubkey);
const startBlockReason = managedAgent
? agentPresenceStartBlockReason(
pairAction ? pairAction === "stop" : isManagedAgentActive(managedAgent),
availability,
)
: undefined;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
@@ -376,8 +388,11 @@ function MemberActionsMenu({
{canViewActivity ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
data-testid={`sidebar-agent-action-${member.pubkey}`}
disabled={disabled}
onClick={() => onManagedAgentAction(managedAgent)}
disabled={disabled || Boolean(startBlockReason)}
title={startBlockReason}
onClick={() => {
if (!startBlockReason) onManagedAgentAction(managedAgent);
}}
>
{pairAction
? getPairActionIcon(pairAction)

View File

@@ -1,3 +1,10 @@
import {
agentPresenceStartBlockReason,
resolveAgentAvailability,
} from "@/features/agents/lib/useAgentAvailability";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useRelayConnection } from "@/shared/api/useRelayConnection";
import { normalizePubkey } from "@/shared/lib/pubkey";
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
@@ -56,6 +63,24 @@ export function useMembersSidebarActions({
relayUrl,
}: UseMembersSidebarActionsOptions) {
const queryClient = useQueryClient();
const presenceQuery = usePresenceQuery(
controllableManagedBots.map((agent) => agent.pubkey),
);
const connection = useRelayConnection();
function assertStartNotBlockedByPresence(
agent: ManagedAgent,
lifecycleActive: boolean,
) {
const reason = agentPresenceStartBlockReason(
lifecycleActive,
resolveAgentAvailability(
presenceQuery.data?.[normalizePubkey(agent.pubkey)],
presenceQuery.isSuccess,
connection === "connected",
),
);
if (reason) throw new Error(reason);
}
const removeMemberMutation = useRemoveChannelMemberMutation(channelId);
const startManagedAgentMutation = useStartManagedAgentMutation();
const stopManagedAgentMutation = useStopManagedAgentMutation();
@@ -155,6 +180,7 @@ export function useMembersSidebarActions({
// agent-wide deploy/!shutdown flow below.
if (agent.backend.type === "local" && relayUrl) {
const action = managedAgentPairAction(runtime);
assertStartNotBlockedByPresence(agent, action === "stop");
await runtimeActionMutation.mutateAsync({
action,
pubkey: agent.pubkey,
@@ -188,6 +214,7 @@ export function useMembersSidebarActions({
return;
}
assertStartNotBlockedByPresence(agent, false);
await startManagedAgentWithRules({
agent,
startManagedAgent: startManagedAgentMutation.mutateAsync,
@@ -205,6 +232,7 @@ export function useMembersSidebarActions({
async function handleRespawnAll() {
await runBulkAgentAction({
action: async (agent) => {
assertStartNotBlockedByPresence(agent, isManagedAgentActive(agent));
await respawnManagedAgentWithRules({
agent,
startManagedAgent: startManagedAgentMutation.mutateAsync,

View File

@@ -2,7 +2,11 @@ import * as React from "react";
import { ChevronDown, ChevronUp, Pencil } from "lucide-react";
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
import { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions";
import { agentPresenceStartBlockReason } from "@/features/agents/lib/useAgentAvailability";
import {
getManagedAgentPrimaryActionLabel,
isManagedAgentActive,
} from "@/features/agents/lib/managedAgentControlActions";
import { RestartDiffBadge } from "@/features/agents/ui/RestartDiffBadge";
import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel";
import type { IdentityArchiveActions } from "@/features/identity-archive/hooks";
@@ -417,6 +421,14 @@ export function ProfileSummaryView({
concealed={primaryActionsConcealed}
followMutation={followMutation}
agentActionDisabled={isAgentActionPending}
agentStartBlockReason={
managedAgent
? agentPresenceStartBlockReason(
isManagedAgentActive(managedAgent),
presenceStatus,
)
: undefined
}
agentActionLabel={
isOwner === true && managedAgent
? getManagedAgentPrimaryActionLabel(managedAgent)

View File

@@ -25,6 +25,7 @@ import { Spinner } from "@/shared/ui/spinner";
export function ProfilePrimaryActions({
actionGroupRef,
agentActionDisabled,
agentStartBlockReason,
agentActionLabel,
agentActionLive,
className,
@@ -44,6 +45,7 @@ export function ProfilePrimaryActions({
}: {
actionGroupRef?: Ref<HTMLDivElement>;
agentActionDisabled?: boolean;
agentStartBlockReason?: string;
agentActionLabel?: string;
agentActionLive?: boolean;
className?: string;
@@ -94,7 +96,8 @@ export function ProfilePrimaryActions({
{onAgentPrimaryAction && agentActionLabel ? (
<ProfileActionTile
active
disabled={agentActionDisabled}
disabled={agentActionDisabled || Boolean(agentStartBlockReason)}
title={agentStartBlockReason}
icon={agentActionLive ? Square : Play}
label={agentActionLabel}
onClick={onAgentPrimaryAction}
@@ -216,6 +219,7 @@ function ProfileActionTile({
label,
onClick,
testId,
title,
}: {
active?: boolean;
disabled?: boolean;
@@ -224,6 +228,7 @@ function ProfileActionTile({
label: string;
onClick: () => void;
testId?: string;
title?: string;
}) {
return (
<button
@@ -234,6 +239,7 @@ function ProfileActionTile({
active && "bg-foreground text-background hover:bg-foreground/90",
)}
data-testid={testId}
title={title}
disabled={disabled}
onClick={onClick}
type="button"

View File

@@ -7,6 +7,10 @@ import {
startManagedAgentWithRules,
stopManagedAgentWithRules,
} from "@/features/agents/lib/managedAgentControlActions";
import {
agentPresenceStartBlockReason,
useAgentAvailability,
} from "@/features/agents/lib/useAgentAvailability";
import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks";
import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types";
@@ -23,6 +27,7 @@ export function useAgentLifecycleActions({
startManagedAgent: (pubkey: string) => Promise<unknown>;
stopManagedAgent: (pubkey: string) => Promise<unknown>;
}) {
const { status: availability } = useAgentAvailability(managedAgent?.pubkey);
const handleAgentPrimaryAction = React.useCallback(async () => {
if (!managedAgent) return;
@@ -41,6 +46,8 @@ export function useAgentLifecycleActions({
return;
}
const blockReason = agentPresenceStartBlockReason(false, availability);
if (blockReason) throw new Error(blockReason);
await startManagedAgentWithRules({
agent: managedAgent,
startManagedAgent,
@@ -56,6 +63,7 @@ export function useAgentLifecycleActions({
);
}
}, [
availability,
channels,
managedAgent,
relayAgents,
@@ -67,6 +75,11 @@ export function useAgentLifecycleActions({
if (!managedAgent) return;
try {
const blockReason = agentPresenceStartBlockReason(
isManagedAgentActive(managedAgent),
availability,
);
if (blockReason) throw new Error(blockReason);
await respawnManagedAgentWithRules({
agent: managedAgent,
startManagedAgent,
@@ -79,7 +92,7 @@ export function useAgentLifecycleActions({
error instanceof Error ? error.message : "Agent restart failed.",
);
}
}, [managedAgent, startManagedAgent, stopManagedAgent]);
}, [availability, managedAgent, startManagedAgent, stopManagedAgent]);
return { handleAgentPrimaryAction, handleAgentRestart };
}

View File

@@ -189,3 +189,162 @@ test("missing snapshot is offline but failed reads cannot reuse cached online",
).toHaveAttribute("aria-label", "Stop");
}
});
test("stopped local with authored presence has a dot, not an invokable Start", async ({
page,
}, testInfo) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: LOCAL,
name: "Present local",
status: "stopped",
channelNames: ["agents"],
},
],
});
await page.goto("/#/agents");
await expect
.poll(() =>
page.evaluate(() =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "agents",
kind: 20001,
}),
),
)
.toBe(true);
await page.evaluate(
(pubkey) =>
window.__BUZZ_E2E_EMIT_MOCK_PRESENCE__?.({ pubkey, status: "online" }),
LOCAL,
);
await expect(
page.getByTestId(`agent-runtime-active-${LOCAL}`),
).toHaveAttribute("aria-label", "Present local: Online");
await expect(page.getByTestId(`agent-runtime-start-${LOCAL}`)).toHaveCount(0);
await page
.getByRole("button", { name: "Present local agent profile" })
.press("Enter");
const action = page.getByTestId("user-profile-agent-primary-action");
await expect(action).toHaveAttribute("aria-label", "Start agent");
await expect(action).toBeDisabled();
await action.evaluate((button: HTMLButtonElement) => button.click());
await action.press("Enter");
await action.press("Space");
expect(
await page.evaluate(() =>
(window.__BUZZ_E2E_COMMANDS__ ?? []).filter((command) =>
["start_managed_agent", "stop_managed_agent"].includes(command),
),
),
).toEqual([]);
await expect(page.getByTestId("user-profile-agent-restart")).toHaveCount(0);
const badge = page.getByTestId("user-profile-presence-badge");
await expect(badge).toHaveAttribute("aria-label", "Online");
await waitForAnimations(page);
await page
.getByTestId(`managed-agent-${LOCAL}`)
.screenshot({ path: testInfo.outputPath("stopped-online-card.png") });
await page
.getByTestId("user-profile-panel")
.screenshot({ path: testInfo.outputPath("stopped-online-profile.png") });
for (const status of ["away", "offline"] as const) {
await page.evaluate(
({ pubkey, status }) =>
window.__BUZZ_E2E_EMIT_MOCK_PRESENCE__?.({ pubkey, status }),
{ pubkey: LOCAL, status },
);
await expect(badge).toHaveAttribute(
"aria-label",
status === "away" ? "Away" : "Offline",
);
if (status === "away") {
await expect(
page.getByTestId(`agent-runtime-active-${LOCAL}`),
).toHaveAttribute("aria-label", "Present local: Away");
await expect(action).toBeDisabled();
} else {
await expect(action).toBeEnabled();
await expect(
page.getByTestId(`agent-runtime-start-${LOCAL}`),
).toBeVisible();
}
}
// Keyboard activation is restored for the ordinary stopped/offline control.
// Runtime startup alone must still not manufacture authored availability.
await action.press("Enter");
await expect(action).toHaveAttribute("aria-label", "Stop");
await expect(badge).toHaveAttribute("aria-label", "Offline");
await expect(
page.getByTestId(`agent-runtime-active-${LOCAL}`),
).toHaveAttribute("aria-label", "Present local: Offline");
});
test("member menu cannot start a present stopped local runtime", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: LOCAL,
name: "Present member",
status: "stopped",
channelNames: ["agents"],
},
],
});
await page.goto("/");
await page.getByTestId("channel-agents").click();
await page.getByTestId("channel-members-trigger").click();
const row = page.getByTestId(`sidebar-member-${LOCAL}`);
await expect(row).toBeVisible();
await expect
.poll(() =>
page.evaluate(() =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "agents",
kind: 20001,
}),
),
)
.toBe(true);
await page.evaluate(
(pubkey) =>
window.__BUZZ_E2E_EMIT_MOCK_PRESENCE__?.({ pubkey, status: "away" }),
LOCAL,
);
await row.hover();
const menu = page.getByTestId(`sidebar-member-menu-${LOCAL}`);
await menu.focus();
await menu.press("Enter");
const action = page.getByTestId(`sidebar-agent-action-${LOCAL}`);
await expect(action).toContainText("Start");
await expect(action).toHaveAttribute("aria-disabled", "true");
await action.press("Enter");
await action.evaluate((node: HTMLElement) => node.click());
expect(
await page.evaluate(() =>
(window.__BUZZ_E2E_COMMANDS__ ?? []).filter((command) =>
/^(start|stop)_managed_agent/.test(command),
),
),
).toEqual([]);
await page.evaluate(
(pubkey) =>
window.__BUZZ_E2E_EMIT_MOCK_PRESENCE__?.({ pubkey, status: "offline" }),
LOCAL,
);
await expect(action).not.toHaveAttribute("aria-disabled", "true");
await action.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMANDS__ ?? []).filter(
(command) => command === "start_managed_agent_runtime",
).length,
),
)
.toBe(1);
});

View File

@@ -12,12 +12,31 @@ remain separate: a deployed provider agent still offers Shutdown while offline.
Shutdown sends a request, not a confirmed termination, and absence of presence
is not permission to deploy a duplicate body. Local Stop/Start routing is unchanged.
A locally stopped record with current exact-key Online or Away presence does
not establish local process ownership. Its card replaces Start (including a
stale restart/error badge) with the presence dot; its profile and member menu
retain the lifecycle-derived Start label but disable activation with an
explanation. No Stop is invented. Local/pair-owned running controls keep their
existing Stop/Restart routing, even when presence is Offline. The same positive
presence guard covers list/profile callbacks and member bulk respawn so the
visible affordance is not the only fence. This is a UI startup guard, **not** a
distributed singleton lock: missing, expired, failed or disconnected presence
cannot certify that starting another body is safe. Existing relay query/live
subscription freshness remains authoritative; no separate heartbeat or cache
is introduced. Runtime details remain accessible through the card/profile.
## Regression evidence
- `desktop/src/features/agents/lib/useAgentAvailability.test.mjs`: successful,
missing, unavailable, and disconnected presence; lifecycle routing and badges.
- `desktop/tests/e2e/agent-availability.spec.ts`: deployed/offline profile and
card, live presence and disconnected-state behavior.
card, stopped/Online and Away presence, disabled keyboard/click Start in
profile and member menu, restored Offline startup, running-but-Offline,
live updates and disconnected-state behavior.
- `desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs`:
mounted persona/custom/unknown cards, exact-key versus other-key presence,
Online/Away/offline/missing transitions and actual Start callback activation.
- `desktop/tests/e2e/agents.spec.ts`: Start badge animation and avatar continuity,
runtime-only negative control, then authored Online presence.
- `desktop/tests/e2e/profile.spec.ts`: created-agent initial Online snapshot and