feat(desktop): remove communities locally and stop their agent pairs (#7997)

🤖 Leave contacts the relay before it removes anything locally: it checks
the relay's `/info` for NIP-43 membership support, then waits for the
relay to accept a signed leave request. When a relay has been torn down,
that check fails, Leave throws, and the community stays in the rail.
Removing a community also left its relay's managed agent pairs running,
and they kept reconnecting to it until the app was quit.

**Remove from this device**
- A new community action, **Remove from this device**, sits under
**Leave community** in the profile menu's community actions. It never
contacts the relay. A confirm dialog explains that relay membership
isn't revoked.
- `useCommunityNavigationTransitions` splits the old `removeCommunity`
into `removeCommunityFromDevice` (local-only cleanup and navigation to a
fallback community or the discovery screen) and
`leaveAndRemoveCommunity` (calls `leaveCommunity`, then runs the same
cleanup). Leave's flow hasn't changed.
- When Leave fails, the error now adds: "If the community no longer
exists, use Remove from this device." The error shown when community
discovery state can't be saved no longer says "Membership was removed",
so its wording fits both actions.

**Agent pairs on a removed relay**
- Once the local removal completes, the shared cleanup stops every live
agent pair on that relay, including manually started ones. It matches
relays with `canonicalRelayUrl`. Stopping is best-effort: failures are
logged and the removal still completes. Pairs on other relays aren't
touched, and nothing is stopped if another community still uses the same
relay.
- Both reconcile call sites (`useManagedAgentRuntimeReconciliation` and
`bootstrapManagedAgentRuntimePairs`) now go through
`reconcileConfiguredManagedAgentRuntimes`. A reconcile that is already
running in Rust can't be cancelled, so after it resolves, this helper
stops any pair it started whose relay is no longer in the saved
community list.
- Launch restore runs in the background after `apply_workspace` and can
finish after its community is removed. `apply_workspace` takes a restore
ticket (the current `launch_restore_generation` in `AppState`) before
handing its lock to the restore task. Removing the active community
calls the new `invalidate_launch_restore` command, which bumps that
generation while holding the runtime transition lock.
`restore_managed_agents_on_launch` re-checks its ticket under the same
lock and spawns nothing if it changed. If restore takes the lock first,
its pairs are registered before invalidation returns, and the removal
stop then finds them. Removal never changes `relay_url_override`: the
outgoing relay stays applied until the next `apply_workspace`, exactly
as on `main`, so in-flight work never falls back to the default relay.
Invalidation never takes `workspace_apply_lock`, which restore holds
while waiting on the transition lock, so lock order is unchanged.
- With no relay applied, backend readers fall back to the default relay,
the same state as launch before the first `apply_workspace`. A normal
quit and relaunch doesn't bring a pair back on a removed relay: restore
targets the applied workspace, and the legacy per-record relay pin is
ignored.

**Tests**
- A `community-rail` e2e case makes the mocked relay's membership check
fail, the way an unreachable relay does. It checks that Leave shows the
error and keeps the community, and that Remove from this device then
removes it, switches to the fallback, stops the agent pair on the
removed relay, and leaves the pair on the other relay running.
- Unit tests in `managedAgentRelayCleanup.test.mjs` cover relay
matching, a stop or listing failure not throwing, and the reconcile
fence stopping a pair that started on a relay removed mid-flight.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-10-01 14:43:21 -04:00
committed by GitHub
parent fad4ff637a
commit 83aab8cb5c
58 changed files with 2489 additions and 279 deletions

View File

@@ -166,3 +166,38 @@ test("test_respawn_onStopped_fires_before_start_resolves", async () => {
"onStopped must fire after stop resolves and before start is called",
);
});
// --- respawn bound to the workspace relay -----------------------------------
const { markRelayRemoved } = await import("../managedAgentRelayCleanup.ts");
async function respawnAcrossStop(relayUrl, duringStop) {
const started = [];
const result = respawnManagedAgentWithRules({
agent: agent({ status: "running" }),
relayUrl,
stopManagedAgent: async () => duringStop(),
startManagedAgent: async (pubkey) => {
started.push(pubkey);
},
});
return { result, started };
}
test("a respawn whose own relay is removed and re-added during its stop starts nothing", async () => {
const { result, started } = await respawnAcrossStop(
"wss://respawn-own.example",
() => markRelayRemoved("wss://respawn-own.example"),
);
await assert.rejects(result, /relay was removed from this device/);
assert.deepEqual(started, []);
});
test("removing an unrelated relay during a respawn's stop still restarts it", async () => {
const { result, started } = await respawnAcrossStop(
"wss://respawn-kept.example",
() => markRelayRemoved("wss://respawn-unrelated.example"),
);
await result;
assert.equal(started.length, 1);
});

View File

@@ -1,3 +1,4 @@
import { captureRelayRemovals } from "@/features/agents/managedAgentRelayCleanup";
import { sendChannelMessage } from "@/shared/api/tauri";
import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types";
import type { AgentAvailabilityReader } from "./useAgentAvailability";
@@ -87,11 +88,15 @@ export async function startManagedAgentWithRules({
export async function respawnManagedAgentWithRules({
agent,
relayUrl,
startManagedAgent,
stopManagedAgent,
onStopped,
}: {
agent: ManagedAgent;
/** The active community's relay: the workspace pair the native stop and
* start target. A removal of it during the stop cancels the start. */
relayUrl: string | undefined;
startManagedAgent: StartManagedAgent;
stopManagedAgent: StopManagedAgent;
/** Called after a successful stop and before start begins — use this to
@@ -99,8 +104,12 @@ export async function respawnManagedAgentWithRules({
onStopped?: () => void;
}) {
if (agent.backend.type === "local" && isManagedAgentActive(agent)) {
const assertRelayNotRemoved = relayUrl
? captureRelayRemovals(relayUrl)
: () => {};
await stopManagedAgent(agent.pubkey);
onStopped?.();
assertRelayNotRemoved();
}
await startManagedAgent(agent.pubkey);

View File

@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import test from "node:test";
import { restartDriftedAgent } from "./useAutoRestartPolicy.ts";
import { markRelayRemoved } from "../managedAgentRelayCleanup.ts";
const PUBKEY = "cd".repeat(32);
// Auto-restart fires only for a running, drifted, opted-in agent.
async function autoRestartAcrossFetch(relayUrl, duringFetch) {
const calls = [];
const ops = {
listManagedAgents: async () => {
duringFetch();
return [
{
pubkey: PUBKEY,
needsRestart: true,
autoRestartOnConfigChange: true,
status: "running",
},
];
},
stopManagedAgent: async () => calls.push("stop"),
startManagedAgent: async () => calls.push("start"),
};
const result = restartDriftedAgent(PUBKEY, relayUrl, ops);
return { result, calls };
}
test("an auto-restart whose own relay is removed during its pre-fire fetch starts nothing", async () => {
const { result, calls } = await autoRestartAcrossFetch(
"wss://auto-own.example",
() => markRelayRemoved("wss://auto-own.example"),
);
await assert.rejects(result, /relay was removed from this device/);
assert.deepEqual(calls, ["stop"]);
});
test("removing an unrelated relay during the pre-fire fetch still restarts the agent", async () => {
const { result, calls } = await autoRestartAcrossFetch(
"wss://auto-kept.example",
() => markRelayRemoved("wss://auto-unrelated.example"),
);
await result;
assert.deepEqual(calls, ["stop", "start"]);
});

View File

@@ -5,6 +5,7 @@ import {
managedAgentsQueryKey,
useManagedAgentsQuery,
} from "@/features/agents/hooks";
import { captureRelayRemovals } from "@/features/agents/managedAgentRelayCleanup";
import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks";
import {
startManagedAgent,
@@ -21,6 +22,42 @@ import {
type AutoRestartEdgeState,
} from "./autoRestartPolicy";
const defaultRestartOps = {
listManagedAgents,
stopManagedAgent,
startManagedAgent,
};
/**
* One auto-restart attempt, bound to `relayUrl` — the active community's
* relay, which the workspace-pair stop and start target — before its first
* await. Only a removal of that relay cancels the start.
*/
export async function restartDriftedAgent(
pubkey: string,
relayUrl: string | undefined,
ops = defaultRestartOps,
): Promise<void> {
const assertRelayNotRemoved = relayUrl
? captureRelayRemovals(relayUrl)
: () => {};
// Pre-fire re-fetch: shrink the stale-decision window to ~0.
const fresh = await ops.listManagedAgents();
const current = fresh.find((a) => a.pubkey === pubkey);
if (
!current?.needsRestart ||
!current.autoRestartOnConfigChange ||
current.status !== "running" ||
getAgentWorkingState(pubkey).source !== "none"
) {
return;
}
await ops.stopManagedAgent(pubkey);
clearActiveTurnsForAgentOnStop(pubkey);
assertRelayNotRemoved();
await ops.startManagedAgent(pubkey);
}
/** How often the policy re-evaluates between summary refetches. Keeps the
* continuity clock honest without waiting for the next 5s poll. */
const POLICY_TICK_MS = 15_000;
@@ -33,9 +70,10 @@ const POLICY_TICK_MS = 15_000;
* tested). This hook only wires inputs, owns per-pubkey edge state, and
* calls the existing stop/start commands — both idempotent and serialized
* on the backend store lock, so a cross-window double-fire is benign (and
* further shrunk by the pre-fire summary re-fetch).
* further shrunk by the pre-fire summary re-fetch). `relayUrl` is the active
* community's relay, which the restarts target.
*/
export function useAutoRestartPolicy() {
export function useAutoRestartPolicy(relayUrl: string | undefined) {
const queryClient = useQueryClient();
const agents: ManagedAgent[] | undefined = useManagedAgentsQuery().data;
const edgesRef = React.useRef(new Map<string, AutoRestartEdgeState>());
@@ -101,32 +139,17 @@ export function useAutoRestartPolicy() {
// until needsRestart cycles (edge-triggered debounce, no retry loops).
edges.set(agent.pubkey, { consumed: true, armedAt: null });
void (async () => {
try {
// Pre-fire re-fetch: shrink the stale-decision window to ~0.
const fresh = await listManagedAgents();
const current = fresh.find((a) => a.pubkey === agent.pubkey);
if (
!current?.needsRestart ||
!current.autoRestartOnConfigChange ||
current.status !== "running" ||
getAgentWorkingState(agent.pubkey).source !== "none"
) {
return;
}
await stopManagedAgent(agent.pubkey);
clearActiveTurnsForAgentOnStop(agent.pubkey);
await startManagedAgent(agent.pubkey);
} catch {
void restartDriftedAgent(agent.pubkey, relayUrl)
.catch(() => {
// Failed attempt: edge stays consumed — badge-only until the
// needsRestart edge cycles. No retry loops by design.
} finally {
})
.finally(() => {
inFlightRef.current.delete(agent.pubkey);
void queryClient.invalidateQueries({
queryKey: managedAgentsQueryKey,
});
}
})();
});
}
// Drop edge state for agents that no longer exist.

View File

@@ -106,3 +106,17 @@ test("classifyReconcileResult treats a relay with no rows as reconciled", () =>
{ succeeded: ["wss://a.example"], failed: [] },
);
});
test("classifyReconcileResult leaves a fenced relay neither reconciled nor failed", () => {
// Its community was removed mid-call: marking it reconciled would stop a
// same-session re-add from starting its pairs.
assert.deepEqual(
classifyReconcileResult(
["wss://removed.example", "wss://alive.example"],
[],
canonicalRelayUrl,
new Set(["wss://removed.example"]),
),
{ succeeded: ["wss://alive.example"], failed: [] },
);
});

View File

@@ -75,6 +75,9 @@ export function classifyReconcileResult(
attempted: readonly string[],
rows: readonly ManagedAgentRuntimeStatus[] | null,
canonicalize: (url: string) => string | null,
// Relays whose community was removed mid-call; the fence stopped their
// pairs, so they count as neither reconciled nor due a retry.
fenced: ReadonlySet<string> = new Set(),
): { succeeded: string[]; failed: string[] } {
if (rows === null) {
return { succeeded: [], failed: [...attempted] };
@@ -88,6 +91,7 @@ export function classifyReconcileResult(
const succeeded: string[] = [];
const failed: string[] = [];
for (const relay of attempted) {
if (fenced.has(relay)) continue;
if (failedRelays.has(relay)) failed.push(relay);
else succeeded.push(relay);
}

View File

@@ -0,0 +1,146 @@
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import {
markRelayRemoved,
readmitRelay,
reconcileConfiguredManagedAgentRuntimes,
refuseRelayAdmission,
stopManagedAgentPairsOnRelay,
} from "./managedAgentRelayCleanup.ts";
function pair(pubkey, relayUrl, lifecycle = "ready") {
return { pubkey, relayUrl, lifecycle };
}
test("stops every live pair on the removed relay and no others", async () => {
const stopped = [];
await stopManagedAgentPairsOnRelay("wss://Dead.example/", {
list: async () => [
pair("a", "wss://dead.example"),
pair("b", "wss://dead.example", "failed"),
pair("c", "wss://dead.example", "stopped"),
pair("d", "wss://alive.example"),
],
stop: async (pubkey, relayUrl) => stopped.push([pubkey, relayUrl]),
});
assert.deepEqual(stopped, [
["a", "wss://dead.example"],
["b", "wss://dead.example"],
]);
});
test("a failed stop is logged and does not throw", async () => {
const warn = mock.method(console, "warn", () => {});
const stopped = [];
await stopManagedAgentPairsOnRelay("wss://dead.example", {
list: async () => [
pair("a", "wss://dead.example"),
pair("b", "wss://dead.example"),
],
stop: async (pubkey) => {
if (pubkey === "a") throw new Error("boom");
stopped.push(pubkey);
},
});
assert.deepEqual(stopped, ["b"]);
assert.equal(warn.mock.callCount(), 1);
warn.mock.restore();
});
test("a failed runtime listing is logged and does not throw", async () => {
const warn = mock.method(console, "warn", () => {});
await stopManagedAgentPairsOnRelay("wss://dead.example", {
list: async () => {
throw new Error("ipc down");
},
stop: async () => assert.fail("nothing to stop"),
});
assert.equal(warn.mock.callCount(), 1);
warn.mock.restore();
});
test("reconcile stops live pairs on a relay removed mid-flight and reports it", async () => {
const stopped = [];
const { runtimes, removedRelays } =
await reconcileConfiguredManagedAgentRuntimes(
[
{ relayUrl: "wss://Dead.example/" },
{ relayUrl: "wss://alive.example" },
],
{
reconcile: async () => {
// The community is removed while the reconcile is in flight.
markRelayRemoved("wss://dead.example");
return [
pair("a", "wss://dead.example"),
pair("b", "wss://dead.example", "failed"),
pair("c", "wss://alive.example"),
];
},
stop: async (pubkey, relayUrl) => stopped.push([pubkey, relayUrl]),
},
);
// The failed row has no live child, so it is left alone.
assert.deepEqual(stopped, [["a", "wss://dead.example"]]);
assert.deepEqual([...removedRelays], ["wss://dead.example"]);
assert.equal(runtimes.length, 3);
});
test("reconcile stops nothing without a removal, whatever storage holds", async () => {
// A storage failure used to look like "every relay removed". The fence now
// needs a removal recorded during the call, so it stops nothing here, even
// for a relay removed before the call started.
markRelayRemoved("wss://earlier.example");
const stopped = [];
const { removedRelays } = await reconcileConfiguredManagedAgentRuntimes(
[
{ relayUrl: "wss://earlier.example" },
{ relayUrl: "wss://alive.example" },
],
{
reconcile: async () => [
pair("a", "wss://earlier.example"),
pair("b", "wss://alive.example"),
],
stop: async (pubkey) => stopped.push(pubkey),
},
);
assert.deepEqual(stopped, []);
assert.equal(removedRelays.size, 0);
});
test("removing a 127.* hostname does not fence the real loopback relay", async () => {
const stopped = [];
const { removedRelays } = await reconcileConfiguredManagedAgentRuntimes(
[
{ relayUrl: "wss://127.preview.example" },
{ relayUrl: "ws://127.0.0.1:3000" },
],
{
reconcile: async () => {
markRelayRemoved("wss://127.preview.example");
return [
pair("a", "wss://127.preview.example"),
pair("b", "ws://127.0.0.1:3000"),
];
},
stop: async (pubkey) => stopped.push(pubkey),
},
);
assert.deepEqual(stopped, ["a"]);
assert.deepEqual([...removedRelays], ["wss://127.preview.example"]);
});
test("a failed admission write rejects for its caller and the queue keeps running", async () => {
// No Tauri runtime here, so every native admission write fails.
await assert.rejects(refuseRelayAdmission("wss://write-fails.example"));
await assert.rejects(readmitRelay("wss://write-fails.example"));
// The queue recovered: a reconcile waiting on it still runs.
const { runtimes } = await reconcileConfiguredManagedAgentRuntimes([], {
list: async () => [],
reconcile: async () => [],
stop: async () => {},
});
assert.deepEqual(runtimes, []);
});

View File

@@ -0,0 +1,163 @@
import { canonicalRelayUrl } from "@/features/agents/managedAgentRuntimeStatus";
import {
listManagedAgentRuntimes,
reconcileManagedAgentRuntimes,
stopManagedAgentRuntime,
} from "@/shared/api/tauriManagedAgents";
import {
readdCommunityRelay,
removeCommunityRelay,
} from "@/shared/api/tauriWorkspace";
import type { ManagedAgentRuntimeStatus } from "@/shared/api/types";
type Dependencies = {
list: () => Promise<ManagedAgentRuntimeStatus[]>;
reconcile: typeof reconcileManagedAgentRuntimes;
stop: (pubkey: string, relayUrl: string) => Promise<unknown>;
};
const defaultDependencies: Dependencies = {
list: listManagedAgentRuntimes,
reconcile: reconcileManagedAgentRuntimes,
stop: stopManagedAgentRuntime,
};
// Per canonical relay URL, how many times its community was removed from this
// device this session. A reconcile compares these before and after its call,
// so only a positive removal — never a storage read — fences its result.
const relayRemovals = new Map<string, number>();
/** Record that the last community on `relayUrl` was removed from this device. */
export function markRelayRemoved(relayUrl: string): void {
const relay = canonicalRelayUrl(relayUrl);
if (!relay) return;
relayRemovals.set(relay, (relayRemovals.get(relay) ?? 0) + 1);
}
/** Native refusal for a start on a relay removed from this device. */
export const RELAY_REMOVED_ERROR = "relay was removed from this device";
/**
* A start that a relay removal cancelled: a quiet no-op, not a failure.
* Accepts a thrown error or a returned `spawnError` message.
*/
export function isRelayRemovedError(error: unknown): boolean {
const message = error instanceof Error ? error.message : error;
return message === RELAY_REMOVED_ERROR;
}
/**
* Capture `relayUrl`'s removal counter before a stop/start sequence awaits.
* The returned check throws `RELAY_REMOVED_ERROR` if that relay was removed
* since. Call it immediately before the start, with no await in between, so
* a removal followed by a re-add cannot readmit the stale continuation.
*/
export function captureRelayRemovals(relayUrl: string): () => void {
// An unparseable relay is never marked removed, so its check never throws.
const relay = canonicalRelayUrl(relayUrl) ?? "";
const before = relayRemovals.get(relay) ?? 0;
return () => {
if ((relayRemovals.get(relay) ?? 0) !== before) {
throw new Error(RELAY_REMOVED_ERROR);
}
};
}
// Writes to Rust's per-relay admission record run in order, and a reconcile
// waits for them, so a reconcile issued after a re-add is admitted.
let admissionWrites: Promise<void> = Promise.resolve();
// A failed write rejects for its own caller; the queue itself recovers so
// later writes still run.
function queueAdmissionWrite(write: () => Promise<void>): Promise<void> {
const result = admissionWrites.then(write);
admissionWrites = result.catch(() => {});
return result;
}
/** Refuse local pairs on a relay no saved community uses any more. */
export const refuseRelayAdmission = (relayUrl: string) =>
queueAdmissionWrite(() => removeCommunityRelay(relayUrl));
/** Admit local pairs on the relay of an explicitly re-added community. */
export const readmitRelay = (relayUrl: string) =>
queueAdmissionWrite(() => readdCommunityRelay(relayUrl));
async function stopPairs(
pairs: readonly ManagedAgentRuntimeStatus[],
stop: Dependencies["stop"],
): Promise<void> {
const results = await Promise.allSettled(
pairs.map((pair) => stop(pair.pubkey, pair.relayUrl)),
);
for (const result of results) {
if (result.status === "rejected") {
console.warn("[managed-agent-runtimes] stop failed:", result.reason);
}
}
}
/**
* Stop every live agent pair on a relay whose community was removed.
* Best-effort: failures are logged so community removal still completes.
*/
export async function stopManagedAgentPairsOnRelay(
relayUrl: string,
dependencies: Dependencies = defaultDependencies,
): Promise<void> {
const relay = canonicalRelayUrl(relayUrl);
if (!relay) return;
try {
const runtimes = await dependencies.list();
await stopPairs(
runtimes.filter(
(runtime) =>
runtime.lifecycle !== "stopped" &&
canonicalRelayUrl(runtime.relayUrl) === relay,
),
dependencies.stop,
);
} catch (error) {
console.warn("[managed-agent-runtimes] list failed:", error);
}
}
/**
* Reconcile auto-start pairs, then stop any pair the reconcile started on a
* relay whose community was removed while the call was in flight. Rust cannot
* be cancelled mid-reconcile, so the result is fenced instead. Returns the
* relays the fence stopped; they must not be treated as reconciled.
*/
export async function reconcileConfiguredManagedAgentRuntimes(
communities: readonly { relayUrl: string }[],
dependencies: Dependencies = defaultDependencies,
): Promise<{
runtimes: ManagedAgentRuntimeStatus[];
removedRelays: Set<string>;
}> {
await admissionWrites;
const before = new Map(relayRemovals);
const runtimes = await dependencies.reconcile(communities);
const removedRelays = new Set<string>();
for (const { relayUrl } of communities) {
const relay = canonicalRelayUrl(relayUrl);
if (relay && relayRemovals.get(relay) !== before.get(relay)) {
removedRelays.add(relay);
}
}
await stopPairs(
runtimes.filter((runtime) => {
const relay = canonicalRelayUrl(runtime.relayUrl);
// `failed` rows have no live child; stopping one only rewrites its
// record and can fail a concurrent reconcile's `expected_updated_at`.
return (
relay !== null &&
removedRelays.has(relay) &&
runtime.lifecycle !== "stopped" &&
runtime.lifecycle !== "failed"
);
}),
dependencies.stop,
);
return { runtimes, removedRelays };
}

View File

@@ -108,3 +108,35 @@ test("test_pair_restart_strict_stop_clear_start_ordering", async () => {
"operations must fire in stop → clear → start order",
);
});
test("a pair restart whose relay is removed during its stop never starts, even after a re-add", async () => {
const { markRelayRemoved } = await import("./managedAgentRelayCleanup.ts");
const relay = "wss://removed-during-restart.example";
let releaseStop;
let started = 0;
const run = () =>
restartManagedAgentPair(
PUBKEY,
relay,
() =>
new Promise((resolve) => (releaseStop = () => resolve(makeStatus()))),
() => {},
async () => {
started += 1;
return makeStatus();
},
);
const stale = run();
// Remove, then re-add: the re-add never resets the removal counter.
markRelayRemoved(relay);
releaseStop();
await assert.rejects(stale, /relay was removed from this device/);
assert.equal(started, 0);
// Positive control: a restart begun after the re-add starts.
const fresh = run();
releaseStop();
await fresh;
assert.equal(started, 1);
});

View File

@@ -12,11 +12,14 @@ import {
} from "@/features/communities/communityStorage";
import {
listManagedAgentRuntimes,
reconcileManagedAgentRuntimes,
startManagedAgentRuntime,
stopManagedAgentRuntime,
} from "@/shared/api/tauriManagedAgents";
import type { ManagedAgentRuntimeStatus } from "@/shared/api/types";
import {
captureRelayRemovals,
reconcileConfiguredManagedAgentRuntimes,
} from "./managedAgentRelayCleanup";
import { canonicalRelayUrl } from "./managedAgentRuntimeStatus";
export const managedAgentRuntimesQueryKey = ["managed-agent-runtimes"] as const;
@@ -85,8 +88,8 @@ export function bootstrapManagedAgentRuntimePairs(
const communities = loadCommunities().map((community) => ({
relayUrl: community.relayUrl,
}));
void reconcileManagedAgentRuntimes(communities)
.then((runtimes) => {
void reconcileConfiguredManagedAgentRuntimes(communities)
.then(({ runtimes }) => {
cacheReconciledManagedAgentRuntimes(queryClient, baseline, runtimes);
})
.catch((error) => {
@@ -159,6 +162,8 @@ export function clearActiveTurnsForAgentOnStop(
* - Clear fires only when stop succeeds.
* - A failed start occurs after the clear — the badge is already gone.
* - No clear can fire after start begins, so genuinely-new turns are safe.
* - A removal of the relay during the stop abandons the start with
* `RELAY_REMOVED_ERROR`, even if the community was re-added since.
*/
export async function restartManagedAgentPair(
pubkey: string,
@@ -173,8 +178,10 @@ export async function restartManagedAgentPair(
relayUrl: string,
) => Promise<ManagedAgentRuntimeStatus>,
): Promise<ManagedAgentRuntimeStatus> {
const assertRelayNotRemoved = captureRelayRemovals(relayUrl);
await stop(pubkey, relayUrl);
clear(pubkey, relayUrl);
assertRelayNotRemoved();
return start(pubkey, relayUrl);
}

View File

@@ -94,6 +94,16 @@ test("canonicalRelayUrl mirrors the backend pair-key normalization", () => {
"wss://relay.example/path",
);
assert.equal(canonicalRelayUrl("ws://[::1]:3000"), "ws://127.0.0.1:3000");
assert.equal(
canonicalRelayUrl("ws://127.8.9.10:3000"),
"ws://127.0.0.1:3000",
);
// Only loopback IP addresses fold, as in buzz-core: a domain whose first
// label is "127" is an ordinary host.
assert.equal(
canonicalRelayUrl("wss://127.preview.example"),
"wss://127.preview.example",
);
assert.equal(canonicalRelayUrl("https://relay.example"), null);
assert.equal(canonicalRelayUrl("not a url"), null);
});

View File

@@ -66,8 +66,8 @@ export const MANAGED_AGENT_PAIR_ACTION_LABELS: Record<
* Canonicalize a relay URL the way the backend keys runtime pairs, so a
* stored community URL (e.g. `ws://localhost:3000`) matches backend rows
* (`ws://127.0.0.1:3000`). Mirrors buzz-core's `normalize_relay_url`
* (`crates/buzz-core/src/relay.rs`): lowercase host, loopback hosts folded
* to 127.0.0.1, default ports and root-path trailing slash stripped.
* (`crates/buzz-core/src/relay.rs`): lowercase host, `localhost` and loopback IP
* addresses folded to 127.0.0.1, default ports and root-path trailing slash stripped.
* Returns null when the URL cannot be parsed as ws/wss.
*/
export function canonicalRelayUrl(raw: string): string | null {
@@ -79,7 +79,13 @@ export function canonicalRelayUrl(raw: string): string | null {
}
if (url.protocol !== "ws:" && url.protocol !== "wss:") return null;
let host = url.hostname.toLowerCase();
if (host === "localhost" || host === "[::1]" || host.startsWith("127.")) {
// `URL` normalizes IP literals, so only a real 127.0.0.0/8 address or the
// IPv6 loopback matches here; a domain such as `127.preview.example` does not.
if (
host === "localhost" ||
host === "[::1]" ||
/^127\.\d+\.\d+\.\d+$/.test(host)
) {
host = "127.0.0.1";
}
const defaultPort = url.protocol === "ws:" ? "80" : "443";

View File

@@ -1,3 +1,4 @@
import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup";
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown } from "lucide-react";
@@ -808,10 +809,9 @@ export function AgentInstanceEditDialog({
startMutation.mutate(result.agent.pubkey, {
onSuccess: () => toast.success(`${startedName} started.`),
onError: (error) =>
isRelayRemovedError(error) ||
toast.error(
error instanceof Error
? `${startedName} failed to start: ${error.message}`
: `${startedName} failed to start.`,
`${startedName} failed to start${error instanceof Error ? `: ${error.message}` : "."}`,
),
});
},

View File

@@ -0,0 +1,211 @@
import assert from "node:assert/strict";
import test from "node:test";
const PUBKEY = "ab".repeat(32);
const RELAY = "wss://removed.example";
const UNRELATED_RELAY = "wss://unrelated.example";
const calls = [];
let releaseStop;
let createSpawnError = null;
const rawAgent = {
pubkey: PUBKEY,
name: "Scout",
persona_id: null,
relay_url: RELAY,
acp_command: "acp",
agent_command: "agent",
agent_args: [],
mcp_command: "mcp",
turn_timeout_seconds: 60,
idle_timeout_seconds: 60,
max_turn_duration_seconds: 60,
parallelism: 1,
system_prompt: null,
model: null,
status: "running",
pid: 1,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
last_started_at: null,
last_stopped_at: null,
last_exit_code: null,
last_error: null,
log_path: "/tmp/log",
start_on_app_launch: false,
backend: { type: "local" },
backend_agent_id: null,
};
const tauriMock = {
invoke(command) {
calls.push(command);
if (command === "list_managed_agents") return Promise.resolve([rawAgent]);
if (command === "stop_managed_agent") {
return new Promise((resolve) => {
releaseStop = () => resolve({ ...rawAgent, status: "stopped" });
});
}
if (command === "start_managed_agent") return Promise.resolve(rawAgent);
if (command === "discover_acp_providers") {
return Promise.resolve([
{
id: "buzz-agent",
label: "Buzz Agent",
avatar_url: "",
availability: "available",
command: "buzz-agent",
binary_path: "/bin/buzz-agent",
default_args: [],
mcp_command: null,
},
]);
}
if (command === "create_managed_agent") {
return Promise.resolve({
agent: { ...rawAgent, status: "stopped" },
private_key_nsec: "nsec1test",
profile_sync_error: null,
spawn_error: createSpawnError,
});
}
return Promise.reject(new Error(`unmocked Tauri command: ${command}`));
},
transformCallback() {
return Math.random();
},
};
globalThis.__TAURI_INTERNALS__ = tauriMock;
globalThis.window.__TAURI_INTERNALS__ = tauriMock;
const React = (await import("react")).default;
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
);
const { useManagedAgentActions } = await import("./useManagedAgentActions.ts");
const { markRelayRemoved } = await import("../managedAgentRelayCleanup.ts");
const { CommunitiesProvider } = await import(
"../../communities/useCommunities.tsx"
);
const { saveActiveCommunityId, saveCommunities } = await import(
"../../communities/communityStorage.ts"
);
const { RELAY_REMOVED_ERROR } = await import("../managedAgentRelayCleanup.ts");
// The active community is the workspace pair the restart targets.
saveCommunities([
{ id: "b", name: "B", relayUrl: RELAY },
{ id: "a", name: "A", relayUrl: UNRELATED_RELAY },
]);
saveActiveCommunityId("b");
async function renderActions() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const root = createRoot(document.createElement("div"));
const latest = {};
function Harness() {
Object.assign(latest, useManagedAgentActions());
return null;
}
await act(async () => {
root.render(
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(
CommunitiesProvider,
null,
React.createElement(Harness),
),
),
);
});
for (let i = 0; i < 20 && !latest.managedAgents?.length; i += 1) {
await act(() => new Promise((resolve) => setTimeout(resolve, 5)));
}
assert.equal(latest.managedAgents?.length, 1, "agent list loaded");
return { latest, unmount: () => act(async () => root.unmount()) };
}
async function restartAcrossStop(duringStop) {
calls.length = 0;
const { latest, unmount } = await renderActions();
let restart;
await act(async () => {
restart = latest.handleRestart(PUBKEY);
});
for (let i = 0; i < 20 && !releaseStop; i += 1) {
await act(() => new Promise((resolve) => setTimeout(resolve, 5)));
}
duringStop();
await act(async () => {
releaseStop();
await restart;
});
releaseStop = undefined;
const result = {
started: calls.includes("start_managed_agent"),
error: latest.actionErrorMessage,
};
await unmount();
return result;
}
test("a Settings restart whose relay is removed and re-added during its stop starts nothing and shows no error", async () => {
// Re-adding never resets the counter, so only the removal matters here.
const result = await restartAcrossStop(() => markRelayRemoved(RELAY));
assert.equal(result.started, false);
assert.equal(result.error, null);
});
test("removing an unrelated community during the stop still restarts the agent", async () => {
const result = await restartAcrossStop(() =>
markRelayRemoved(UNRELATED_RELAY),
);
assert.equal(result.started, true);
assert.equal(result.error, null);
});
test("a Settings restart with no removal during its stop starts the agent", async () => {
const result = await restartAcrossStop(() => {});
assert.equal(result.started, true);
assert.equal(result.error, null);
});
async function startPersonaWithSpawnError(spawnError) {
createSpawnError = spawnError;
calls.length = 0;
const { latest, unmount } = await renderActions();
await act(async () => {
await latest.handleStartPersona({
id: "persona-1",
displayName: "Scout",
systemPrompt: "help",
avatarUrl: null,
runtime: null,
});
});
const result = {
created: calls.includes("create_managed_agent"),
error: latest.actionErrorMessage,
};
await unmount();
createSpawnError = null;
return result;
}
test("a persona Start whose spawn a relay removal cancelled shows no error", async () => {
const result = await startPersonaWithSpawnError(RELAY_REMOVED_ERROR);
assert.equal(result.created, true);
assert.equal(result.error, null);
});
test("a persona Start with a genuine spawn error still shows it", async () => {
const result = await startPersonaWithSpawnError(
"runtime executable not found",
);
assert.equal(result.created, true);
assert.equal(result.error, "runtime executable not found");
});

View File

@@ -1,3 +1,5 @@
import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup";
import { useCommunities } from "@/features/communities/useCommunities";
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -40,6 +42,7 @@ import {
export function useManagedAgentActions() {
const queryClient = useQueryClient();
const relayUrl = useCommunities().activeCommunity?.relayUrl;
const { globalConfig } = useGlobalAgentConfig();
const relayAgentsQuery = useRelayAgentsQuery();
const managedAgentsQuery = useManagedAgentsQuery();
@@ -182,6 +185,7 @@ export function useManagedAgentActions() {
startManagedAgent: startMutation.mutateAsync,
});
} catch (error) {
if (isRelayRemovedError(error)) return;
setActionErrorMessage(
error instanceof Error ? error.message : "Failed to start agent.",
);
@@ -200,11 +204,13 @@ export function useManagedAgentActions() {
assertStartNotBlockedByPresence(agent);
await respawnManagedAgentWithRules({
agent,
relayUrl,
startManagedAgent: startMutation.mutateAsync,
stopManagedAgent: stopMutation.mutateAsync,
onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey),
});
} catch (error) {
if (isRelayRemovedError(error)) return;
setActionErrorMessage(
error instanceof Error ? error.message : "Failed to restart agent.",
);
@@ -243,7 +249,7 @@ export function useManagedAgentActions() {
toast.success("Agent created");
const notices = [...warnings];
if (created.spawnError) {
if (created.spawnError && !isRelayRemovedError(created.spawnError)) {
setActionErrorMessage(created.spawnError);
}
@@ -257,6 +263,7 @@ export function useManagedAgentActions() {
void managedAgentsQuery.refetch();
void relayAgentsQuery.refetch();
} catch (error) {
if (isRelayRemovedError(error)) return;
setActionErrorMessage(
error instanceof Error ? error.message : "Failed to start agent.",
);

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup";
import { useQueryClient } from "@tanstack/react-query";
import {
@@ -249,7 +250,7 @@ export function usePersonaActions() {
created,
targetChannel,
);
if (created.spawnError) {
if (created.spawnError && !isRelayRemovedError(created.spawnError)) {
setPersonaErrorMessage(
`${persona.displayName} was created, but it did not start: ${created.spawnError}`,
);

View File

@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import test from "node:test";
const calls = [];
let resolveFirstReconcile;
const tauriMock = {
invoke(command, args) {
calls.push(command);
if (command === "reconcile_managed_agent_runtimes") {
if (!resolveFirstReconcile) {
return new Promise((resolve) => {
resolveFirstReconcile = resolve;
});
}
return Promise.resolve([]);
}
if (command === "stop_managed_agent_runtime") {
return Promise.resolve({ ...args, lifecycle: "stopped" });
}
return Promise.reject(new Error(`unmocked Tauri command: ${command}`));
},
transformCallback() {
return Math.random();
},
};
globalThis.__TAURI_INTERNALS__ = tauriMock;
globalThis.window.__TAURI_INTERNALS__ = tauriMock;
const React = (await import("react")).default;
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
);
const { useManagedAgentRuntimeReconciliation } = await import(
"./useManagedAgentRuntimeReconciliation.ts"
);
const { markRelayRemoved } = await import("./managedAgentRelayCleanup.ts");
const RELAY = "wss://removed.example";
function Harness({ communities }) {
useManagedAgentRuntimeReconciliation(communities);
return null;
}
test("a reconcile that finishes after its community was removed does not block re-adding it", async () => {
const queryClient = new QueryClient();
const root = createRoot(document.createElement("div"));
const render = (communities) =>
act(async () => {
root.render(
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(Harness, { communities }),
),
);
});
const reconciles = () =>
calls.filter((command) => command === "reconcile_managed_agent_runtimes")
.length;
await render([{ relayUrl: RELAY }]);
assert.equal(reconciles(), 1);
// The community is removed while its reconcile is still in flight.
markRelayRemoved(RELAY);
await render([]);
// The late reconcile finishes with a pair on the removed relay.
await act(async () => {
resolveFirstReconcile([
{ pubkey: "a".repeat(64), relayUrl: RELAY, lifecycle: "ready" },
]);
});
assert.ok(calls.includes("stop_managed_agent_runtime"));
// Re-adding the community must reconcile it again.
await render([{ relayUrl: RELAY }]);
assert.equal(reconciles(), 2);
await act(async () => root.unmount());
});

View File

@@ -13,7 +13,7 @@ import {
} from "@/features/agents/managedAgentRuntimeHooks";
import { canonicalRelayUrl } from "@/features/agents/managedAgentRuntimeStatus";
import type { ManagedAgentRuntimeStatus } from "@/shared/api/types";
import { reconcileManagedAgentRuntimes } from "@/shared/api/tauriManagedAgents";
import { reconcileConfiguredManagedAgentRuntimes } from "@/features/agents/managedAgentRelayCleanup";
/**
* Bootstrap a lazy harness pair for every auto-start local agent in every
@@ -106,10 +106,17 @@ export function useManagedAgentRuntimeReconciliation(
managedAgentRuntimesQueryKey,
);
void reconcileManagedAgentRuntimes(targets)
.then((runtimes) => {
void reconcileConfiguredManagedAgentRuntimes(targets)
.then(({ runtimes, removedRelays }) => {
cacheReconciledManagedAgentRuntimes(queryClient, baseline, runtimes);
return classifyReconcileResult(pending, runtimes, canonicalRelayUrl);
// A relay fenced mid-call is not marked reconciled, so re-adding
// its community reconciles it afresh.
return classifyReconcileResult(
pending,
runtimes,
canonicalRelayUrl,
removedRelays,
);
})
.catch((error) => {
console.warn("[managed-agent-runtimes] reconcile failed:", error);

View File

@@ -1,3 +1,4 @@
import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup";
import {
agentPresenceStartBlockReason,
type AgentAvailabilityReader,
@@ -134,6 +135,7 @@ export function useMembersSidebarActions({
successCount += 1;
} catch (error) {
if (isRelayRemovedError(error)) continue;
failures.push({
error: error instanceof Error ? error.message : failureMessage,
name: agent.name,
@@ -212,6 +214,7 @@ export function useMembersSidebarActions({
});
setActionNoticeMessage(getLifecycleSuccessMessage(agent));
} catch (error) {
if (isRelayRemovedError(error)) return;
setActionErrorMessage(
error instanceof Error ? error.message : "Failed to control agent.",
);
@@ -226,6 +229,7 @@ export function useMembersSidebarActions({
assertStartNotBlockedByPresence(agent, isManagedAgentActive(agent));
await respawnManagedAgentWithRules({
agent,
relayUrl,
startManagedAgent: startManagedAgentMutation.mutateAsync,
stopManagedAgent: stopManagedAgentMutation.mutateAsync,
onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey),

View File

@@ -0,0 +1,118 @@
import assert from "node:assert/strict";
import test from "node:test";
const LOCAL_RELAY = "ws://localhost:3000";
const LEGACY_RELAY = "wss://legacy.example";
const calls = [];
// Mirrors the native admission record, which survives a webview reload.
const removedRelays = new Set();
let legacyWorkspaces;
let onboardingCompletions;
const tauriMock = {
invoke(command, args) {
calls.push(command);
if (command === "get_legacy_workspace_storage") {
return Promise.resolve({
workspaces: JSON.stringify(legacyWorkspaces),
activeWorkspaceId: "legacy",
onboardingCompletions,
});
}
if (command === "readd_community_relay") {
// Mirrors only native's credential and fragment rejection, not full validation.
const url = new URL(args.relayUrl);
if (url.username || url.password || args.relayUrl.includes("#")) {
return Promise.reject(new Error("invalid relay url"));
}
removedRelays.delete(args.relayUrl);
return Promise.resolve();
}
return Promise.reject(new Error(`unmocked Tauri command: ${command}`));
},
transformCallback() {
return Math.random();
},
};
globalThis.__TAURI_INTERNALS__ = tauriMock;
globalThis.window.__TAURI_INTERNALS__ = tauriMock;
const { migrateLegacyCommunityStorageBeforeRender } = await import(
"./legacyCommunityStorage.ts"
);
const COMPLETION_KEY_PREFIX = "buzz-onboarding-complete.v1:";
// Only the localhost community is left after the legacy relay was removed.
async function reloadWithLegacy({
workspaces,
completions = [],
savedLocalRelay = LOCAL_RELAY,
}) {
localStorage.clear();
localStorage.setItem(
"buzz-communities",
JSON.stringify([{ id: "local", name: "Local", relayUrl: savedLocalRelay }]),
);
localStorage.setItem("buzz-active-community-id", "local");
removedRelays.clear();
removedRelays.add(LEGACY_RELAY);
legacyWorkspaces = workspaces;
onboardingCompletions = completions;
calls.length = 0;
await migrateLegacyCommunityStorageBeforeRender();
return calls.filter((c) => c === "readd_community_relay").length;
}
const legacy = { id: "legacy", name: "Legacy", relayUrl: LEGACY_RELAY };
const local = { id: "local", name: "Local", relayUrl: LOCAL_RELAY };
test("a reload whose legacy migration brings back a removed relay re-admits it once", async () => {
const readds = await reloadWithLegacy({ workspaces: [legacy, local] });
assert.match(localStorage.getItem("buzz-communities"), /legacy\.example/);
assert.equal(removedRelays.has(LEGACY_RELAY), false, "legacy relay admitted");
// The unchanged localhost community is not re-admitted.
assert.equal(readds, 1);
});
test("a failed onboarding-completion write after the list is saved still re-admits the relay once", async () => {
const setItem = Storage.prototype.setItem;
Storage.prototype.setItem = function (key, value) {
if (key.startsWith(COMPLETION_KEY_PREFIX)) {
throw new DOMException("full", "QuotaExceededError");
}
return setItem.call(this, key, value);
};
try {
const readds = await reloadWithLegacy({
workspaces: [legacy],
completions: [{ pubkey: "ab".repeat(32), value: "1" }],
});
assert.equal(localStorage.getItem("buzz-active-community-id"), "legacy");
assert.equal(
removedRelays.has(LEGACY_RELAY),
false,
"legacy relay admitted",
);
assert.equal(readds, 1);
} finally {
Storage.prototype.setItem = setItem;
}
});
for (const alias of [`${LEGACY_RELAY}/#old`, "wss://user@legacy.example"]) {
test(`an invalid alias ${alias} listed before the valid relay still re-admits it`, async () => {
await reloadWithLegacy({
workspaces: [{ ...legacy, id: "alias", relayUrl: alias }, legacy],
});
assert.equal(
removedRelays.has(LEGACY_RELAY),
false,
"legacy relay admitted",
);
});
}
test("the same relay URL listed twice is re-admitted once", async () => {
const readds = await reloadWithLegacy({ workspaces: [legacy, legacy] });
assert.equal(readds, 1);
});

View File

@@ -1,5 +1,6 @@
import { invokeTauri } from "@/shared/api/tauri";
import { getStorageItem } from "@/shared/lib/safeStorage";
import { readmitRelay } from "@/features/agents/managedAgentRelayCleanup";
import { migrateLegacyCommunityStorage } from "./communityStorage";
const BUZZ_COMMUNITIES_KEY = "buzz-communities";
@@ -74,34 +75,66 @@ function shouldWriteLegacyCommunities({
return !hasNonLocalCurrentCommunities(currentCommunitiesRaw);
}
/** Relay URLs in `nextRaw` that aren't already saved in `currentRaw`. */
function newRelayUrls(currentRaw: string | null, nextRaw: string): string[] {
// Deliberately raw: native canonicalizes and validates on re-add, and a
// JavaScript notion of "same relay" can skip a URL native would admit.
const seen = new Set(
(parseCommunityList(currentRaw) ?? []).map((c) => c.relayUrl),
);
const introduced: string[] = [];
for (const { relayUrl } of parseCommunityList(nextRaw) ?? []) {
if (typeof relayUrl !== "string" || seen.has(relayUrl)) continue;
seen.add(relayUrl);
introduced.push(relayUrl);
}
return introduced;
}
export function applyLegacyCommunityStorage(
legacyStorage: LegacyCommunityStorageSnapshot,
storage: Storage = window.localStorage,
): void {
): string[] {
const currentCommunitiesRaw = storage.getItem(BUZZ_COMMUNITIES_KEY);
const shouldWriteCommunities = shouldWriteLegacyCommunities({
currentCommunitiesRaw,
legacyCommunitiesRaw: legacyStorage.workspaces,
});
let introducedRelayUrls: string[] = [];
if (shouldWriteCommunities && legacyStorage.workspaces) {
storage.setItem(BUZZ_COMMUNITIES_KEY, legacyStorage.workspaces);
introducedRelayUrls = newRelayUrls(
currentCommunitiesRaw,
legacyStorage.workspaces,
);
}
const currentActiveCommunityId = storage.getItem(BUZZ_ACTIVE_COMMUNITY_KEY);
if (
legacyStorage.activeWorkspaceId &&
(!currentActiveCommunityId || shouldWriteCommunities)
) {
storage.setItem(BUZZ_ACTIVE_COMMUNITY_KEY, legacyStorage.activeWorkspaceId);
}
for (const completion of legacyStorage.onboardingCompletions) {
const key = `${BUZZ_ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX}${completion.pubkey}`;
if (storage.getItem(key) === null) {
storage.setItem(key, completion.value);
// The list is saved, so its new relays must still be re-admitted even if
// these follow-up writes fail; a later reload would skip the migration.
try {
const currentActiveCommunityId = storage.getItem(BUZZ_ACTIVE_COMMUNITY_KEY);
if (
legacyStorage.activeWorkspaceId &&
(!currentActiveCommunityId || shouldWriteCommunities)
) {
storage.setItem(
BUZZ_ACTIVE_COMMUNITY_KEY,
legacyStorage.activeWorkspaceId,
);
}
for (const completion of legacyStorage.onboardingCompletions) {
const key = `${BUZZ_ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX}${completion.pubkey}`;
if (storage.getItem(key) === null) {
storage.setItem(key, completion.value);
}
}
} catch (error) {
console.warn("Failed to migrate legacy Sprout onboarding state.", error);
}
return introducedRelayUrls;
}
/**
@@ -129,13 +162,27 @@ export async function migrateLegacyCommunityStorageBeforeRender(): Promise<void>
return;
}
let introducedRelayUrls: string[];
try {
applyLegacyCommunityStorage(
introducedRelayUrls = applyLegacyCommunityStorage(
await invokeTauri<LegacyCommunityStorageSnapshot>(
"get_legacy_workspace_storage",
),
);
} catch (error) {
console.warn("Failed to read legacy Sprout community storage.", error);
return;
}
// A webview reload keeps the native process, so a relay removed earlier in
// this session stays refused unless the migration re-admits it here.
await Promise.all(
introducedRelayUrls.map((relayUrl) =>
readmitRelay(relayUrl).catch((error) => {
console.error(
"[communities] re-admitting local agents on a migrated relay failed:",
error,
);
}),
),
);
}

View File

@@ -8,6 +8,7 @@ import {
Settings2,
LogOut,
Ticket,
Trash2,
WifiOff,
} from "lucide-react";
import * as React from "react";
@@ -15,6 +16,16 @@ import { toast } from "sonner";
import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity";
import type { Community } from "@/features/communities/types";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -66,7 +77,8 @@ type CommunitySwitcherProps = {
id: string,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
) => void;
onRemoveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
onLeaveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
onRemoveCommunityFromDevice: (id: string) => Promise<void>;
};
export function CommunityEmojiIcon({
@@ -107,13 +119,16 @@ export function CommunitySwitcher({
onSwitchCommunity,
onAddCommunity,
onUpdateCommunity,
onRemoveCommunity,
onLeaveCommunity,
onRemoveCommunityFromDevice,
}: CommunitySwitcherProps) {
const [editingCommunity, setEditingCommunity] =
React.useState<Community | null>(null);
const [dropdownOpen, setDropdownOpen] = React.useState(false);
const [leaveError, setLeaveError] = React.useState<string | null>(null);
const [isLeaving, setIsLeaving] = React.useState(false);
const [removingCommunity, setRemovingCommunity] =
React.useState<Community | null>(null);
const profileMenuHoverTimer = React.useRef<number | null>(null);
const connectionState = useRelayConnection();
const degraded = isRelayConnectionDegraded(connectionState);
@@ -170,7 +185,7 @@ export function CommunitySwitcher({
setIsLeaving(true);
setLeaveError(null);
try {
const result = await onRemoveCommunity(activeCommunity.id);
const result = await onLeaveCommunity(activeCommunity.id);
setDropdownOpen(false);
if (result?.status === "already-absent") {
toast("Community removed", {
@@ -179,16 +194,33 @@ export function CommunitySwitcher({
});
}
} catch (error) {
setLeaveError(
const message =
error instanceof Error
? error.message
: "Couldn't leave the community. Try again.",
: "Couldn't leave the community. Try again.";
setLeaveError(
`${message} If the community no longer exists, use Remove from this device.`,
);
setDropdownOpen(true);
} finally {
setIsLeaving(false);
}
}, [activeCommunity, isLeaving, onRemoveCommunity]);
}, [activeCommunity, isLeaving, onLeaveCommunity]);
const handleRemoveFromDevice = React.useCallback(async () => {
if (!removingCommunity) return;
const { id } = removingCommunity;
setRemovingCommunity(null);
try {
await onRemoveCommunityFromDevice(id);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Couldn't remove the community from this device. Try again.",
);
}
}, [onRemoveCommunityFromDevice, removingCommunity]);
const triggerContent = (
<>
@@ -331,6 +363,19 @@ export function CommunitySwitcher({
<LogOut className="h-4 w-4" />
<span>{isLeaving ? "Leaving…" : "Leave community"}</span>
</button>
<button
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-destructive outline-hidden transition-colors hover:bg-destructive/10 focus:bg-destructive/10 focus:outline-none focus-visible:bg-destructive/10 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
disabled={isLeaving}
onClick={() => {
setDropdownOpen(false);
setRemovingCommunity(activeCommunity);
}}
role="menuitem"
type="button"
>
<Trash2 className="h-4 w-4" />
<span>Remove from this device</span>
</button>
{leaveError ? (
<p
className="px-3 py-1 text-xs text-destructive"
@@ -460,6 +505,28 @@ export function CommunitySwitcher({
community={editingCommunity}
showIconEditor={editingCommunity?.id === activeCommunity?.id}
/>
<AlertDialog
onOpenChange={(open) => {
if (!open) setRemovingCommunity(null);
}}
open={removingCommunity !== null}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove from this device</AlertDialogTitle>
<AlertDialogDescription>
{`Remove "${removingCommunity?.name ?? ""}" from this device? Buzz won't contact the relay, so your membership isn't revoked. You can add the community again later.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => void handleRemoveFromDevice()}>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import test from "node:test";
const RELAY_A = "wss://a.example";
const RELAY_B = "wss://b.example";
const calls = [];
// Mirrors the native admission record: removed relays refuse local starts.
const removedRelays = new Set();
const tauriMock = {
invoke(command, args) {
calls.push(command);
if (command === "remove_community_relay") {
removedRelays.add(args.relayUrl);
return Promise.resolve();
}
if (command === "readd_community_relay") {
removedRelays.delete(args.relayUrl);
return Promise.resolve();
}
return Promise.reject(new Error(`unmocked Tauri command: ${command}`));
},
transformCallback() {
return Math.random();
},
};
globalThis.__TAURI_INTERNALS__ = tauriMock;
globalThis.window.__TAURI_INTERNALS__ = tauriMock;
const React = (await import("react")).default;
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { CommunitiesProvider, useCommunities } = await import(
"./useCommunities.tsx"
);
const { saveActiveCommunityId, saveCommunities } = await import(
"./communityStorage.ts"
);
const { refuseRelayAdmission } = await import(
"../agents/managedAgentRelayCleanup.ts"
);
test("editing a community onto a removed relay re-admits that relay once", async () => {
// B was removed from this device earlier; only A is still saved.
saveCommunities([{ id: "a", name: "A", relayUrl: RELAY_A }]);
saveActiveCommunityId("a");
await refuseRelayAdmission(RELAY_B);
assert.ok(removedRelays.has(RELAY_B));
const latest = {};
function Harness() {
Object.assign(latest, useCommunities());
return null;
}
const root = createRoot(document.createElement("div"));
await act(async () => {
root.render(
React.createElement(
CommunitiesProvider,
null,
React.createElement(Harness),
),
);
});
calls.length = 0;
let result;
await act(async () => {
result = latest.updateCommunity("a", { relayUrl: RELAY_B });
});
// Any reconcile queued after the edit waits on the admission write queue.
await refuseRelayAdmission("wss://unrelated.example");
assert.equal(result.kind, "updated");
assert.equal(removedRelays.has(RELAY_B), false, "B admits local starts");
assert.equal(calls.filter((c) => c === "readd_community_relay").length, 1);
// A name-only edit does not re-admit.
calls.length = 0;
await act(async () => {
latest.updateCommunity("a", { name: "Renamed" });
});
assert.equal(calls.includes("readd_community_relay"), false);
await act(async () => root.unmount());
});

View File

@@ -23,6 +23,7 @@ import { removeProjectSnapshotForRelay } from "@/features/projects/projectSnapsh
import { clearChannelHeadCache } from "@/shared/api/tauriChannelHeadCache";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore";
import { readmitRelay } from "@/features/agents/managedAgentRelayCleanup";
import {
clearCommunityDestinations,
removeCommunityDestination,
@@ -213,6 +214,13 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
saveCommunities(next);
return next;
});
// A relay removed from this device earlier admits local agent pairs again.
readmitRelay(community.relayUrl).catch((error) => {
console.error(
"[communities] re-admitting local agents on this relay failed; their starts stay refused:",
error,
);
});
return resolvedId;
}, []);
@@ -298,6 +306,9 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
);
if (result.kind === "updated") {
const previousRelayUrl = communitiesRef.current.find(
(w) => w.id === id,
)?.relayUrl;
setCommunitiesState((prev) => {
const next = prev.map((w) =>
w.id === id ? { ...w, ...updates } : w,
@@ -306,6 +317,19 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
return next;
});
if (
updates.relayUrl !== undefined &&
updates.relayUrl !== previousRelayUrl
) {
// Queued before the reinit so its reconcile on the new relay is admitted.
readmitRelay(updates.relayUrl).catch((error) => {
console.error(
"[communities] re-admitting local agents on the edited relay failed; their starts stay refused:",
error,
);
});
}
if (result.requiresReinit) {
setReinitKey((k) => k + 1);
}

View File

@@ -5,6 +5,7 @@ import { isMacPlatform } from "@/shared/lib/platform";
import { relayClient } from "@/shared/api/relayClient";
import { resetChannelMembershipWrites } from "@/shared/api/channelMembershipWrites";
import { resetRateLimitGate } from "@/shared/api/relayRateLimitGate";
import { readmitRelay } from "@/features/agents/managedAgentRelayCleanup";
import {
autoConnectDefaultRelayEnabled,
getDefaultRelayUrl,
@@ -230,6 +231,14 @@ export function useCommunityInit(
identity.pubkey,
);
if (community && !cancelled) {
// Removing the last community refused this relay in the native
// process, which survives the reload.
await readmitRelay(community.relayUrl).catch((error) => {
console.error(
"[communities] re-admitting local agents on the default relay failed:",
error,
);
});
window.location.reload();
return;
}

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
@@ -464,7 +465,9 @@ export function UserProfilePanel({
try {
const created = await createManagedAgentForPersona(resolvedPersona);
if (created.spawnError) {
if (isRelayRemovedError(created.spawnError)) {
// Its community was removed meanwhile: created, not started, no error.
} else if (created.spawnError) {
toast.error(created.spawnError);
} else {
toast.success(`Started ${created.agent.name}.`);

View File

@@ -9,6 +9,8 @@ import {
} from "@/features/agents/lib/managedAgentControlActions";
import { agentPresenceStartBlockReason } from "@/features/agents/lib/useAgentAvailability";
import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks";
import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup";
import { useCommunities } from "@/features/communities/useCommunities";
import type {
Channel,
ManagedAgent,
@@ -31,6 +33,7 @@ export function useAgentLifecycleActions({
startManagedAgent: (pubkey: string) => Promise<unknown>;
stopManagedAgent: (pubkey: string) => Promise<unknown>;
}) {
const relayUrl = useCommunities().activeCommunity?.relayUrl;
const handleAgentPrimaryAction = React.useCallback(async () => {
if (!managedAgent) return;
@@ -85,17 +88,25 @@ export function useAgentLifecycleActions({
if (blockReason) throw new Error(blockReason);
await respawnManagedAgentWithRules({
agent: managedAgent,
relayUrl,
startManagedAgent,
stopManagedAgent,
onStopped: () => clearActiveTurnsForAgentOnStop(managedAgent.pubkey),
});
toast.success(`Restarted ${managedAgent.name}.`);
} catch (error) {
if (isRelayRemovedError(error)) return;
toast.error(
error instanceof Error ? error.message : "Agent restart failed.",
);
}
}, [availability, managedAgent, startManagedAgent, stopManagedAgent]);
}, [
availability,
managedAgent,
relayUrl,
startManagedAgent,
stopManagedAgent,
]);
return { handleAgentPrimaryAction, handleAgentRestart };
}

View File

@@ -114,7 +114,8 @@ export function AppSidebar({
onBrowseChannels,
onOpenDm,
onUpdateCommunity,
onRemoveCommunity,
onLeaveCommunity,
onRemoveCommunityFromDevice,
onCreateAgent,
onSelectAgents,
onSelectProjects,
@@ -864,7 +865,8 @@ export function AppSidebar({
onOpenAddCommunity={onOpenAddCommunity}
onOpenSettings={onSelectSettings}
onSendFeedback={onSendFeedback}
onRemoveCommunity={onRemoveCommunity}
onLeaveCommunity={onLeaveCommunity}
onRemoveCommunityFromDevice={onRemoveCommunityFromDevice}
onSetPresenceStatus={onSetPresenceStatus}
onSetUserStatus={onSetUserStatus}
onClearUserStatus={onClearUserStatus}

View File

@@ -82,7 +82,8 @@ export type AppSidebarProps = {
id: string,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
) => void;
onRemoveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
onLeaveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
onRemoveCommunityFromDevice: (id: string) => Promise<void>;
onCreateAgent: () => void;
onSelectAgents: () => void;
onSelectProjects: () => void;

View File

@@ -24,7 +24,8 @@ type SidebarProfileCardProps = {
isPresencePending?: boolean;
onOpenAddCommunity: () => void;
onOpenSettings: (section?: SettingsSection) => void;
onRemoveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
onLeaveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
onRemoveCommunityFromDevice: (id: string) => Promise<void>;
onSendFeedback?: () => void;
onSetPresenceStatus?: (status: PresenceStatus) => void;
onSetUserStatus: (status: UserStatusInput) => void;
@@ -47,7 +48,8 @@ export function SidebarProfileCard({
onOpenAddCommunity,
onOpenSettings,
onSendFeedback,
onRemoveCommunity,
onLeaveCommunity,
onRemoveCommunityFromDevice,
onSetPresenceStatus,
onSetUserStatus,
onClearUserStatus,
@@ -177,7 +179,8 @@ export function SidebarProfileCard({
setProfilePopoverOpen(false);
onOpenSettings("community-members");
}}
onRemoveCommunity={onRemoveCommunity}
onLeaveCommunity={onLeaveCommunity}
onRemoveCommunityFromDevice={onRemoveCommunityFromDevice}
onSwitchCommunity={onSwitchCommunity}
onUpdateCommunity={onUpdateCommunity}
variant="profile-menu"