mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
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:
@@ -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`
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ export function useManagedAgentActions() {
|
||||
agent,
|
||||
channels,
|
||||
deleteManagedAgent: deleteMutation.mutateAsync,
|
||||
presenceLookup: managedPresenceQuery.data,
|
||||
getAvailability,
|
||||
relayAgents: relayAgentsQuery.data ?? [],
|
||||
});
|
||||
if (result.cancelled) return;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user