mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): scope the agents UI per community
Filter every managed-agent surface to the active community's relay so concurrently running workspaces stop bleeding into each other's UI (Phase 3 of lazy multi-workspace agents, building on the Phase 1 relay pinning and Phase 2 lazy activation): - Add agentRelayScope helpers: a frontend mirror of the backend relay normalizer plus agentBelongsToRelay / partitionAgentsByRelay / hasRunningAgentInCommunity. A blank pin follows the active community (same defense-in-depth fallback as effective_agent_relay_url), and a missing provider/community degrades to unscoped rather than blanking every surface. - Scope the useManagedAgentActions list — and the bulk stop, start/ stop/delete lookups, and presence derived from it — to the active relay via a new lenient useActiveRelayUrl hook. Persona delete keeps counting instances against the unscoped record set since deleting a persona removes instances in every community. - Gate the 5s managed-agents liveness poll on a running agent in this community; agents running in other communities render no process state here, so they no longer keep the poll alive. - Hold auto-restart for agents pinned to other communities — their working/observer signals are read from the active relay only, so a pure workspace switch must never fire a restart — and re-check the pin in the pre-fire re-fetch so a rebind cannot restart a foreign agent. - Scope mergeKnownAgentPubkeys' managed-agent source to the active relay; the relay-agent (kind:10100) source stays unfiltered. - Surface an "N agents running in other communities" line in the agents header so concurrent background agents stay discoverable. Tested: desktop pnpm test (2939 passed, incl. new agentRelayScope, knownAgentPubkeys, and autoRestartPolicy unit tests), tsc --noEmit, biome check, Playwright smoke project (537 passed, 5 failed; 2 of the failures passed on rerun and the other 3 — channels intro-scroll, video review mode, shared-compute empty state — reproduce on baseline HEAD without this change, so all 5 are pre-existing flakes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2e2babb969
commit
ee40613898
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
agentBelongsToRelay,
|
||||
hasRunningAgentInCommunity,
|
||||
normalizeRelayUrlForCompare,
|
||||
partitionAgentsByRelay,
|
||||
} from "./agentRelayScope.ts";
|
||||
|
||||
const RELAY_A = "ws://relay-a.example.com:3000";
|
||||
const RELAY_B = "wss://relay-b.example.com";
|
||||
|
||||
// ── normalizeRelayUrlForCompare ──────────────────────────────────────────────
|
||||
// Must agree with the Rust `normalize_relay_url` (desktop/src-tauri/src/relay.rs)
|
||||
// because record pins are stamped by the backend and compared here. These
|
||||
// vectors mirror the Rust unit tests.
|
||||
|
||||
test("normalize_stripsWhitespaceAndTrailingSlashes", () => {
|
||||
assert.equal(
|
||||
normalizeRelayUrlForCompare(" wss://relay.example.com// "),
|
||||
"wss://relay.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
test("normalize_lowercasesSchemeAndAuthority_preservesPathCase", () => {
|
||||
assert.equal(
|
||||
normalizeRelayUrlForCompare("WSS://Relay.Example.COM:3000/Path"),
|
||||
"wss://relay.example.com:3000/Path",
|
||||
);
|
||||
});
|
||||
|
||||
test("normalize_schemelessInput_passesThroughTrimmed", () => {
|
||||
assert.equal(normalizeRelayUrlForCompare("not-a-url/"), "not-a-url");
|
||||
});
|
||||
|
||||
// ── agentBelongsToRelay ──────────────────────────────────────────────────────
|
||||
|
||||
test("belongs_exactMatch", () => {
|
||||
assert.equal(agentBelongsToRelay(RELAY_A, RELAY_A), true);
|
||||
});
|
||||
|
||||
test("belongs_cosmeticDifferences_stillMatch", () => {
|
||||
// Trailing slash and scheme/host case must not split an agent from its
|
||||
// community — this is exactly what the shared normalizer exists for.
|
||||
assert.equal(
|
||||
agentBelongsToRelay("WS://RELAY-A.example.com:3000/", RELAY_A),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("belongs_differentRelay_doesNotMatch", () => {
|
||||
assert.equal(agentBelongsToRelay(RELAY_A, RELAY_B), false);
|
||||
});
|
||||
|
||||
test("belongs_blankPin_followsActiveCommunity", () => {
|
||||
// Defense-in-depth mirror of the backend's `effective_agent_relay_url`
|
||||
// blank fallback: a record that escaped stamping follows the visited
|
||||
// community instead of vanishing from every community.
|
||||
assert.equal(agentBelongsToRelay("", RELAY_A), true);
|
||||
assert.equal(agentBelongsToRelay(" ", RELAY_B), true);
|
||||
assert.equal(agentBelongsToRelay(undefined, RELAY_A), true);
|
||||
});
|
||||
|
||||
test("belongs_noActiveCommunityRelay_degradesToUnscoped", () => {
|
||||
assert.equal(agentBelongsToRelay(RELAY_A, null), true);
|
||||
assert.equal(agentBelongsToRelay(RELAY_A, ""), true);
|
||||
assert.equal(agentBelongsToRelay(RELAY_A, undefined), true);
|
||||
});
|
||||
|
||||
// ── partitionAgentsByRelay: the agent-list filter ────────────────────────────
|
||||
|
||||
test("partition_scopesListToActiveCommunity", () => {
|
||||
// Two communities' agents coexist after lazy activation; the list the
|
||||
// user sees in community A must contain only A's agents (plus blank-pin
|
||||
// strays), with B's surfaced only through the "other communities" count.
|
||||
const agents = [
|
||||
{ pubkey: "a1", relayUrl: RELAY_A },
|
||||
{ pubkey: "b1", relayUrl: RELAY_B },
|
||||
{ pubkey: "a2", relayUrl: `${RELAY_A}/` },
|
||||
{ pubkey: "stray", relayUrl: "" },
|
||||
];
|
||||
|
||||
const { inCommunity, other } = partitionAgentsByRelay(agents, RELAY_A);
|
||||
|
||||
assert.deepEqual(
|
||||
inCommunity.map((agent) => agent.pubkey),
|
||||
["a1", "a2", "stray"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
other.map((agent) => agent.pubkey),
|
||||
["b1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("partition_undefinedAgents_yieldsEmpty", () => {
|
||||
const { inCommunity, other } = partitionAgentsByRelay(undefined, RELAY_A);
|
||||
assert.deepEqual(inCommunity, []);
|
||||
assert.deepEqual(other, []);
|
||||
});
|
||||
|
||||
// ── hasRunningAgentInCommunity: the polling gate ─────────────────────────────
|
||||
|
||||
test("pollingGate_runningAgentInOtherCommunity_doesNotPoll", () => {
|
||||
// A workspace switch leaves the previous community's agents running; they
|
||||
// must not keep this community's 5s liveness poll alive.
|
||||
const agents = [
|
||||
{ relayUrl: RELAY_B, status: "running" },
|
||||
{ relayUrl: RELAY_A, status: "stopped" },
|
||||
];
|
||||
|
||||
assert.equal(hasRunningAgentInCommunity(agents, RELAY_A), false);
|
||||
});
|
||||
|
||||
test("pollingGate_runningAgentInThisCommunity_polls", () => {
|
||||
const agents = [
|
||||
{ relayUrl: RELAY_B, status: "running" },
|
||||
{ relayUrl: RELAY_A, status: "running" },
|
||||
];
|
||||
|
||||
assert.equal(hasRunningAgentInCommunity(agents, RELAY_A), true);
|
||||
});
|
||||
|
||||
test("pollingGate_blankPinRunningAgent_polls", () => {
|
||||
assert.equal(
|
||||
hasRunningAgentInCommunity([{ relayUrl: "", status: "running" }], RELAY_A),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Relay-scoping for managed agents. Every managed-agent record is pinned to
|
||||
* its home relay (`ManagedAgent.relayUrl`, stamped by the backend at create),
|
||||
* and the desktop UI presents agents per community — so every surface that
|
||||
* lists, counts, or acts on managed agents must scope to the active
|
||||
* community's relay through these helpers. Agents pinned to other relays
|
||||
* keep running in the background; they are just not "in" this community.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Canonical form of a relay URL for identity comparisons — NOT for
|
||||
* connecting. Frontend mirror of `normalize_relay_url` in
|
||||
* `desktop/src-tauri/src/relay.rs`; the two must agree because record pins
|
||||
* are stamped by the backend and compared here: trim, strip trailing
|
||||
* slashes, lowercase scheme + authority (case-insensitive per RFC 3986),
|
||||
* preserve any path or query case-sensitively.
|
||||
*
|
||||
* Distinct from `normalizeRelayUrl` in `communityStorage.ts` (input
|
||||
* canonicalisation: prepends `wss://`) and in `selfProfileStorage.ts`
|
||||
* (storage keys: lowercases the whole URL, path included).
|
||||
*/
|
||||
export function normalizeRelayUrlForCompare(url: string): string {
|
||||
const trimmed = url.trim().replace(/\/+$/, "");
|
||||
const schemeEnd = trimmed.indexOf("://");
|
||||
if (schemeEnd === -1) {
|
||||
return trimmed;
|
||||
}
|
||||
const scheme = trimmed.slice(0, schemeEnd);
|
||||
const rest = trimmed.slice(schemeEnd + "://".length);
|
||||
const pathStart = rest.indexOf("/");
|
||||
const authority = pathStart === -1 ? rest : rest.slice(0, pathStart);
|
||||
const path = pathStart === -1 ? "" : rest.slice(pathStart);
|
||||
return `${scheme.toLowerCase()}://${authority.toLowerCase()}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an agent record belongs to the given community relay.
|
||||
*
|
||||
* A blank pin follows the active community — the same defense-in-depth
|
||||
* fallback as the backend's `effective_agent_relay_url` for records that
|
||||
* escaped stamping. A blank/absent community relay (no provider, no active
|
||||
* community yet) degrades to unscoped rather than blanking every surface.
|
||||
*/
|
||||
export function agentBelongsToRelay(
|
||||
agentRelayUrl: string | null | undefined,
|
||||
communityRelayUrl: string | null | undefined,
|
||||
): boolean {
|
||||
const community = communityRelayUrl?.trim() ?? "";
|
||||
if (community === "") {
|
||||
return true;
|
||||
}
|
||||
const pinned = agentRelayUrl?.trim() ?? "";
|
||||
if (pinned === "") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
normalizeRelayUrlForCompare(pinned) ===
|
||||
normalizeRelayUrlForCompare(community)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split agents into those pinned to the active community's relay and the
|
||||
* rest. `inCommunity` drives the agents screen; `other` exists only for
|
||||
* cross-community affordances (the "running in other communities" count).
|
||||
*/
|
||||
export function partitionAgentsByRelay<T extends { relayUrl?: string | null }>(
|
||||
agents: readonly T[] | undefined,
|
||||
communityRelayUrl: string | null | undefined,
|
||||
): { inCommunity: T[]; other: T[] } {
|
||||
const inCommunity: T[] = [];
|
||||
const other: T[] = [];
|
||||
for (const agent of agents ?? []) {
|
||||
if (agentBelongsToRelay(agent.relayUrl, communityRelayUrl)) {
|
||||
inCommunity.push(agent);
|
||||
} else {
|
||||
other.push(agent);
|
||||
}
|
||||
}
|
||||
return { inCommunity, other };
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed-agents polling gate: poll only while an agent *in this
|
||||
* community* is running. Agents running in other communities don't render
|
||||
* process state on this community's surfaces, so they must not keep its
|
||||
* 5s liveness poll alive.
|
||||
*/
|
||||
export function hasRunningAgentInCommunity(
|
||||
agents: readonly { relayUrl?: string | null; status: string }[] | undefined,
|
||||
communityRelayUrl: string | null | undefined,
|
||||
): boolean {
|
||||
return (agents ?? []).some(
|
||||
(agent) =>
|
||||
agent.status === "running" &&
|
||||
agentBelongsToRelay(agent.relayUrl, communityRelayUrl),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { hasRunningAgentInCommunity } from "@/features/agents/agentRelayScope";
|
||||
import {
|
||||
connectAcpRuntime,
|
||||
discoverAcpAuthMethods,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
ensureChannelAgentPresetInChannel,
|
||||
provisionChannelManagedAgent,
|
||||
} from "@/features/agents/channelAgents";
|
||||
import { useActiveRelayUrl } from "@/features/communities/useCommunities";
|
||||
import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng";
|
||||
import {
|
||||
channelsQueryKey,
|
||||
@@ -310,6 +312,7 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) {
|
||||
}
|
||||
|
||||
export function useManagedAgentsQuery(options?: { enabled?: boolean }) {
|
||||
const activeRelayUrl = useActiveRelayUrl();
|
||||
return useQuery({
|
||||
enabled: options?.enabled ?? true,
|
||||
queryKey: managedAgentsQueryKey,
|
||||
@@ -321,10 +324,11 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) {
|
||||
// with no relay event to signal it, so this poll is the only liveness
|
||||
// path for them. When nothing is running there IS an event path —
|
||||
// `agents-data-changed` (control-plane changes) — so the idle branch
|
||||
// drops its poll entirely rather than falling back to 30s.
|
||||
return agents?.some((agent) => agent.status === "running")
|
||||
? 5_000
|
||||
: false;
|
||||
// drops its poll entirely rather than falling back to 30s. Scoped to
|
||||
// the active community's relay: agents left running in other
|
||||
// communities render no process state on this community's surfaces,
|
||||
// so they must not keep its poll alive.
|
||||
return hasRunningAgentInCommunity(agents, activeRelayUrl) ? 5_000 : false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,3 +34,60 @@ test("normalisesCaseAndWhitespace_dedupingAcrossSources", () => {
|
||||
|
||||
assert.deepEqual([...merged], [MANAGED]);
|
||||
});
|
||||
|
||||
// ── relay scoping ────────────────────────────────────────────────────────────
|
||||
|
||||
const RELAY_A = "ws://relay-a.example.com:3000";
|
||||
const RELAY_B = "wss://relay-b.example.com";
|
||||
|
||||
test("managedAgentPinnedToOtherRelay_excludedWhenScoped", () => {
|
||||
// With multiple communities' agents running concurrently, a managed agent
|
||||
// pinned to relay B is not an agent "in" community A — it must not enter
|
||||
// A's known-agent baseline just because it's locally managed.
|
||||
const merged = mergeKnownAgentPubkeys(
|
||||
[
|
||||
{ pubkey: MANAGED, relayUrl: RELAY_B },
|
||||
// Cosmetic URL differences must not split an agent from its community.
|
||||
{ pubkey: RELAY, relayUrl: `${RELAY_A}/` },
|
||||
],
|
||||
undefined,
|
||||
RELAY_A,
|
||||
);
|
||||
|
||||
assert.deepEqual([...merged], [RELAY]);
|
||||
});
|
||||
|
||||
test("blankPinnedRelay_followsActiveCommunity", () => {
|
||||
// Defense-in-depth mirror of the backend's blank-relay fallback: a record
|
||||
// that escaped stamping belongs to whichever community is visited.
|
||||
const merged = mergeKnownAgentPubkeys(
|
||||
[{ pubkey: MANAGED, relayUrl: "" }],
|
||||
undefined,
|
||||
RELAY_A,
|
||||
);
|
||||
|
||||
assert.deepEqual([...merged], [MANAGED]);
|
||||
});
|
||||
|
||||
test("relayAgents_neverRelayFiltered", () => {
|
||||
// Relay agents come from the active relay's own kind:10100 profiles —
|
||||
// already community-scoped at the source, so the merge must keep them
|
||||
// even when a managed-agent scope is in effect.
|
||||
const merged = mergeKnownAgentPubkeys(
|
||||
[{ pubkey: MANAGED, relayUrl: RELAY_B }],
|
||||
[{ pubkey: RELAY }],
|
||||
RELAY_A,
|
||||
);
|
||||
|
||||
assert.deepEqual([...merged], [RELAY]);
|
||||
});
|
||||
|
||||
test("noActiveRelay_degradesToUnscopedMerge", () => {
|
||||
const merged = mergeKnownAgentPubkeys(
|
||||
[{ pubkey: MANAGED, relayUrl: RELAY_B }],
|
||||
undefined,
|
||||
null,
|
||||
);
|
||||
|
||||
assert.deepEqual([...merged], [MANAGED]);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentBelongsToRelay } from "@/features/agents/agentRelayScope";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
/**
|
||||
@@ -5,15 +6,27 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
* normalised via `normalizePubkey` so membership checks work against
|
||||
* normalised pubkeys.
|
||||
*
|
||||
* Structurally typed on `{ pubkey }` so node unit tests don't need to build
|
||||
* full `ManagedAgent`/`RelayAgent` values.
|
||||
* Managed agents are scoped to `activeRelayUrl` (the active community's
|
||||
* relay): the baseline answers "is this pubkey an agent *in this
|
||||
* community*", and a locally managed agent pinned to another community's
|
||||
* relay is not — it neither posts here nor appears in this community's
|
||||
* directory. An agent genuinely present on both relays is still covered by
|
||||
* the relay-agent source (kind:10100 from the active relay), which is never
|
||||
* relay-filtered. Omitting `activeRelayUrl` degrades to the unscoped merge.
|
||||
*
|
||||
* Structurally typed on `{ pubkey, relayUrl? }` so node unit tests don't
|
||||
* need to build full `ManagedAgent`/`RelayAgent` values.
|
||||
*/
|
||||
export function mergeKnownAgentPubkeys(
|
||||
managedAgents: readonly { pubkey: string }[] | undefined,
|
||||
managedAgents:
|
||||
| readonly { pubkey: string; relayUrl?: string | null }[]
|
||||
| undefined,
|
||||
relayAgents: readonly { pubkey: string }[] | undefined,
|
||||
activeRelayUrl?: string | null,
|
||||
): ReadonlySet<string> {
|
||||
const pubkeys = new Set<string>();
|
||||
for (const agent of managedAgents ?? []) {
|
||||
if (!agentBelongsToRelay(agent.relayUrl, activeRelayUrl)) continue;
|
||||
pubkeys.add(normalizePubkey(agent.pubkey));
|
||||
}
|
||||
for (const agent of relayAgents ?? []) {
|
||||
|
||||
@@ -23,6 +23,7 @@ function greenInputs(overrides = {}) {
|
||||
workingSource: "none",
|
||||
connected: true,
|
||||
isLocalBackend: true,
|
||||
inActiveCommunity: true,
|
||||
isRunning: true,
|
||||
edgeConsumed: false,
|
||||
quiescentForMs: AUTO_RESTART_QUIESCENCE_MS,
|
||||
@@ -55,6 +56,7 @@ const NEVER_FIRE_ROWS = [
|
||||
["typing source alone defers", { workingSource: "typing" }],
|
||||
["observer relay not connected", { connected: false }],
|
||||
["remote backend", { isLocalBackend: false }],
|
||||
["agent pinned to another community's relay", { inActiveCommunity: false }],
|
||||
["agent not running", { isRunning: false }],
|
||||
[
|
||||
"edge already consumed (one attempt per rising edge)",
|
||||
@@ -72,6 +74,30 @@ for (const [label, overrides] of NEVER_FIRE_ROWS) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── workspace switch ─────────────────────────────────────────────────────────
|
||||
|
||||
test("a pure workspace switch never fires a restart", () => {
|
||||
// Switching communities leaves the previous community's agents running,
|
||||
// and their working/observer signals are read from the ACTIVE relay only —
|
||||
// so from the new community they read as idle even mid-turn. Even with a
|
||||
// fully elapsed quiescence window and every other gate green, an agent
|
||||
// pinned to another community's relay must hold, whatever the (blind)
|
||||
// connected/working signals claim.
|
||||
for (const overrides of [
|
||||
{},
|
||||
{ connected: true, working: false, workingSource: "none" },
|
||||
{ quiescentForMs: AUTO_RESTART_QUIESCENCE_MS * 10 },
|
||||
]) {
|
||||
assert.equal(
|
||||
decideAutoRestart(
|
||||
greenInputs({ ...overrides, inActiveCommunity: false }),
|
||||
),
|
||||
"hold",
|
||||
"an out-of-community fire is a kill decided on blind data",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── the quiescence window ───────────────────────────────────────────────────
|
||||
|
||||
test("arms (does not fire) before the window elapses", () => {
|
||||
|
||||
@@ -33,6 +33,13 @@ export type AutoRestartInputs = {
|
||||
connected: boolean;
|
||||
/** Only local agents can be restarted by this loop. */
|
||||
isLocalBackend: boolean;
|
||||
/** Whether the agent is pinned to the active community's relay. Agents
|
||||
* from other communities keep running after a workspace switch, but their
|
||||
* working/observer signals are read from the ACTIVE relay only — they
|
||||
* look idle and disconnected here regardless of what they're actually
|
||||
* doing, so any decision about them would be made on blind data. A pure
|
||||
* workspace switch must never fire a restart. */
|
||||
inActiveCommunity: boolean;
|
||||
/** Agent process status from the summary ("running" required). */
|
||||
isRunning: boolean;
|
||||
/** Edge-trigger state: true when this needsRestart rising edge has
|
||||
@@ -58,6 +65,7 @@ export function decideAutoRestart(
|
||||
workingSource,
|
||||
connected,
|
||||
isLocalBackend,
|
||||
inActiveCommunity,
|
||||
isRunning,
|
||||
edgeConsumed,
|
||||
quiescentForMs,
|
||||
@@ -67,6 +75,7 @@ export function decideAutoRestart(
|
||||
if (!autoRestartEnabled) return "hold";
|
||||
if (!needsRestart) return "hold";
|
||||
if (!isLocalBackend) return "hold";
|
||||
if (!inActiveCommunity) return "hold";
|
||||
if (!isRunning) return "hold";
|
||||
if (!connected) return "hold";
|
||||
// Any working signal — observer OR typing — defers. `working` and
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
managedAgentsQueryKey,
|
||||
useManagedAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { useActiveRelayUrl } from "@/features/communities/useCommunities";
|
||||
import {
|
||||
startManagedAgent,
|
||||
stopManagedAgent,
|
||||
} from "@/shared/api/tauriManagedAgents";
|
||||
import { listManagedAgents } from "@/shared/api/tauri";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { agentBelongsToRelay } from "../agentRelayScope";
|
||||
import { getAgentObserverSnapshot } from "../observerRelayStore";
|
||||
import { getAgentWorkingState } from "../agentWorkingSignal";
|
||||
import {
|
||||
@@ -36,6 +38,7 @@ const POLICY_TICK_MS = 15_000;
|
||||
export function useAutoRestartPolicy() {
|
||||
const queryClient = useQueryClient();
|
||||
const agents: ManagedAgent[] | undefined = useManagedAgentsQuery().data;
|
||||
const activeRelayUrl = useActiveRelayUrl();
|
||||
const edgesRef = React.useRef(new Map<string, AutoRestartEdgeState>());
|
||||
const inFlightRef = React.useRef(new Set<string>());
|
||||
const [, setTick] = React.useState(0);
|
||||
@@ -71,6 +74,7 @@ export function useAutoRestartPolicy() {
|
||||
workingSource: working.source,
|
||||
connected: observer.connectionState === "open",
|
||||
isLocalBackend: agent.backend.type === "local",
|
||||
inActiveCommunity: agentBelongsToRelay(agent.relayUrl, activeRelayUrl),
|
||||
isRunning,
|
||||
edgeConsumed: edge.consumed,
|
||||
quiescentForMs: edge.armedAt === null ? 0 : now - edge.armedAt,
|
||||
@@ -97,13 +101,16 @@ export function useAutoRestartPolicy() {
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
// Pre-fire re-fetch: shrink the stale-decision window to ~0.
|
||||
// Pre-fire re-fetch: shrink the stale-decision window to ~0. The
|
||||
// relay pin is re-checked too — a rebind (community relay edit)
|
||||
// between decision and fire must not restart a foreign agent.
|
||||
const fresh = await listManagedAgents();
|
||||
const current = fresh.find((a) => a.pubkey === agent.pubkey);
|
||||
if (
|
||||
!current?.needsRestart ||
|
||||
!current.autoRestartOnConfigChange ||
|
||||
current.status !== "running" ||
|
||||
!agentBelongsToRelay(current.relayUrl, activeRelayUrl) ||
|
||||
getAgentWorkingState(agent.pubkey).source !== "none"
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -136,7 +136,21 @@ export function AgentsView() {
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
description="Set up and manage your agents."
|
||||
description={
|
||||
<>
|
||||
<span>Set up and manage your agents.</span>
|
||||
{agents.otherCommunityRunningCount > 0 ? (
|
||||
<span
|
||||
className="mt-1 block text-xs"
|
||||
data-testid="other-community-running-count"
|
||||
>
|
||||
{agents.otherCommunityRunningCount === 1
|
||||
? "1 agent running in other communities."
|
||||
: `${agents.otherCommunityRunningCount} agents running in other communities.`}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
title="Agents"
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -323,7 +337,10 @@ export function AgentsView() {
|
||||
{personas.personaToDelete ? (
|
||||
<PersonaDeleteDialog
|
||||
instanceCount={
|
||||
(agents.managedAgents ?? []).filter(
|
||||
// Deleting a persona removes its instances in EVERY community,
|
||||
// so count against the unscoped record set — the community-scoped
|
||||
// list would understate what the delete is about to do.
|
||||
(agents.allManagedAgents ?? []).filter(
|
||||
(a) => a.personaId === personas.personaToDelete?.id,
|
||||
).length
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
useStopManagedAgentMutation,
|
||||
useDeleteManagedAgentMutation,
|
||||
} from "@/features/agents/hooks";
|
||||
import { partitionAgentsByRelay } from "@/features/agents/agentRelayScope";
|
||||
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { useActiveRelayUrl } from "@/features/communities/useCommunities";
|
||||
import { usePresenceQuery } from "@/features/presence/hooks";
|
||||
import type {
|
||||
AgentPersona,
|
||||
@@ -75,16 +77,33 @@ export function useManagedAgentActions() {
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
// The agents screen is presented per community ("Agents in this
|
||||
// community"), so scope the list — and every action derived from it (bulk
|
||||
// stop, start/stop/delete lookups, presence) — to records pinned to the
|
||||
// active community's relay. Agents pinned elsewhere keep running; they
|
||||
// surface only through `otherCommunityRunningCount`.
|
||||
const activeRelayUrl = useActiveRelayUrl();
|
||||
const allManagedAgents = managedAgentsQuery.data;
|
||||
const relayPartition = React.useMemo(
|
||||
() => partitionAgentsByRelay(allManagedAgents, activeRelayUrl),
|
||||
[allManagedAgents, activeRelayUrl],
|
||||
);
|
||||
|
||||
const managedAgents = React.useMemo(
|
||||
() =>
|
||||
[...(managedAgentsQuery.data ?? [])].sort((left, right) => {
|
||||
[...relayPartition.inCommunity].sort((left, right) => {
|
||||
const activeScore = (s: string) =>
|
||||
s === "running" || s === "deployed" ? 1 : 0;
|
||||
const diff = activeScore(right.status) - activeScore(left.status);
|
||||
if (diff !== 0) return diff;
|
||||
return left.name.localeCompare(right.name);
|
||||
}),
|
||||
[managedAgentsQuery.data],
|
||||
[relayPartition],
|
||||
);
|
||||
|
||||
const otherCommunityRunningCount = React.useMemo(
|
||||
() => relayPartition.other.filter(isManagedAgentActive).length,
|
||||
[relayPartition],
|
||||
);
|
||||
// Observer ingestion is owner-global (useAgentObserverIngestion in
|
||||
// AppShell); this hook only reads derived state.
|
||||
@@ -395,6 +414,10 @@ export function useManagedAgentActions() {
|
||||
managedAgentLogQuery,
|
||||
managedPresenceQuery,
|
||||
managedAgents,
|
||||
/** Unscoped record set — for cross-community concerns only (e.g. how
|
||||
* many instances a persona delete would remove across ALL communities). */
|
||||
allManagedAgents,
|
||||
otherCommunityRunningCount,
|
||||
managedPubkeys,
|
||||
channelIdToName,
|
||||
channelsByPubkey,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useRelayAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { mergeKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys";
|
||||
import { useActiveRelayUrl } from "@/features/communities/useCommunities";
|
||||
import { useStableSet } from "@/shared/hooks/useStableReference";
|
||||
|
||||
const EMPTY_KNOWN_AGENT_PUBKEYS: ReadonlySet<string> = new Set();
|
||||
@@ -41,10 +42,13 @@ export function KnownAgentPubkeysProvider({
|
||||
}) {
|
||||
const managedAgents = useManagedAgentsQuery().data;
|
||||
const relayAgents = useRelayAgentsQuery().data;
|
||||
// Scope managed agents to the active community's relay: another
|
||||
// community's still-running agents are not agents "in" this community.
|
||||
const activeRelayUrl = useActiveRelayUrl();
|
||||
|
||||
const merged = React.useMemo(
|
||||
() => mergeKnownAgentPubkeys(managedAgents, relayAgents),
|
||||
[managedAgents, relayAgents],
|
||||
() => mergeKnownAgentPubkeys(managedAgents, relayAgents, activeRelayUrl),
|
||||
[managedAgents, relayAgents, activeRelayUrl],
|
||||
);
|
||||
const stable = useStableSet(merged);
|
||||
|
||||
|
||||
@@ -112,6 +112,19 @@ export function useCommunities(): UseCommunitiesReturn {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient read of the active community's relay URL — the key that scopes
|
||||
* managed agents to a community (records are pinned to their home relay).
|
||||
* Unlike `useCommunities`, this returns `null` instead of throwing outside
|
||||
* `CommunitiesProvider`, so relay-scoping consumers (agent lists, polling
|
||||
* gates, the auto-restart policy) degrade to unscoped in provider-less
|
||||
* mounts rather than crashing.
|
||||
*/
|
||||
export function useActiveRelayUrl(): string | null {
|
||||
const ctx = useContext(CommunitiesContext);
|
||||
return ctx?.activeCommunity?.relayUrl ?? null;
|
||||
}
|
||||
|
||||
function useCommunitiesInternal(): UseCommunitiesReturn {
|
||||
const [communities, setCommunitiesState] =
|
||||
useState<Community[]>(loadCommunities);
|
||||
|
||||
Reference in New Issue
Block a user