fix(desktop): preserve availability freshness during agent deletion

Use the shared exact-key action-time reader for Agents and profile deletion, including channel-discovery awaits and unqueried persona siblings. Request shutdown for unknown availability before removing provider records; retain records on request failure and preserve established Offline and native local deletion behavior.

Cover the production hook/IPC seams and profile confirmation journeys with cached Online, Offline and failed/disconnected evidence. Document request-only shutdown and existing cleanup limits.

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-09-02 13:36:07 -04:00
parent 2e3f1a2615
commit 324f7b95c0
11 changed files with 732 additions and 43 deletions

View File

@@ -210,7 +210,11 @@ with a TypeScript lookup table or an id comparison in a component.
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
startup. Deletion also consumes that same exact-key availability reader:
unknown requests shutdown when a channel exists, request failure retains the
record, and only established Offline keeps the intentional no-request path.
Unqueried persona siblings are unknown. No presence state grants deletion or
Stop authority; native local stop-before-remove remains independent. 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

@@ -1,10 +1,6 @@
import { sendChannelMessage } from "@/shared/api/tauri";
import type {
Channel,
ManagedAgent,
PresenceLookup,
RelayAgent,
} from "@/shared/api/types";
import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types";
import type { AgentAvailabilityReader } from "./useAgentAvailability";
import { normalizePubkey } from "@/shared/lib/pubkey";
type DeleteManagedAgentInput = {
@@ -23,7 +19,7 @@ type ManagedAgentChannelContext = {
};
type ManagedAgentActionContext = ManagedAgentChannelContext & {
presenceLookup?: PresenceLookup | null;
getAvailability: AgentAvailabilityReader;
};
export type ManagedAgentActionResult = {
@@ -148,7 +144,7 @@ export async function deleteManagedAgentWithRules({
channels,
deleteManagedAgent,
preferredChannelId,
presenceLookup,
getAvailability,
relayAgents,
skipRemoteDeleteConfirm = false,
}: {
@@ -157,7 +153,7 @@ export async function deleteManagedAgentWithRules({
skipRemoteDeleteConfirm?: boolean;
} & ManagedAgentActionContext): Promise<ManagedAgentActionResult> {
if (agent.backend.type === "provider" && agent.backendAgentId) {
const presence = presenceLookup?.[normalizePubkey(agent.pubkey)];
const availability = getAvailability(agent.pubkey);
const channelId = resolveManagedAgentChannelId(agent, {
channels,
preferredChannelId,
@@ -165,14 +161,19 @@ export async function deleteManagedAgentWithRules({
});
if (channelId) {
if (presence === "online" || presence === "away") {
// Only established Offline preserves the intentional no-request path.
// Unknown is not evidence that shutdown can safely be skipped.
if (availability !== "offline") {
await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [
agent.pubkey,
]);
if (!skipRemoteDeleteConfirm) {
const confirmed = window.confirm(
"Shutdown command sent, but the agent may still be running. " +
(availability === undefined
? "This agent’s availability is unknown. "
: "") +
"Shutdown requested, but the agent may still be running. " +
"Deleting now removes the local record — the remote deployment " +
"will be orphaned if shutdown hasn't completed. Continue?",
);
@@ -195,7 +196,7 @@ export async function deleteManagedAgentWithRules({
if (!skipRemoteDeleteConfirm) {
const confirmed = window.confirm(
"This agent is deployed but not in any channel. " +
"Deleting will orphan the remote deployment (it will keep running). Continue?",
"Deleting removes the local management record; the remote deployment may still be running. Continue?",
);
if (!confirmed) {
return { cancelled: true };

View File

@@ -0,0 +1,469 @@
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
const PK = "a".repeat(64);
const SIBLING = "b".repeat(64);
const agent = {
pubkey: PK,
name: "Remote",
personaId: "persona",
status: "deployed",
backend: { type: "provider", id: "fixture", config: {} },
backendAgentId: "receipt",
};
const channel = { id: "channel", name: "agents", memberPubkeys: [PK] };
const directory = [
{ pubkey: PK, channels: ["agents"], channelIds: ["channel"] },
];
let act,
render,
cleanup,
waitFor,
createElement,
QueryClient,
QueryClientProvider;
let useAgentAvailabilityLookup,
useManagedAgentActions,
useProfileAgentDeletion,
CommunitiesProvider;
let deleteManagedAgentWithRules, deleteManagedAgent, relayClient, originals;
let connection, listeners, handlers, commands, confirms, clients;
before(async () => {
Object.assign(globalThis, {
window: dom.window,
localStorage: dom.window.localStorage,
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
});
dom.window.__TAURI_INTERNALS__ = {
invoke: async (command, args) => {
commands.push([command, args]);
if (handlers.has(command)) return handlers.get(command)(args);
throw new Error(`Unexpected IPC: ${command}`);
},
transformCallback: () => 1,
};
({ act, render, cleanup, waitFor } = await import("@testing-library/react"));
({ createElement } = await import("react"));
({ QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
));
({ CommunitiesProvider } = await import(
"../../communities/useCommunities.tsx"
));
({ useAgentAvailabilityLookup } = await import("./useAgentAvailability.ts"));
({ useManagedAgentActions } = await import(
"../ui/useManagedAgentActions.ts"
));
({ useProfileAgentDeletion } = await import(
"../../profile/ui/UserProfilePanelDeletion.ts"
));
({ deleteManagedAgentWithRules } = await import(
"./managedAgentControlActions.ts"
));
({ deleteManagedAgent } = await import("../../../shared/api/tauri.ts"));
({ relayClient } = await import("../../../shared/api/relayClient.ts"));
originals = {
getConnectionState: relayClient.getConnectionState,
subscribeToConnectionState: relayClient.subscribeToConnectionState,
};
relayClient.getConnectionState = () => connection;
relayClient.subscribeToConnectionState = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
});
afterEach(() => {
cleanup();
for (const client of clients ?? []) {
client.cancelQueries();
client.clear();
}
});
after(() => {
Object.assign(relayClient, originals);
dom.window.close();
});
function setup() {
clients = [];
commands = [];
confirms = [];
connection = "connected";
listeners = new Set();
handlers = new Map([
["get_presence", () => ({ [PK]: "online" })],
["delete_managed_agent", () => null],
["remove_channel_member", () => null],
["send_channel_message", () => ({ event_id: "event", created_at: 0 })],
["list_managed_agents", () => []],
["get_relay_agents", () => []],
["list_available_acp_runtimes", () => []],
["get_channels", () => []],
["plugin:event|listen", () => 1],
["plugin:event|unlisten", () => null],
]);
dom.window.confirm = (copy) => {
confirms.push(copy);
return true;
};
}
function mount(
owner,
{ agents = [agent], keys = [PK], seedChannels = true } = {},
) {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: Infinity },
mutations: { retry: false, gcTime: 0 },
},
});
clients.push(client);
client.setQueryData(["managed-agents"], agents);
client.setQueryData(["relay-agents"], directory);
if (seedChannels) client.setQueryData(["channels"], [channel]);
client.setQueryData(["globalAgentConfig"], { env_vars: {} });
let current;
function AgentsSurface() {
current = useManagedAgentActions();
return null;
}
function ProfileSurface() {
const availability = useAgentAvailabilityLookup(keys);
const deletion = useProfileAgentDeletion({
channels: [channel],
managedAgents: agents,
managedAgent: agents[0],
relayAgents: agents.map((row) => ({
...directory[0],
pubkey: row.pubkey,
})),
getAvailability: availability.getAvailability,
deleteManagedAgent: ({ pubkey, forceRemoteDelete }) =>
deleteManagedAgent(pubkey, forceRemoteDelete),
});
current = { ...availability, ...deletion };
return null;
}
const Surface = owner === "agents" ? AgentsSurface : ProfileSurface;
render(
createElement(
QueryClientProvider,
{ client },
createElement(CommunitiesProvider, null, createElement(Surface)),
),
);
return { client, current: () => current };
}
function effects() {
return commands.filter(([name]) =>
[
"send_channel_message",
"delete_managed_agent",
"remove_channel_member",
].includes(name),
);
}
for (const owner of ["agents", "profile"]) {
for (const scenario of [
"online",
"away",
"offline",
"missing",
"pending",
"failed-online",
"failed-offline",
"disconnected-online",
"disconnected-offline",
]) {
test(`${owner} deletion uses resolved ${scenario} at the production hook/IPC boundary`, async () => {
setup();
const warm = scenario.endsWith("-offline") ? "offline" : "online";
handlers.set("get_presence", () => {
if (scenario === "pending") return new Promise(() => {});
if (scenario === "missing") return {};
return { [PK]: scenario.includes("-") ? warm : scenario };
});
const surface = mount(owner);
const key = ["presence", PK];
if (scenario !== "pending") {
await waitFor(() =>
assert.equal(surface.client.getQueryState(key)?.status, "success"),
);
}
if (scenario.startsWith("failed")) {
handlers.set("get_presence", () =>
Promise.reject("relay unreachable: request timed out"),
);
await act(() =>
surface.client.invalidateQueries({ queryKey: key, exact: true }),
);
assert.equal(surface.client.getQueryState(key).status, "error");
assert.deepEqual(surface.client.getQueryData(key), { [PK]: warm });
}
if (scenario.startsWith("disconnected")) {
await act(async () => {
connection = "disconnected";
for (const listener of listeners) listener(connection);
});
assert.deepEqual(surface.client.getQueryData(key), { [PK]: warm });
}
const unknown = scenario.includes("-") || scenario === "pending";
await waitFor(() =>
assert.equal(
surface.current().getAvailability(PK),
unknown ? undefined : scenario === "missing" ? "offline" : scenario,
),
);
commands.length = 0;
await act(async () => {
if (owner === "agents") await surface.current().handleDelete(PK);
else await surface.current().deleteManagedAgentRecord(agent);
});
const shouldShutdown = scenario !== "offline" && scenario !== "missing";
assert.deepEqual(
effects().map(([name]) => name),
[
...(shouldShutdown ? ["send_channel_message"] : []),
"delete_managed_agent",
"remove_channel_member",
],
);
if (shouldShutdown) {
assert.equal(effects()[0][1].content, "!shutdown");
assert.deepEqual(effects()[0][1].mentionPubkeys, [PK]);
}
assert.deepEqual(
effects().find(([name]) => name === "delete_managed_agent")[1],
{
pubkey: PK,
forceRemoteDelete: true,
},
);
if (owner === "agents") {
assert.equal(confirms.length, 1);
if (unknown) {
assert.match(confirms[0], /availability is unknown/);
assert.doesNotMatch(confirms[0], /offline/i);
} else if (!shouldShutdown) assert.match(confirms[0], /is offline/);
} else
assert.deepEqual(confirms, [], "profile already obtained confirmation");
});
}
}
test("reader retained across an await sees errors/disconnect, not cached success; unqueried siblings stay unknown", async () => {
setup();
const surface = mount("profile");
await waitFor(() =>
assert.equal(surface.current().getAvailability(PK), "online"),
);
const retainedReader = surface.current().getAvailability;
assert.equal(retainedReader(SIBLING), undefined);
handlers.set("get_presence", () => Promise.reject("failed"));
await act(() =>
surface.client.invalidateQueries({ queryKey: ["presence", PK] }),
);
assert.equal(retainedReader(PK), undefined);
await act(async () =>
surface.client.setQueryData(["presence", PK], { [PK]: "online" }),
);
connection = "reconnecting"; // even before the next React connection render
assert.equal(retainedReader(PK), undefined);
});
for (const owner of ["agents", "profile"]) {
test(`${owner} unknown shutdown failure preserves record and channel membership`, async () => {
setup();
handlers.set("get_presence", () => Promise.reject("failed"));
handlers.set("send_channel_message", () =>
Promise.reject(new Error("shutdown refused")),
);
const surface = mount(owner);
await waitFor(() =>
assert.equal(
surface.client.getQueryState(["presence", PK])?.status,
"error",
),
);
await act(async () => {
if (owner === "agents") await surface.current().handleDelete(PK);
else
await assert.rejects(
surface.current().deleteManagedAgentRecord(agent),
/shutdown refused/,
);
});
assert.deepEqual(
effects().map(([name]) => name),
["send_channel_message"],
);
assert.deepEqual(confirms, []);
if (owner === "agents")
assert.equal(surface.current().actionErrorMessage, "shutdown refused");
});
}
test("unknown waits for shutdown before confirmation/delete; cancellation retains record", async () => {
setup();
let release;
handlers.set(
"send_channel_message",
() =>
new Promise((resolve) => {
release = resolve;
}),
);
dom.window.confirm = (copy) => {
confirms.push(copy);
return false;
};
const operation = deleteManagedAgentWithRules({
agent,
channels: [channel],
relayAgents: directory,
getAvailability: () => undefined,
deleteManagedAgent: ({ pubkey, forceRemoteDelete }) =>
deleteManagedAgent(pubkey, forceRemoteDelete),
});
await waitFor(() => assert.equal(typeof release, "function"));
assert.deepEqual(confirms, []);
assert.equal(effects().length, 1);
release({ event_id: "event" });
assert.deepEqual(await operation, { cancelled: true });
assert.match(confirms[0], /availability is unknown/);
assert.equal(effects().length, 1);
});
test("no channel warns without claiming process state; local deletion ignores presence", async () => {
setup();
const remove = ({ pubkey, forceRemoteDelete }) =>
deleteManagedAgent(pubkey, forceRemoteDelete);
await deleteManagedAgentWithRules({
agent,
channels: [],
relayAgents: [],
getAvailability: () => undefined,
deleteManagedAgent: remove,
});
assert.match(confirms[0], /may still be running/);
assert.doesNotMatch(confirms[0], /will keep running|offline/i);
assert.deepEqual(
effects().map(([name]) => name),
["delete_managed_agent"],
);
commands.length = 0;
confirms.length = 0;
await deleteManagedAgentWithRules({
agent: { ...agent, backend: { type: "local" } },
channels: [],
relayAgents: [],
getAvailability: () => {
throw new Error("must not consult presence");
},
deleteManagedAgent: remove,
});
assert.deepEqual(effects(), [
["delete_managed_agent", { pubkey: PK, forceRemoteDelete: null }],
]);
assert.deepEqual(confirms, []);
});
test("Agents deletion rechecks availability after channel discovery, not the click-time snapshot", async () => {
setup();
let releaseChannels;
handlers.set(
"get_channels",
() =>
new Promise((resolve) => {
releaseChannels = resolve;
}),
);
handlers.set("get_presence", () => ({ [PK]: "offline" }));
const surface = mount("agents", { seedChannels: false });
await waitFor(() =>
assert.equal(surface.current().getAvailability(PK), "offline"),
);
let operation;
await act(async () => {
operation = surface.current().handleDelete(PK);
});
await waitFor(() => assert.equal(typeof releaseChannels, "function"));
handlers.set("get_presence", () => Promise.reject("failed"));
await act(() =>
surface.client.invalidateQueries({ queryKey: ["presence", PK] }),
);
assert.deepEqual(effects(), []);
await act(async () => {
releaseChannels({ hash: "empty", channels: [], last_messages: {} });
await operation;
});
assert.deepEqual(
effects().map(([name]) => name),
["send_channel_message", "delete_managed_agent", "remove_channel_member"],
);
assert.match(confirms[0], /availability is unknown/);
});
test("profile persona deletion cannot infer Offline for an unqueried sibling", async () => {
setup();
handlers.set("get_presence", () => ({}));
const surface = mount("profile", {
agents: [agent, { ...agent, pubkey: SIBLING }],
keys: [PK],
});
await waitFor(() =>
assert.equal(surface.current().getAvailability(PK), "offline"),
);
assert.equal(surface.current().getAvailability(SIBLING), undefined);
await act(() =>
surface.current().deleteManagedAgentsForPersona({ id: "persona" }),
);
const requests = effects().filter(
([name]) => name === "send_channel_message",
);
assert.equal(requests.length, 1);
assert.deepEqual(requests[0][1].mentionPubkeys, [SIBLING]);
assert.match(confirms[0], /is offline/);
assert.match(confirms[1], /availability is unknown/);
});
test("successful cached snapshot remains authoritative during refetch; only settled error revokes it", async () => {
setup();
const surface = mount("profile");
await waitFor(() =>
assert.equal(surface.current().getAvailability(PK), "online"),
);
let rejectRead;
handlers.set(
"get_presence",
() =>
new Promise((_, reject) => {
rejectRead = reject;
}),
);
let refresh;
await act(async () => {
refresh = surface.client.invalidateQueries({ queryKey: ["presence", PK] });
});
assert.equal(surface.current().getAvailability(PK), "online");
await act(async () => {
rejectRead("failed");
await refresh;
});
assert.equal(surface.current().getAvailability(PK), undefined);
});

View File

@@ -1,6 +1,8 @@
import * as React from "react";
import { usePresenceQuery } from "@/features/presence/hooks";
import type { PresenceStatus } from "@/shared/api/types";
import { useQueryClient } from "@tanstack/react-query";
import { presenceQueryKey, usePresenceQuery } from "@/features/presence/hooks";
import { relayClient } from "@/shared/api/relayClient";
import type { PresenceLookup, PresenceStatus } from "@/shared/api/types";
import { useRelayConnection } from "@/shared/api/useRelayConnection";
import { normalizePubkey } from "@/shared/lib/pubkey";
@@ -39,18 +41,29 @@ export function useAgentAvailabilityLookup(
options?: { enabled?: boolean },
) {
const query = usePresenceQuery(pubkeys, options);
const connection = useRelayConnection();
const getAvailability: AgentAvailabilityReader = React.useCallback(
(pubkey) =>
pubkey
? resolveAgentAvailability(
query.data?.[normalizePubkey(pubkey)],
query.isSuccess,
connection === "connected",
)
: undefined,
[query.data, query.isSuccess, connection],
);
const queryClient = useQueryClient();
// Subscribe for display updates; action-time reads below must also see a
// disconnect/error that occurred while an action awaited channel discovery.
const connection = useRelayConnection({ degradedAfterMs: 0 });
const keyId = JSON.stringify(presenceQueryKey(pubkeys));
// These dependencies subscribe the render to data/status changes, while the
// callback reads the cache at invocation time (including after an await).
// biome-ignore lint/correctness/useExhaustiveDependencies: Track observer changes and invalidate memoized consumers; invocation must read live state, not capture those values.
const getAvailability: AgentAvailabilityReader = React.useMemo(() => {
const key: string[] = JSON.parse(keyId);
const requested = new Set(key.slice(1));
return (pubkey) => {
const normalized = pubkey ? normalizePubkey(pubkey) : "";
// A successful subset says nothing about unqueried persona siblings.
if (!normalized || !requested.has(normalized)) return undefined;
const state = queryClient.getQueryState<PresenceLookup>(key);
return resolveAgentAvailability(
state?.data?.[normalized],
state?.status === "success",
relayClient.getConnectionState() === "connected",
);
};
}, [keyId, queryClient, query.data, query.isSuccess, connection]);
return { query, getAvailability };
}

View File

@@ -328,7 +328,7 @@ export function useManagedAgentActions() {
agent,
channels,
deleteManagedAgent: deleteMutation.mutateAsync,
presenceLookup: managedPresenceQuery.data,
getAvailability,
relayAgents: relayAgentsQuery.data ?? [],
});
if (result.cancelled) return;

View File

@@ -45,7 +45,8 @@ function normalizePubkeys(pubkeys: string[]) {
.sort();
}
function presenceQueryKey(pubkeys: string[]) {
/** Canonical normalized key shared by observers and action-time readers. */
export function presenceQueryKey(pubkeys: string[]) {
return ["presence", ...normalizePubkeys(pubkeys)] as const;
}

View File

@@ -264,7 +264,9 @@ function AgentDeleteConfirmDialog({
<AlertDialogHeader>
<AlertDialogTitle>Delete this agent?</AlertDialogTitle>
<AlertDialogDescription>
Deleting this agent stops and removes the agent from this community.
{isProviderAgent
? "Deleting removes this agent’s local management record, not its remote deployment."
: "Deleting this agent stops and removes the agent from this community."}
</AlertDialogDescription>
</AlertDialogHeader>
<ul className="list-disc space-y-1.5 pl-5 text-sm text-muted-foreground">
@@ -276,7 +278,7 @@ function AgentDeleteConfirmDialog({
</li>
<li>
{isProviderAgent
? "Requests remote deletion; if it is online, Buzz first sends a shutdown command when possible. If the deployment cannot be reached through a channel, the remote process may keep running without local management."
? "Unless the agent is known to be Offline, Buzz first requests shutdown through a channel when available. A failed request cancels deletion. The remote process may still be running even after a successful request."
: "Stops any local agent process before deleting the record"}
</li>
</ul>

View File

@@ -38,7 +38,7 @@ import {
} from "@/features/agents/ui/personaDialogState";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useIdentityArchive } from "@/features/identity-archive/hooks";
import { useAgentAvailability } from "@/features/agents/lib/useAgentAvailability";
import { useAgentAvailabilityLookup } from "@/features/agents/lib/useAgentAvailability";
import {
useContactListQuery,
useFollowMutation,
@@ -251,8 +251,10 @@ export function UserProfilePanel({
effectivePubkey ? [effectivePubkey] : [],
);
const channelsQuery = useChannelsQuery();
const { query: presenceQuery, status: presenceStatus } =
useAgentAvailability(effectivePubkey);
const { query: presenceQuery, getAvailability } = useAgentAvailabilityLookup(
effectivePubkey ? [effectivePubkey] : [],
);
const presenceStatus = getAvailability(effectivePubkey);
const userStatusQuery = useUserStatusQuery(
effectivePubkey ? [effectivePubkey] : [],
);
@@ -411,7 +413,7 @@ export function UserProfilePanel({
deleteManagedAgent: deleteAgentMutation.mutateAsync,
managedAgent,
managedAgents: managedAgentsQuery.data,
presenceLookup: presenceQuery.data,
getAvailability,
relayAgents: relayAgentsQuery.data,
});

View File

@@ -11,7 +11,6 @@ import type {
AgentPersona,
Channel,
ManagedAgent,
PresenceLookup,
RelayAgent,
} from "@/shared/api/types";
import { getRelayAgentChannelIds } from "@/features/profile/ui/UserProfilePanelUtils";
@@ -36,7 +35,7 @@ type UseProfileAgentDeletionInput = {
deleteManagedAgent: DeleteManagedAgentRulesContext["deleteManagedAgent"];
managedAgent?: ManagedAgent;
managedAgents?: readonly ManagedAgent[];
presenceLookup?: PresenceLookup | null;
getAvailability: DeleteManagedAgentRulesContext["getAvailability"];
relayAgents?: readonly RelayAgent[];
};
@@ -45,7 +44,7 @@ export function useProfileAgentDeletion({
deleteManagedAgent,
managedAgent,
managedAgents,
presenceLookup,
getAvailability,
relayAgents,
}: UseProfileAgentDeletionInput) {
const queryClient = useQueryClient();
@@ -83,7 +82,7 @@ export function useProfileAgentDeletion({
deleteProfileManagedAgent(agentToDelete, {
channels: channels ?? [],
deleteManagedAgent,
presenceLookup,
getAvailability,
relayAgents: relayAgents ?? [],
removeAgentFromAllChannels,
skipRemoteDeleteConfirm: true,
@@ -91,7 +90,7 @@ export function useProfileAgentDeletion({
[
channels,
deleteManagedAgent,
presenceLookup,
getAvailability,
relayAgents,
removeAgentFromAllChannels,
],
@@ -103,7 +102,7 @@ export function useProfileAgentDeletion({
channels: channels ?? [],
deleteManagedAgent,
managedAgents: managedAgents ?? [],
presenceLookup,
getAvailability,
relayAgents: relayAgents ?? [],
removeAgentFromAllChannels,
selectedAgent: managedAgent,
@@ -113,7 +112,7 @@ export function useProfileAgentDeletion({
deleteManagedAgent,
managedAgent,
managedAgents,
presenceLookup,
getAvailability,
relayAgents,
removeAgentFromAllChannels,
],

View File

@@ -592,3 +592,153 @@ test("hover profile preserves unknown availability and announces only establishe
await expectUnknown();
await expect(popover).toBeVisible();
});
for (const scenario of [
"failed-online",
"disconnected-online",
"failed-offline",
"offline",
] as const) {
test(`profile deletion respects ${scenario} and preserves the record on shutdown failure`, async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: LOCAL,
name: "Deletion fixture",
status: "deployed",
backend: { type: "provider", id: "fixture", config: {} },
channelNames: ["agents"],
},
],
});
await page.goto("/#/agents");
await expect(
page.getByRole("button", { name: "Deletion fixture agent profile" }),
).toBeVisible();
await page.evaluate(
async ({ pubkey, warm }) => {
const w = window as typeof window & {
__DELETE_FAIL_PRESENCE__?: boolean;
__DELETE_FAIL_SHUTDOWN__?: boolean;
__DELETE_EFFECTS__?: string[];
__TAURI_INTERNALS__: {
invoke: (
command: string,
payload: unknown,
options: unknown,
) => Promise<unknown>;
};
};
const original = w.__TAURI_INTERNALS__.invoke.bind(
w.__TAURI_INTERNALS__,
);
w.__DELETE_EFFECTS__ = [];
w.__DELETE_FAIL_SHUTDOWN__ = true;
w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => {
if (command === "list_managed_agents") {
const rows = (await original(command, payload, options)) as Array<{
pubkey: string;
backend_agent_id: string | null;
}>;
return rows.map((row) =>
row.pubkey === pubkey
? { ...row, backend_agent_id: "fixture-receipt" }
: row,
);
}
if (command === "get_presence") {
if (w.__DELETE_FAIL_PRESENCE__)
throw "relay unreachable: request timed out";
return { [pubkey]: warm };
}
if (
command === "send_channel_message" &&
(payload as { content?: string })?.content === "!shutdown"
) {
w.__DELETE_EFFECTS__?.push("shutdown");
if (w.__DELETE_FAIL_SHUTDOWN__) throw "shutdown refused";
}
if (command === "delete_managed_agent")
w.__DELETE_EFFECTS__?.push("delete");
if (command === "remove_channel_member")
w.__DELETE_EFFECTS__?.push("remove-member");
return original(command, payload, options);
};
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["managed-agents"],
});
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["presence", pubkey],
exact: true,
});
},
{
pubkey: LOCAL,
warm: scenario.endsWith("offline") ? "offline" : "online",
},
);
await page
.getByRole("button", { name: "Deletion fixture agent profile" })
.click();
await expect(
page.getByTestId("user-profile-presence-badge"),
).toHaveAttribute(
"aria-label",
scenario.endsWith("offline") ? "Offline" : "Online",
);
if (scenario.startsWith("failed")) {
await page.evaluate(async (pubkey) => {
(
window as typeof window & { __DELETE_FAIL_PRESENCE__?: boolean }
).__DELETE_FAIL_PRESENCE__ = true;
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["presence", pubkey],
exact: true,
});
}, LOCAL);
} else if (scenario.startsWith("disconnected")) {
await page.evaluate(() =>
window.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?.("disconnected"),
);
}
if (scenario !== "offline")
await expect(page.getByTestId("user-profile-presence-badge")).toHaveCount(
0,
);
await page.getByTestId("user-profile-delete-agent-row").click();
const dialog = page.getByTestId("agent-delete-confirm-dialog");
await expect(dialog).toContainText("not its remote deployment");
await expect(dialog).toContainText("A failed request cancels deletion");
await expect(dialog).not.toContainText("This agent is offline");
await page.getByTestId("agent-delete-confirm-action").click();
const effects = () =>
page.evaluate(
() =>
(window as typeof window & { __DELETE_EFFECTS__?: string[] })
.__DELETE_EFFECTS__ ?? [],
);
if (scenario === "offline") {
await expect.poll(effects).toEqual(["delete", "remove-member"]);
await expect(page.getByTestId("user-profile-panel")).toHaveCount(0);
} else {
await expect.poll(effects).toEqual(["shutdown"]);
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
// Retry the same user action without healing presence. A successful
// request permits the explicitly confirmed local deletion, not a claim
// of remote termination. No membership write precedes it.
await page.evaluate(() => {
(
window as typeof window & { __DELETE_FAIL_SHUTDOWN__?: boolean }
).__DELETE_FAIL_SHUTDOWN__ = false;
});
await page.getByTestId("user-profile-delete-agent-row").click();
await page.getByTestId("agent-delete-confirm-action").click();
await expect
.poll(effects)
.toEqual(["shutdown", "shutdown", "delete", "remove-member"]);
await expect(page.getByTestId("user-profile-panel")).toHaveCount(0);
}
});
}

View File

@@ -3,7 +3,9 @@
Availability means conversational presence on the relay, not process health.
A retained deployment receipt, PID, or `running` record cannot turn an agent's
availability dot green. A successful presence snapshot with no entry means
Offline. A pending/failed read or disconnected relay means unknown, including
Offline **only for an identity included in that snapshot’s requested keys**.
An unqueried identity is unknown, never implicitly Offline. An initial pending
read, failed read, or disconnected relay means unknown, including
when the cache previously contained Online. Online and Away require presence.
Agents cards and profiles share the existing presence query and live subscription;
@@ -34,6 +36,42 @@ subscription freshness remains authoritative; no separate heartbeat or cache
is introduced. Runtime details remain accessible through the card/profile.
## Lifecycle decision authority
Presence does not grant management authority. Backend type, deployment receipt,
local/pair ownership and the existing owner gates still decide which operations
are offered and where they route. All presence-dependent decisions use the same
surface-owned reader, not raw query data. The reader checks the canonical query
cache and current relay connection at invocation, including after asynchronous
channel discovery and between persona siblings. Display observers refresh on
query data/status and connection changes (without the warning-banner debounce).
A successful cached snapshot remains usable during an in-flight background
refetch under the existing query policy; a settled error revokes it even though
TanStack retains the old data. A live single-author update cannot heal a failed
aggregate. This is not a new freshness cache or a distributed lock.
For deletion of a provider agent with a deployment receipt:
| Availability / route | Before local record deletion |
| --- | --- |
| Online or Away, channel available | Await a shutdown request; warn it does not confirm termination. |
| Unknown, channel available | Also await shutdown; explicitly identify unknown availability in the action confirmation, never claim Offline. |
| Established Offline | Preserve intentional offline removal without a shutdown request; warn the remote deployment may still exist. |
| No channel | No shutdown route; warn the remote deployment may still run, without claiming it will. |
A shutdown request failure propagates and leaves the management record and
channel memberships intact for retry; it is not silently converted into force
removal. A successful request still requires the existing orphan confirmation
(or the profile’s already-accepted equivalent) before forced **local record**
deletion. The profile confirmation describes this policy without asserting the
current availability or promising remote deletion. Cancelling after a request
retains the record; it cannot unsend the request. Channel removal follows record
deletion. Local deletion does not consult presence and still delegates to the
native stop-before-remove boundary. Custom-persona native cascade continues to
refuse provider-deployed targets; built-in persona instance removal uses these
same per-instance rules, treating unqueried siblings as unknown.
## Regression evidence
- `desktop/src-tauri/src/commands/profile_presence_tests.rs`: actual command
@@ -72,3 +110,13 @@ availability use `__BUZZ_E2E_EMIT_MOCK_PRESENCE__` to seed the snapshot and emit
kind 20001 with the agent as author. Updating a directory row or posting a chat
message is not presence. Mock browser tests do not certify native relay TTL,
provider termination, or substrate health.
- `managedAgentDeletionAvailability.test.mjs`: mounted production Agents and
profile deletion hooks through safe mock IPC, including failed/disconnected
cached Online **and Offline**, pending, genuine missing/Offline, Online/Away,
channel-discovery suspension, unqueried persona siblings, successful in-flight
refetch, shutdown failure/ordering/cancel, no-channel and local delegation.
- `agent-availability.spec.ts`: real profile Delete dialog/action with failed or
disconnected cached Online and failed cached Offline. A rejected shutdown
retains the record; retry orders shutdown → deletion → membership removal.
Genuine Offline preserves no-request deletion. All destructive IPC is mocked.