From 7ab14aef86928157d530e19930099f9aa3547014 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 28 Oct 2026 16:36:12 -0400 Subject: [PATCH 01/32] fix(agents): let watches hear public channels the agent is not in A watch woke only for channels the agent was a member of, and heard live only channels the owner had joined. A public channel, which anyone may read, was silent unless both had joined it. Listening now follows read access: a watch hears a public channel too. Delivery still needs membership, so a watch's turn never goes to a channel the agent cannot post in. The relay session gains attend(), which keeps the public channels agents' watches name in the live routes, after joined and opened channels and in the room they leave. Their metadata is read first, only into a ready channel list. They are not joined, listed or counted unread. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/relay-queries.md | 5 +- src/features/agents/runner.ts | 10 ++- src/features/agents/service-testing.ts | 27 ++++++- src/features/agents/service.test.ts | 72 ++++++++++++++++- src/features/agents/service.ts | 59 ++++++++++++-- src/features/relay/attend.session.test.ts | 96 +++++++++++++++++++++++ src/features/relay/session.ts | 73 ++++++++++++++++- 7 files changed, 327 insertions(+), 15 deletions(-) create mode 100644 src/features/relay/attend.session.test.ts diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 98baf0df40..fea12e4c9c 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -321,7 +321,10 @@ DM type grants nonmember reading. Signed membership still owns the joined roster `channels.list()` remains joined-only. Opening a nonmember destination revalidates its metadata and reuses the existing channel window/exact thread owners. It does not join, persist a public head, warm all search results, or add sidebar/unread -membership. Only demanded retained previews enter the existing live interest set. +membership. Only demanded retained previews enter the existing live interest set, +and the public channels agents' watches name (`session.attend`), which the session +reads metadata for first. Attended channels come after joined and demanded ones, +in the capacity those leave, and are never joined, listed or counted unread. Shared composers and reactions remain unavailable for nonmembers; message helpers and workflows do not acquire write eligibility from public metadata. The relay remains the final write authority, including low-level outbox intent. diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 756905e358..1a3506091a 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -412,6 +412,10 @@ export type RunnerHost = { /** Whether the agent is in the event's channel, or undefined while its * roster is only the one saved on disk and the relay has not confirmed it. */ member(agent: Agent, event: Pick): boolean | undefined; + /** Whether the agent may read the event's channel: it is in it, or the + * channel is public; undefined as for `member`. Only a member may be + * delivered to. */ + reads(agent: Agent, event: Pick): boolean | undefined; /** Whether the event's channel is a DM between the agent and one other. */ dm(agent: Agent, event: RelayEvent): boolean; /** While the owner is still starting this chat, settles once it can take @@ -2079,9 +2083,9 @@ export class AgentRunners { this.heldForStartup = true; return { triggers: [], held: true, discarded }; } - const member = this.host.member(agent, event); - if (member === undefined) return { triggers: [], held: true }; - if (!member) return { triggers: [] }; + const reads = this.host.reads(agent, event); + if (reads === undefined) return { triggers: [], held: true }; + if (!reads) return { triggers: [] }; const destinations = matches.map(({ watch }) => watch.channel ? this.host.member(agent, { tags: [["h", watch.channel]] }) diff --git a/src/features/agents/service-testing.ts b/src/features/agents/service-testing.ts index e81b5de365..5573ab4ec3 100644 --- a/src/features/agents/service-testing.ts +++ b/src/features/agents/service-testing.ts @@ -20,6 +20,10 @@ export const other = "b".repeat(64); export const bot = "c".repeat(64); export const sibling = "d".repeat(64); export const channel = "0b8e2a3c-1d4f-4a5b-8c6d-7e8f9a0b1c2d"; +/** A public channel the owner is in and the agent is not. */ +export const lobby = "1c9f3b4d-2e5a-4b6c-9d7e-8f9a0b1c2d3e"; +/** A public channel neither is in. */ +export const plaza = "2d0a4c5e-3f6b-4c7d-8e8f-9a0b1c2d3e4f"; export const dm = "dm-with-bot"; export const groupDm = "dm-with-bot-and-other"; export const event = (id: string, patch: Partial = {}) => @@ -40,17 +44,21 @@ export function fakeRelay(origin = "https://relay.example.test") { const replays = new Set(); const changes = new Set<() => void>(); const rosterChanges = new Set<() => void>(); + const attended = new Set(); // The agent is in `channel`, `dm` and `groupDm`; `private` is the owner's // alone, and `dm-others` and `dm-unknown` are DMs it is not known to be in // (discovery emits an empty roster, never a missing one). `saved` has only a - // roster loaded from disk, not yet confirmed by the relay. + // roster loaded from disk, not yet confirmed by the relay. `lobby` is + // public, and the agent is not in it. const rows: { id: string; name?: string; channelType?: "dm"; + visibility?: "public"; members: string[]; cached?: true; }[] = [ + { id: lobby, name: "lobby", visibility: "public", members: [viewer] }, { id: channel, name: "general", members: [viewer, bot] }, { id: "private", name: "private", members: [viewer] }, { id: dm, channelType: "dm", members: [viewer, bot] }, @@ -139,7 +147,23 @@ export function fakeRelay(origin = "https://relay.example.test") { rosterChanges.add(listener); return () => rosterChanges.delete(listener); }, + // Public, and neither the owner nor the agent is in it. + get: (id: string) => + id === plaza + ? { + id, + name: id, + visibility: "public", + readOnly: true, + members: [other], + } + : undefined, }, + /** Each list of channels an `attend` keeps live, while it does. */ + attend: vi.fn((ids: readonly string[]) => { + attended.add(ids); + return () => attended.delete(ids); + }), live: { // Each channel of the list is live while connected, unless `unrouted`. snapshot: () => ({ @@ -237,6 +261,7 @@ export function fakeRelay(origin = "https://relay.example.test") { for (const listener of rosterChanges) listener(); }, archives, + attended, listed, unrouted, history, diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 78d05817ca..249a47ccd5 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -90,7 +90,9 @@ import { fakeNative, fakeRelay, groupDm, + lobby, other, + plaza, sibling, steps, viewer, @@ -339,6 +341,7 @@ async function setup({ archives: fake.archives, session: fake.session, history: fake.history, + attended: fake.attended, authorize, plugin, watching, @@ -8689,7 +8692,7 @@ it("wakes nothing from the time its watch was off, also when it is turned on aft } }); -it("wakes a watch only for channels the agent is in, even when it watches all", async () => { +it("wakes a watch only for channels the agent may read, even when it watches all", async () => { const { service, run, emit } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); await service.save(bot, { attentionEnabled: true }); @@ -8712,15 +8715,76 @@ it("wakes a watch only for channels the agent is in, even when it watches all", event("unknown", { tags: [["h", "elsewhere"]] }), event("bare", { kind: 1, tags: [] }), event("open"), + // Public channels anyone may read, though the agent is in neither. + event("lobby", { tags: [["h", lobby]] }), + event("plaza", { tags: [["h", plaza]] }), ], }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(3)); + await settle(); + expect(run).toHaveBeenCalledTimes(3); + expect(woke(run)).toEqual(["open", "lobby", "plaza"]); +}); + +it("listens in a public channel it is not in, but delivers a watch's turn only where it is a member", async () => { + const { service, run, emit } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { attentionEnabled: true }); + const watch = { + type: "event", + interest_id: "default", + enabled: true, + since: 1, + channels: [lobby], + kinds: [9], + } as const; + await service.save(bot, { + attention: { + "watch/here": { ...watch, channel }, + "watch/there": { ...watch, channel: lobby }, + }, + }); + emit({ events: [event("hello", { tags: [["h", lobby]] })] }); await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); await settle(); expect(run).toHaveBeenCalledTimes(1); - expect(run.mock.calls[0]?.[0].trigger).toMatchObject({ - slug: "watch/everything", - event: { id: "open".padEnd(64, "0") }, + // An addressed watch's turn runs in its channel. + const trigger = run.mock.calls[0]?.[0].trigger as { + channel?: string; + text?: string; + }; + expect(trigger.channel).toBe(channel); + expect(trigger.text).toContain('watch "here"'); +}); + +it("keeps the channels its enabled watches name live while attention is on", async () => { + const { service, attended } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const watch = { + type: "event", + interest_id: "default", + enabled: true, + since: 1, + kinds: [9], + } as const; + await service.save(bot, { + attention: { + "watch/plaza": { ...watch, channels: [plaza, channel] }, + "watch/all": { ...watch, channels: "all" }, + "watch/off": { ...watch, enabled: false, channels: [lobby] }, + }, }); + await settle(); + expect([...attended]).toEqual([]); + await service.save(bot, { attentionEnabled: true }); + await settle(); + // A watch of "all" hears what the session listens to already. + expect([...attended].map((ids) => [...ids].sort())).toEqual([ + [channel, plaza].sort(), + ]); + await service.save(bot, { attentionEnabled: false }); + await settle(); + expect([...attended]).toEqual([]); }); it("answers an unmentioned one-to-one DM as conversation even when a watch matches it", async () => { diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 8b11f197c7..351c252844 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -1545,6 +1545,8 @@ type Binding = { controller: AbortController; stopLive: () => void; connection: Connection; + /** The channels its agents' watches name that it keeps live, and its stop. */ + attending?: Readonly<{ key: string; stop: () => void }>; /** Its config support while no config channels are up: being checked, the * check failed (and when), or the relay has none. */ support?: "checking" | "unsupported" | Readonly<{ failed: number }>; @@ -1931,6 +1933,7 @@ export class AgentsService extends Service implements Agents { ready: (key) => this.ready(key), agent: (key) => this.agent(key), member: (agent, event) => this.member(agent, event), + reads: (agent, event) => this.reads(agent, event), dm: (agent, event) => this.dm(agent, event), starting: (agent, channelId) => this.bindingOf(agent)?.session.channelWrites.starting(channelId), @@ -5732,6 +5735,7 @@ export class AgentsService extends Service implements Agents { this.edits.delete(origin); entry?.stop(); entry?.binding?.stopLive(); + entry?.binding?.attending?.stop(); entry?.binding?.controller.abort(); this.communityConfigs.get(origin)?.dispose(); this.communityConfigs.delete(origin); @@ -5759,6 +5763,7 @@ export class AgentsService extends Service implements Agents { entry.binding?.scope !== live?.scope; if (changed) { entry.binding?.stopLive(); + entry.binding?.attending?.stop(); entry.binding?.controller.abort(); this.listings.delete(origin); this.edits.delete(origin); @@ -5962,6 +5967,7 @@ export class AgentsService extends Service implements Agents { ); this.announce(); this.runners.catchUp(false); + this.attend(running); for (const [origin, config] of this.communityConfigs) void config.attach( running @@ -5980,6 +5986,32 @@ export class AgentsService extends Service implements Agents { ); } + /** Keeps live, in each community, the channels that its running agents' + * enabled watches name, so a watch also hears a public channel the owner + * has not joined. A watch of "all" hears what the session already listens + * to. */ + private attend(running: readonly Agent[]) { + const wanted = new Map>(); + for (const { binding } of this.bound.values()) + if (binding) wanted.set(binding, new Set()); + for (const agent of running) { + const channels = this.bindingOf(agent); + const ids = channels && wanted.get(channels); + if (!ids || !agent.attentionEnabled) continue; + for (const { value } of Object.values(agent.attention)) + if (value.type === "event" && value.enabled && value.channels !== "all") + for (const id of value.channels) ids.add(id); + } + for (const [binding, ids] of wanted) { + const key = JSON.stringify([...ids].sort()); + if (binding.attending?.key === key) continue; + binding.attending?.stop(); + delete binding.attending; + if (ids.size) + binding.attending = { key, stop: binding.session.attend([...ids]) }; + } + } + /** Tells each community which agents this copy runs there: online every * minute while it does, and offline once it stops, unless another copy took * over. Offline is best effort; the relay lets a lapsed online expire. */ @@ -6074,11 +6106,10 @@ export class AgentsService extends Service implements Agents { ); } /** Whether the agent is in the event's channel, by its relay-signed roster. - * The stream is the owner's, so a watch, even of "all", sees only what the - * agent itself could read; an event with no channel, or whose roster is - * unknown, wakes no watch. A roster only loaded from disk decides nothing - * (undefined): the agent may have been removed or added since it was saved, - * so the event waits until the relay confirms it. */ + * An event with no channel, or whose roster is unknown, is in none. A + * roster only loaded from disk decides nothing (undefined): the agent may + * have been removed or added since it was saved, so the event waits until + * the relay confirms it. */ private member( agent: Agent, event: Pick, @@ -6087,6 +6118,24 @@ export class AgentsService extends Service implements Agents { if (summary?.cached) return undefined; return !!summary?.members?.includes(agent.pubkey); } + /** Whether the agent may read the event's channel: it is in it, or the + * channel is public, as its relay-signed metadata says, and neither hidden + * nor a DM, so the relay lets anyone read it. The stream is the owner's, + * so a watch, even of "all", hears only what the agent itself could read. */ + private reads( + agent: Agent, + event: Pick, + ): boolean | undefined { + const summary = this.channelOf(agent, event); + if ( + !summary?.cached && + summary?.visibility === "public" && + !summary.hidden && + summary.channelType !== "dm" + ) + return true; + return this.member(agent, event); + } /** Whether the event's channel is a DM of exactly the agent and one other, * by the same roster. A group DM, or one whose roster is unknown, is not. */ private dm(agent: Agent, event: RelayEvent) { diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts new file mode 100644 index 0000000000..226c34677a --- /dev/null +++ b/src/features/relay/attend.session.test.ts @@ -0,0 +1,96 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createRelaySession } from "./session"; +import type { RelayEvent } from "./events"; +import type { LiveCallbacks } from "./live"; +import { flush, keypair, scriptedTransport, signed } from "./testing"; + +const viewer = keypair(), + relay = keypair(), + other = keypair(); +const owners: ReturnType[] = []; +afterEach(() => { + for (const owner of owners.splice(0)) owner.dispose(); +}); +const OPEN = "33333333-3333-4333-8333-333333333333"; +const CLOSED = "44444444-4444-4444-8444-444444444444"; + +function harness() { + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + const update = + vi.fn<(ids: readonly string[], joined?: readonly string[]) => void>(); + let live!: LiveCallbacks; + const owner = createRelaySession({ + ...wire.transport, + scope: "https://one.example", + subscribe(callbacks) { + live = callbacks; + return { update, retry() {}, dispose() {} }; + }, + }); + owners.push(owner); + /** Connects, and the viewer's roster read finds no channel. */ + const discover = async () => { + live.state({ status: "connected", routes: [] }); + live.established(); + await vi.waitFor(() => expect(wire.pending).toHaveLength(1)); + wire.next().respond([]); + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + }; + return { owner, wire, update, discover }; +} +/** The relay's signed metadata and roster of a channel only `other` is in. */ +const channel = (id: string, visibility: "public" | "private") => [ + signed(relay, { + kind: 39000, + content: "", + created_at: 10, + tags: [["d", id], ["name", id.slice(0, 4)], [visibility]], + }), + signed(relay, { + kind: 39002, + content: "", + created_at: 10, + tags: [ + ["d", id], + ["p", other.pubkey], + ], + }), +]; +const asks = (filters: readonly { "#d"?: readonly string[] }[], id: string) => + filters.some((filter) => filter["#d"]?.includes(id)); + +it("keeps an attended public channel live once its metadata is read, never a private one, and drops it when stopped", async () => { + const h = harness(); + const live = () => h.update.mock.lastCall?.[0] ?? []; + const joined = () => h.update.mock.lastCall?.[1] ?? []; + const stop = h.owner.session.attend([OPEN, CLOSED, "not a channel id"]); + // Unknown until the relay's metadata is read, which waits for discovery: + // a lookup must not stand in for it. + await flush(); + expect(live()).not.toContain(OPEN); + expect(h.wire.pending.some((entry) => asks(entry.filters, OPEN))).toBe(false); + await h.discover(); + await vi.waitFor(() => + expect(h.wire.pending.some((entry) => asks(entry.filters, OPEN))).toBe( + true, + ), + ); + const read = h.wire.pending.find((entry) => asks(entry.filters, OPEN)); + read?.respond([ + ...channel(OPEN, "public"), + ...channel(CLOSED, "private"), + ] as RelayEvent[]); + await vi.waitFor(() => expect(live()).toContain(OPEN)); + await flush(); + // Live, though not joined or listed; a private channel the viewer is not + // in is not live. + expect(joined()).not.toContain(OPEN); + expect(live()).not.toContain(CLOSED); + expect( + h.owner.session.channels.list().channels.map(({ id }) => id), + ).not.toContain(OPEN); + stop(); + expect(live()).not.toContain(OPEN); +}); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index d97470a268..62817e246b 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -92,7 +92,12 @@ import { import { PRODUCT_FEEDBACK_KIND } from "./product-feedback"; import { attemptEnded } from "./withdraw"; import type { ReadTransport } from "./transport"; -import type { LiveGlobal, LiveSnapshot, LiveSubscription } from "./live"; +import { + LIVE_CHANNEL_CAPACITY, + type LiveGlobal, + type LiveSnapshot, + type LiveSubscription, +} from "./live"; import { hasTag, type ReadFilter, @@ -1281,6 +1286,32 @@ export function createRelaySession( const followed = new Set(); /** Each follow's check, after an access change, for channels it lost. */ const followRevocations = new Set<() => void>(); + /** Channels each `attend` keeps live for agents' watches, its own list. */ + const attended = new Set(); + /** Attended channels whose metadata was read for them, or is being read. */ + const attendChecked = new Set(); + /** Reads the metadata of attended channels not known yet, once each. Only + * into a ready list: a lookup may extend one, never stand in for initial + * discovery. A failed read is not tried again until a later `attend`. */ + const resolveAttended = () => { + if ( + closed || + !channels.queries.resolve || + channels.queries.list().status !== "ready" + ) + return; + const unknown = [...new Set([...attended].flat())].filter( + (id) => !attendChecked.has(id) && !channels.queries.get?.(id), + ); + for (const id of unknown) attendChecked.add(id); + for (let start = 0; start < unknown.length; start += 128) + void channels.queries + .resolve(unknown.slice(start, start + 128), { + signal: lifetime.signal, + }) + .catch(() => {}) + .finally(updateInterests); + }; const liveListeners = new Set<() => void>(); let liveSnapshot: LiveSnapshot = Object.freeze({ status: transport?.subscribe ? "connecting" : "unavailable", @@ -2257,6 +2288,27 @@ export function createRelaySession( incomingListeners.delete(listener); }; }, + /** Keeps `channelIds` in the live routes while attended, for agents' + * watches: a readable public channel the viewer has not joined too, whose + * metadata is read first when unknown. It is not joined, listed or counted + * unread, as an opened preview is not. Its events reach `subscribeLive`. + * Joined and opened channels keep their room first; past it, the rest + * are left out. */ + attend(channelIds: readonly string[]): () => void { + if (closed) return () => {}; + const ids = [ + ...new Set( + channelIds.filter((id) => /^[a-zA-Z0-9_-]{1,128}$/.test(id)), + ), + ]; + // Unread or refused before, it is read again. + for (const id of ids) attendChecked.delete(id); + attended.add(ids); + updateInterests(); + return () => { + if (attended.delete(ids)) updateInterests(); + }; + }, /** Verified, access-filtered live-phase events of any kind, after reconciliation. * Never finite reads or local intent, except the viewer's own messages in * a Solo chat this device is starting: those arrive here before the relay @@ -3138,6 +3190,25 @@ export function createRelaySession( }), ]), ]; + // Channels agents' watches listen to come last, in the room the others + // leave (two globals may take some), so they never push one out. + const room = Math.max(0, LIVE_CHANNEL_CAPACITY - 2 - ids.length); + const listened = new Set(ids); + ids.push( + ...[...new Set([...attended].flat())] + .filter((id) => { + const channel = channels.queries.get?.(id); + return ( + !listened.has(id) && + !!channel && + channels.canAccess(id) && + !channel.cached && + !channel.pending + ); + }) + .slice(0, room), + ); + resolveAttended(); const wanted = new Set(ids); for (const id of catchups.keys()) if (!wanted.has(id)) catchups.delete(id); try { From f0416670816f3b21c29343588f7297635ee180de Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 28 Oct 2026 16:58:49 -0400 Subject: [PATCH 02/32] fix(agents): hold a public channel's catch-up until its metadata is read A read of a channel the session does not know yet returns none of its events. Catch-up could read a watched public channel before its metadata arrived and save its checkpoint past what it missed. A failed metadata read was also never tried again in that session. The session now says an attended channel is pending until its metadata is read, and catch-up leaves that channel's stream where it was until then. A failed read is tried again after 30 s, and after a reconnect. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/relay-queries.md | 3 ++ src/features/agents/runner.ts | 13 ++++- src/features/agents/service-testing.ts | 12 ++++- src/features/agents/service.test.ts | 52 +++++++++++++++++++ src/features/relay/attend.session.test.ts | 61 ++++++++++++++++++++++- src/features/relay/session.ts | 48 +++++++++++++++--- 6 files changed, 178 insertions(+), 11 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index fea12e4c9c..01cfef4a29 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -325,6 +325,9 @@ membership. Only demanded retained previews enter the existing live interest set and the public channels agents' watches name (`session.attend`), which the session reads metadata for first. Attended channels come after joined and demanded ones, in the capacity those leave, and are never joined, listed or counted unread. +Until an attended channel's metadata is read, `session.attendPending(id)` is true +and a read of it returns none of its events, so agent catch-up waits for it. A +failed metadata read is tried again after 30 s, and after a reconnect. Shared composers and reactions remain unavailable for nonmembers; message helpers and workflows do not acquire write eligibility from public metadata. The relay remains the final write authority, including low-level outbox intent. diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 1a3506091a..92f29a7566 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -351,7 +351,10 @@ export type Connection = { /** An agent's community while it is live: one object for as long as its * session lasts. */ export type LiveCommunity = { - readonly session: Pick; + readonly session: Pick< + RelaySession, + "read" | "readRemote" | "live" | "attendPending" + >; /** Aborts when the session is replaced, so no work outlives it. */ readonly controller: AbortController; readonly connection: Connection; @@ -1235,7 +1238,13 @@ export class AgentRunners { // A stream new to catch-up, as every one is on an upgrade, starts // now: an earlier `since` does not replay older events. move(stream.key, { through: now }); - else reading.push(stream); + // Until its channel's metadata is known, a read returns none of its + // events: it waits, with its checkpoint where it was. + else if ( + stream.channel === undefined || + !community?.session.attendPending(stream.channel) + ) + reading.push(stream); } const pass = this.passes.get(runner.key); const since = community?.connection.since; diff --git a/src/features/agents/service-testing.ts b/src/features/agents/service-testing.ts index 5573ab4ec3..d5053720fc 100644 --- a/src/features/agents/service-testing.ts +++ b/src/features/agents/service-testing.ts @@ -98,6 +98,8 @@ export function fakeRelay(origin = "https://relay.example.test") { }), }; let status = "connected"; + /** Attended channels whose metadata the session has not read yet. */ + const pending = new Set(); /** Channels of the list live does not route. */ const unrouted = new Set(); const statuses = new Set<() => void>(); @@ -126,9 +128,15 @@ export function fakeRelay(origin = "https://relay.example.test") { ) throw new Error("Relay reads need valid kinds or event IDs"); // A catch-up read is bounded on both sides; it may ask for any kinds. + // The session drops a channel's events until it knows the channel. return filters[0]?.kinds?.includes(9) || (filters[0]?.since !== undefined && filters[0]?.until !== undefined) - ? page(history, filters[0]) + ? page(history, filters[0]).filter( + ({ tags }) => + !tags.some( + ([key, value]) => key === "h" && pending.has(value ?? ""), + ), + ) : [...listed]; }), readRemote: vi.fn(async (filters: readonly ReadFilter[]) => { @@ -164,6 +172,7 @@ export function fakeRelay(origin = "https://relay.example.test") { attended.add(ids); return () => attended.delete(ids); }), + attendPending: (id: string) => pending.has(id), live: { // Each channel of the list is live while connected, unless `unrouted`. snapshot: () => ({ @@ -264,6 +273,7 @@ export function fakeRelay(origin = "https://relay.example.test") { attended, listed, unrouted, + pending, history, memories, session, diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 249a47ccd5..6564bb6a4f 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -337,6 +337,7 @@ async function setup({ reconnecting: fake.reconnecting, recover: fake.recover, unrouted: fake.unrouted, + pending: fake.pending, memories: fake.memories, archives: fake.archives, session: fake.session, @@ -8757,6 +8758,57 @@ it("listens in a public channel it is not in, but delivers a watch's turn only w expect(trigger.text).toContain('watch "here"'); }); +it("holds a public channel's catch-up until its metadata is known, and hears it in a watch of every channel", async () => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + try { + const { service, run, history, recover, storage, pending } = await setup({ + identities: [echo], + }); + const now = nowSeconds(); + vi.setSystemTime(now * 1000); + const watch = { ...chat["watch/chat"], channels: [plaza] }; + await service.save(bot, { + attentionEnabled: true, + attention: { "watch/channel": null, "watch/plaza": watch }, + }); + await settle(); + vi.setSystemTime((now + 60) * 1000); + history.push(eventDto(message(author, plaza, "missed", now + 30))); + // Read now, it would come back empty: the session drops a channel's + // events until it knows the channel. + pending.add(plaza); + recover(); + await vi.advanceTimersByTimeAsync(10_000); + await settle(); + expect(heard(run)).toEqual([]); + expect( + Object.values(readCheckpoints(storage)).map(({ through }) => through), + ).toEqual([now]); + pending.delete(plaza); + await vi.advanceTimersByTimeAsync(5_000); + await settle(); + await settle(); + expect(heard(run)).toEqual(["missed"]); + // A watch of every channel hears it too, though it is not live, from the + // read of what live does not cover. + await service.save(bot, { + attention: { + "watch/plaza": null, + "watch/all": { ...chat["watch/chat"], channels: "all" }, + }, + }); + await settle(); + const later = nowSeconds(); + history.push(eventDto(message(author, plaza, "posted", later + 1))); + await vi.advanceTimersByTimeAsync(45_000); + await settle(); + await settle(); + expect(heard(run)).toEqual(["missed", "posted"]); + } finally { + vi.useRealTimers(); + } +}); + it("keeps the channels its enabled watches name live while attention is on", async () => { const { service, attended } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 226c34677a..1afa5986b6 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -38,7 +38,22 @@ function harness() { expect(owner.session.channels.list().status).toBe("ready"), ); }; - return { owner, wire, update, discover }; + /** Drops and restores the socket, and discovery reads the roster again. */ + const reconnect = async () => { + live.state({ status: "retrying", routes: [] }); + live.state({ status: "connected", routes: [] }); + live.established(); + const roster = () => + wire.pending.findIndex( + (entry) => + !entry.signal?.aborted && + !entry.filters.some((filter) => filter["#d"]), + ); + await vi.waitFor(() => expect(roster()).not.toBe(-1)); + wire.pending.splice(roster(), 1)[0]?.respond([]); + await flush(); + }; + return { owner, wire, update, discover, reconnect }; } /** The relay's signed metadata and roster of a channel only `other` is in. */ const channel = (id: string, visibility: "public" | "private") => [ @@ -94,3 +109,47 @@ it("keeps an attended public channel live once its metadata is read, never a pri stop(); expect(live()).not.toContain(OPEN); }); + +it("says an attended channel is pending until its metadata is read, and reads again what failed", async () => { + const h = harness(); + const pending = () => h.owner.session.attendPending(OPEN); + const lookup = () => + h.wire.pending.find( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ); + /** Answers the metadata read in flight, and takes it off the wire. */ + const answer = ( + respond: (entry: NonNullable>) => void, + ) => { + const entry = lookup(); + if (!entry) throw new Error("No metadata read"); + h.wire.pending.splice(h.wire.pending.indexOf(entry), 1); + respond(entry); + }; + expect(pending()).toBe(false); + h.owner.session.attend([OPEN]); + expect(pending()).toBe(true); + await h.discover(); + await vi.waitFor(() => expect(lookup()).toBeDefined()); + // A failed read leaves it pending, and a reconnect reads it again. + answer((entry) => entry.fail(new Error("down"))); + await flush(); + expect(pending()).toBe(true); + expect(lookup()).toBeUndefined(); + await h.reconnect(); + await vi.waitFor(() => expect(lookup()).toBeDefined()); + // So does a wait, with no reconnect. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + answer((entry) => entry.fail(new Error("down again"))); + await vi.advanceTimersByTimeAsync(0); + expect(lookup()).toBeUndefined(); + await vi.advanceTimersByTimeAsync(30_000); + } finally { + vi.useRealTimers(); + } + await vi.waitFor(() => expect(lookup()).toBeDefined()); + expect(pending()).toBe(true); + answer((entry) => entry.respond(channel(OPEN, "public") as RelayEvent[])); + await vi.waitFor(() => expect(pending()).toBe(false)); +}); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 62817e246b..eec77ae9a6 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1290,9 +1290,19 @@ export function createRelaySession( const attended = new Set(); /** Attended channels whose metadata was read for them, or is being read. */ const attendChecked = new Set(); + /** How long a failed metadata read of attended channels waits. */ + const ATTEND_RETRY_MS = 30_000; + /** Attended channels whose metadata read finished, found or not. */ + const attendRead = new Set(); + /** Forgets the reads that did not finish, so they are tried again. */ + const retryAttended = () => { + for (const id of attendChecked) + if (!attendRead.has(id)) attendChecked.delete(id); + }; /** Reads the metadata of attended channels not known yet, once each. Only * into a ready list: a lookup may extend one, never stand in for initial - * discovery. A failed read is not tried again until a later `attend`. */ + * discovery. A failed read is tried again after `ATTEND_RETRY_MS`, or once + * discovery is after a reconnect. */ const resolveAttended = () => { if ( closed || @@ -1304,13 +1314,26 @@ export function createRelaySession( (id) => !attendChecked.has(id) && !channels.queries.get?.(id), ); for (const id of unknown) attendChecked.add(id); - for (let start = 0; start < unknown.length; start += 128) + for (let start = 0; start < unknown.length; start += 128) { + const batch = unknown.slice(start, start + 128); void channels.queries - .resolve(unknown.slice(start, start + 128), { - signal: lifetime.signal, - }) - .catch(() => {}) + .resolve(batch, { signal: lifetime.signal }) + .then( + () => { + for (const id of batch) attendRead.add(id); + }, + () => { + if (closed) return; + const timer = setTimeout(() => { + timers.delete(timer); + retryAttended(); + updateInterests(); + }, ATTEND_RETRY_MS); + timers.add(timer); + }, + ) .finally(updateInterests); + } }; const liveListeners = new Set<() => void>(); let liveSnapshot: LiveSnapshot = Object.freeze({ @@ -2302,13 +2325,23 @@ export function createRelaySession( ), ]; // Unread or refused before, it is read again. - for (const id of ids) attendChecked.delete(id); + for (const id of ids) { + attendChecked.delete(id); + attendRead.delete(id); + } attended.add(ids); updateInterests(); return () => { if (attended.delete(ids)) updateInterests(); }; }, + /** Whether `channelId` is attended and its metadata is not known yet: + * until it is, a read of the channel returns none of its events. */ + attendPending(channelId: string): boolean { + if (![...attended].some((ids) => ids.includes(channelId))) return false; + const channel = channels.queries.get?.(channelId); + return channel ? !!channel.pending : !attendRead.has(channelId); + }, /** Verified, access-filtered live-phase events of any kind, after reconciliation. * Never finite reads or local intent, except the viewer's own messages in * a Solo chat this device is starting: those arrive here before the relay @@ -3576,6 +3609,7 @@ export function createRelaySession( liveGeneration++; // Reminder catch-up waits for the recovery read after re-establishment. reminders?.stale(); + retryAttended(); channelActivity.cancel(); activityRosterKey = undefined; catchups.clear(); From 17bb2992800fec2e2e5b508421217e96c185737f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 28 Oct 2026 17:04:53 -0400 Subject: [PATCH 03/32] fix(agents): hold a channel's catch-up before it is attended, too On a restart, catch-up runs before the service attends its watched channels. attendPending was false for a channel not attended yet, so that first read of an unknown public channel came back empty and moved its checkpoint past what it missed. It is now true for any channel the session does not know and has not read metadata for, attended or not. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/relay-queries.md | 5 +-- src/features/agents/service-testing.ts | 2 +- src/features/agents/service.test.ts | 40 +++++++++++++++++++++++ src/features/relay/attend.session.test.ts | 6 ++-- src/features/relay/session.ts | 7 ++-- 5 files changed, 52 insertions(+), 8 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 01cfef4a29..d7a4de194b 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -325,8 +325,9 @@ membership. Only demanded retained previews enter the existing live interest set and the public channels agents' watches name (`session.attend`), which the session reads metadata for first. Attended channels come after joined and demanded ones, in the capacity those leave, and are never joined, listed or counted unread. -Until an attended channel's metadata is read, `session.attendPending(id)` is true -and a read of it returns none of its events, so agent catch-up waits for it. A +Until the session knows a channel, or has read its metadata for `attend`, +`session.attendPending(id)` is true, also before the channel is attended: a read +of it returns none of its events, so agent catch-up waits for it. A failed metadata read is tried again after 30 s, and after a reconnect. Shared composers and reactions remain unavailable for nonmembers; message helpers and workflows do not acquire write eligibility from public metadata. The relay diff --git a/src/features/agents/service-testing.ts b/src/features/agents/service-testing.ts index d5053720fc..d9e6b6ddbb 100644 --- a/src/features/agents/service-testing.ts +++ b/src/features/agents/service-testing.ts @@ -98,7 +98,7 @@ export function fakeRelay(origin = "https://relay.example.test") { }), }; let status = "connected"; - /** Attended channels whose metadata the session has not read yet. */ + /** Channels the session does not know yet, attended or not. */ const pending = new Set(); /** Channels of the list live does not route. */ const unrouted = new Set(); diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 6564bb6a4f..0f9ea92755 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -169,6 +169,7 @@ async function setup({ identities = [] as AgentIdentity[], listed = [] as RelayEvent[], history = [] as RelayEvent[], + pendingChannels = [] as string[], timeoutMs = 60_000, runOnce = undefined as AgentType["runOnce"], jev = undefined as FakeJev | undefined, @@ -206,6 +207,7 @@ async function setup({ const native = fakeNative(identities); fake.listed.push(...listed); fake.history.push(...history); + for (const id of pendingChannels) fake.pending.add(id); const authorize = vi.fn( async (_: string, __: AbortSignal) => ["auth", viewer, "", "f".repeat(128)] as const, @@ -8809,6 +8811,44 @@ it("holds a public channel's catch-up until its metadata is known, and hears it } }); +it("holds a public channel's catch-up after a restart, before it is attended, until its metadata is known", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const start = nowSeconds(); + const storage = memoryStorage(); + const first = await setup({ storage, identities: [echo] }); + vi.setSystemTime(start * 1000); + await first.service.save(bot, { + attentionEnabled: true, + attention: { + "watch/channel": null, + "watch/plaza": { ...chat["watch/chat"], channels: [plaza] }, + }, + }); + await settle(); + first.connect(false); + vi.setSystemTime((start + 60) * 1000); + // Restarted: the session does not know plaza yet. + const second = await setup({ + storage, + identities: [echo], + history: [eventDto(message(author, plaza, "missed", start + 30))], + pendingChannels: [plaza], + }); + await settle(); + await settle(); + second.pending.delete(plaza); + vi.setSystemTime((start + 90) * 1000); + second.recover(); + await settle(); + await settle(); + await settle(); + expect(heard(second.run)).toEqual(["missed"]); + } finally { + vi.useRealTimers(); + } +}); + it("keeps the channels its enabled watches name live while attention is on", async () => { const { service, attended } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 1afa5986b6..c211323f74 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -110,7 +110,7 @@ it("keeps an attended public channel live once its metadata is read, never a pri expect(live()).not.toContain(OPEN); }); -it("says an attended channel is pending until its metadata is read, and reads again what failed", async () => { +it("says a channel is pending until its metadata is read, attended or not yet, and reads again what failed", async () => { const h = harness(); const pending = () => h.owner.session.attendPending(OPEN); const lookup = () => @@ -126,7 +126,9 @@ it("says an attended channel is pending until its metadata is read, and reads ag h.wire.pending.splice(h.wire.pending.indexOf(entry), 1); respond(entry); }; - expect(pending()).toBe(false); + // Unknown, it is pending before any `attend`, so a catch-up that starts + // first waits too. + expect(pending()).toBe(true); h.owner.session.attend([OPEN]); expect(pending()).toBe(true); await h.discover(); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index eec77ae9a6..3f3ca6f308 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -2335,10 +2335,11 @@ export function createRelaySession( if (attended.delete(ids)) updateInterests(); }; }, - /** Whether `channelId` is attended and its metadata is not known yet: - * until it is, a read of the channel returns none of its events. */ + /** Whether the session does not know `channelId` yet, and has not read + * its metadata for `attend`: until then, a read of the channel returns + * none of its events. True of a channel not attended yet too, so a read + * that runs before its `attend` waits for it as well. */ attendPending(channelId: string): boolean { - if (![...attended].some((ids) => ids.includes(channelId))) return false; const channel = channels.queries.get?.(channelId); return channel ? !!channel.pending : !attendRead.has(channelId); }, From b2dabb7b67698befaf6bbad999eddc399e992221 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 28 Oct 2026 17:11:35 -0400 Subject: [PATCH 04/32] fix(agents): resolve the channels a watch of every channel finds A read of every channel resolved the channels its events name only when every kind it asked for was a chat kind. A watch of every channel with other kinds, or of every kind, lost the events of a public channel the session did not know yet, and its checkpoint moved past them. Such a catch-up read now passes resolveChannels, and the session resolves the channels its events name whatever their kinds. Before discovery the read fails and is tried again, rather than drop what it found. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/relay-queries.md | 5 +- src/features/agents/runner.ts | 2 + src/features/agents/service.test.ts | 15 +++- src/features/relay/attend.session.test.ts | 86 ++++++++++++++++++++++- src/features/relay/reader.ts | 4 ++ src/features/relay/session.ts | 29 +++++--- 6 files changed, 126 insertions(+), 15 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index d7a4de194b..475bb141d8 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -328,7 +328,10 @@ in the capacity those leave, and are never joined, listed or counted unread. Until the session knows a channel, or has read its metadata for `attend`, `session.attendPending(id)` is true, also before the channel is attended: a read of it returns none of its events, so agent catch-up waits for it. A -failed metadata read is tried again after 30 s, and after a reconnect. +failed metadata read is tried again after 30 s, and after a reconnect. A read of +every channel with `resolveChannels` resolves the channels its events name, of +any kinds, before visibility filtering, as ranked search does for chat kinds; +before discovery it fails and is tried again, rather than drop what it finds. Shared composers and reactions remain unavailable for nonmembers; message helpers and workflows do not acquire write eligibility from public metadata. The relay remains the final write authority, including low-level outbox intent. diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 92f29a7566..fdbaedd035 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -1465,6 +1465,8 @@ export class AgentRunners { signal, priority: "background", fresh: true, + // Of every channel: a public one the session does not know yet too. + ...(filter["#h"] ? {} : { resolveChannels: true }), }); signal.throwIfAborted(); // Only what the relay signed: a read also returns the owner's own diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 0f9ea92755..72d83600aa 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -8763,9 +8763,8 @@ it("listens in a public channel it is not in, but delivers a watch's turn only w it("holds a public channel's catch-up until its metadata is known, and hears it in a watch of every channel", async () => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); try { - const { service, run, history, recover, storage, pending } = await setup({ - identities: [echo], - }); + const { service, run, history, recover, storage, pending, session } = + await setup({ identities: [echo] }); const now = nowSeconds(); vi.setSystemTime(now * 1000); const watch = { ...chat["watch/chat"], channels: [plaza] }; @@ -8806,6 +8805,16 @@ it("holds a public channel's catch-up until its metadata is known, and hears it await settle(); await settle(); expect(heard(run)).toEqual(["missed", "posted"]); + // Its read asks the session to resolve the channels it finds. + expect( + vi + .mocked(session.read) + .mock.calls.filter(([filters]) => filters[0]?.until !== undefined) + .map(([filters, options]) => [ + !!filters[0]?.["#h"], + !!options?.resolveChannels, + ]), + ).toContainEqual([false, true]); } finally { vi.useRealTimers(); } diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index c211323f74..3cc80aa358 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -53,7 +53,12 @@ function harness() { wire.pending.splice(roster(), 1)[0]?.respond([]); await flush(); }; - return { owner, wire, update, discover, reconnect }; + /** Connects, with the roster read left unanswered. */ + const connect = () => { + live.state({ status: "connected", routes: [] }); + live.established(); + }; + return { owner, wire, update, discover, reconnect, connect }; } /** The relay's signed metadata and roster of a channel only `other` is in. */ const channel = (id: string, visibility: "public" | "private") => [ @@ -155,3 +160,82 @@ it("says a channel is pending until its metadata is read, attended or not yet, a answer((entry) => entry.respond(channel(OPEN, "public") as RelayEvent[])); await vi.waitFor(() => expect(pending()).toBe(false)); }); + +it("resolves the channels a read of every channel finds, of any kinds, once discovery is done", async () => { + const h = harness(); + const read = () => + h.owner.session.read([{ kinds: [9, 7], since: 11, until: 20, limit: 16 }], { + fresh: true, + resolveChannels: true, + }); + const message = signed(other, { + kind: 9, + content: "message", + created_at: 15, + tags: [["h", OPEN]], + }); + const history = [ + message, + signed(other, { + kind: 7, + content: "+", + created_at: 16, + tags: [ + ["h", OPEN], + ["e", message.id], + ], + }), + ] as RelayEvent[]; + await h.discover(); + const reading = read(); + await vi.waitFor(() => + expect( + h.wire.pending.some((entry) => + entry.filters.some((filter) => filter.kinds?.includes(7)), + ), + ).toBe(true), + ); + const index = h.wire.pending.findIndex((entry) => + entry.filters.some((filter) => filter.kinds?.includes(7)), + ); + h.wire.pending.splice(index, 1)[0]?.respond(history); + await vi.waitFor(() => + expect(h.wire.pending.some((entry) => asks(entry.filters, OPEN))).toBe( + true, + ), + ); + const lookup = h.wire.pending.find((entry) => asks(entry.filters, OPEN)); + lookup?.respond(channel(OPEN, "public") as RelayEvent[]); + expect((await reading).map(({ kind }) => kind).sort()).toEqual([7, 9]); +}); + +it("does not answer a read of every channel that resolves its channels before discovery, rather than drop what it cannot place", async () => { + const h = harness(); + // Discovery has not succeeded: its roster read failed. + h.connect(); + await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); + h.wire.next().fail(new Error("down")); + await vi.waitFor(() => + expect(h.owner.session.channels.list().status).not.toBe("ready"), + ); + let settled = false; + void h.owner.session + .read([{ kinds: [7], since: 11, until: 20, limit: 16 }], { + fresh: true, + resolveChannels: true, + }) + .finally(() => { + settled = true; + }) + .catch(() => {}); + const reads = () => + h.wire.pending.findIndex((entry) => + entry.filters.some((filter) => filter.kinds?.includes(7)), + ); + await vi.waitFor(() => expect(reads()).not.toBe(-1)); + h.wire.pending.splice(reads(), 1)[0]?.respond([]); + await flush(); + await flush(); + // It is tried again later, not answered empty. + expect(settled).toBe(false); +}); diff --git a/src/features/relay/reader.ts b/src/features/relay/reader.ts index 0ce83c842d..785c63ffcb 100644 --- a/src/features/relay/reader.ts +++ b/src/features/relay/reader.ts @@ -18,6 +18,10 @@ export type ReadOptions = { /** Replaces the reader's deadline for new work; a joining read keeps the * deadline of the read it joins. */ deadlineMs?: number; + /** A read of every channel resolves the channels its events name first, + * whatever their kinds, so a readable one the session does not know yet is + * not dropped. It waits for discovery: before it, the read fails. */ + resolveChannels?: boolean; }; /** Finite, verified event reads. No retained event cache or claim of live freshness. */ export type RelayReader = { diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 3f3ca6f308..30a84b763a 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -688,24 +688,31 @@ export function createRelaySession( events = events.filter((event) => event.kind !== PRODUCT_FEEDBACK_KIND); // Ranked global search needs channel authority before visibility filtering. // Store metadata reads use channelTraffic=false, so this cannot recurse. + // So does a read of every channel that asks for it, of any kinds. + const chat = (kind: number) => [9, 40002, 40008].includes(kind); + const broad = !!settings?.resolveChannels; if ( channelTraffic && filters.every( (filter) => - (filter.search !== undefined || + (broad || + filter.search !== undefined || !!filter.authors?.length || filter.since !== undefined || filter.until !== undefined) && !filter["#h"]?.length && !!filter.kinds?.length && - filter.kinds.every((kind) => [9, 40002, 40008].includes(kind)), + (broad || filter.kinds.every(chat)), ) ) { + // A lookup may extend a discovered list, never stand in for it. + if (broad && channels.queries.list().status !== "ready") + throw new ReadError("unavailable", "Channels are not discovered yet."); const ids = [ ...new Set( events.flatMap((event) => { const tags = event.tags.filter(([name]) => name === "h"); - return [9, 40002, 40008].includes(event.kind) && + return (broad || chat(event.kind)) && tags.length === 1 && tags[0]?.[1] ? [tags[0][1]] @@ -714,13 +721,15 @@ export function createRelaySession( ), ]; await channels.queries.resolve?.(ids, settings); - events = events.filter((event) => { - const destinations = event.tags.filter(([name]) => name === "h"); - const id = destinations[0]?.[1]; - return ( - destinations.length === 1 && !!id && !!channels.queries.get?.(id) - ); - }); + // A read of every channel keeps what visibility keeps below. + if (!broad) + events = events.filter((event) => { + const destinations = event.tags.filter(([name]) => name === "h"); + const id = destinations[0]?.[1]; + return ( + destinations.length === 1 && !!id && !!channels.queries.get?.(id) + ); + }); settings?.signal?.throwIfAborted(); if (closed || epoch !== accessEpoch) throw new DOMException("Stale relay search", "AbortError"); From 7fd1bb49dd3a12338d65a575d740ff4cea245624 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 3 Nov 2026 17:10:39 -0500 Subject: [PATCH 05/32] fix(agents): read again an attended channel found unreadable A metadata read that succeeded but found nothing the viewer may read, such as a private channel, was never read again, even after a reconnect. If the channel then turned public, catch-up moved past its events. Such a channel now stays pending, and is read again after 30 s and after a reconnect, like a failed read, through one shared wait. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- docs/relay-queries.md | 9 +-- src/features/relay/attend.session.test.ts | 54 +++++++++++++++++- src/features/relay/session.ts | 69 ++++++++++++----------- 3 files changed, 92 insertions(+), 40 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 475bb141d8..bc0882565b 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -325,10 +325,11 @@ membership. Only demanded retained previews enter the existing live interest set and the public channels agents' watches name (`session.attend`), which the session reads metadata for first. Attended channels come after joined and demanded ones, in the capacity those leave, and are never joined, listed or counted unread. -Until the session knows a channel, or has read its metadata for `attend`, -`session.attendPending(id)` is true, also before the channel is attended: a read -of it returns none of its events, so agent catch-up waits for it. A -failed metadata read is tried again after 30 s, and after a reconnect. A read of +Until the session knows a channel, `session.attendPending(id)` is true, also +before the channel is attended: a read of it returns none of its events, so +agent catch-up waits for it. A metadata read that fails, or finds nothing the +viewer may read (a private channel may turn public), is tried again after 30 s, +and after a reconnect. A read of every channel with `resolveChannels` resolves the channels its events name, of any kinds, before visibility filtering, as ranked search does for chat kinds; before discovery it fails and is tried again, rather than drop what it finds. diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 3cc80aa358..7e2aa98ae6 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -61,17 +61,21 @@ function harness() { return { owner, wire, update, discover, reconnect, connect }; } /** The relay's signed metadata and roster of a channel only `other` is in. */ -const channel = (id: string, visibility: "public" | "private") => [ +const channel = ( + id: string, + visibility: "public" | "private", + created_at = 10, +) => [ signed(relay, { kind: 39000, content: "", - created_at: 10, + created_at, tags: [["d", id], ["name", id.slice(0, 4)], [visibility]], }), signed(relay, { kind: 39002, content: "", - created_at: 10, + created_at, tags: [ ["d", id], ["p", other.pubkey], @@ -239,3 +243,47 @@ it("does not answer a read of every channel that resolves its channels before di // It is tried again later, not answered empty. expect(settled).toBe(false); }); + +it("reads again a channel found unreadable, holding it pending, and keeps it live once it turns public", async () => { + const h = harness(); + const live = () => h.update.mock.lastCall?.[0] ?? []; + const pending = () => h.owner.session.attendPending(OPEN); + const lookup = () => + h.wire.pending.find( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ); + const answer = (events: readonly unknown[]) => { + const entry = lookup(); + if (!entry) throw new Error("No metadata read"); + h.wire.pending.splice(h.wire.pending.indexOf(entry), 1); + entry.respond(events as RelayEvent[]); + }; + h.owner.session.attend([OPEN]); + await h.discover(); + await vi.waitFor(() => expect(lookup()).toBeDefined()); + // Private, and the viewer is not in it: nothing it can read yet. It stays + // pending, so catch-up keeps its checkpoint, and a reconnect reads it again. + answer(channel(OPEN, "private")); + await flush(); + expect(pending()).toBe(true); + expect(lookup()).toBeUndefined(); + await h.reconnect(); + await vi.waitFor(() => expect(lookup()).toBeDefined()); + // So does a wait, with no reconnect: the relay may have no answer at all. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + answer([]); + await vi.advanceTimersByTimeAsync(0); + expect(pending()).toBe(true); + expect(lookup()).toBeUndefined(); + await vi.advanceTimersByTimeAsync(30_000); + } finally { + vi.useRealTimers(); + } + await vi.waitFor(() => expect(lookup()).toBeDefined()); + expect(pending()).toBe(true); + // Now public, by newer metadata: known, and live. + answer(channel(OPEN, "public", 20)); + await vi.waitFor(() => expect(pending()).toBe(false)); + await vi.waitFor(() => expect(live()).toContain(OPEN)); +}); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 30a84b763a..25cbc9e936 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1299,19 +1299,34 @@ export function createRelaySession( const attended = new Set(); /** Attended channels whose metadata was read for them, or is being read. */ const attendChecked = new Set(); - /** How long a failed metadata read of attended channels waits. */ + /** How long an attended channel still unknown after a read waits. */ const ATTEND_RETRY_MS = 30_000; - /** Attended channels whose metadata read finished, found or not. */ - const attendRead = new Set(); - /** Forgets the reads that did not finish, so they are tried again. */ + /** One wait for every channel still unknown, so reads do not multiply. */ + let attendRetry: ReturnType | undefined; + /** Forgets the reads made, and any wait for them, so channels still + * unknown are read again. */ const retryAttended = () => { - for (const id of attendChecked) - if (!attendRead.has(id)) attendChecked.delete(id); + if (attendRetry) { + clearTimeout(attendRetry); + timers.delete(attendRetry); + attendRetry = undefined; + } + attendChecked.clear(); + }; + const retryAttendedLater = () => { + if (closed || attendRetry) return; + attendRetry = setTimeout(() => { + retryAttended(); + updateInterests(); + }, ATTEND_RETRY_MS); + timers.add(attendRetry); }; - /** Reads the metadata of attended channels not known yet, once each. Only - * into a ready list: a lookup may extend one, never stand in for initial - * discovery. A failed read is tried again after `ATTEND_RETRY_MS`, or once - * discovery is after a reconnect. */ + /** Reads the metadata of attended channels not known yet. Only into a + * ready list: a lookup may extend one, never stand in for initial + * discovery. A channel still unknown after its read, because the read + * failed or found nothing the viewer may read, is read again after + * `ATTEND_RETRY_MS`, or once discovery is after a reconnect: it may have + * turned public since. */ const resolveAttended = () => { if ( closed || @@ -1327,20 +1342,10 @@ export function createRelaySession( const batch = unknown.slice(start, start + 128); void channels.queries .resolve(batch, { signal: lifetime.signal }) - .then( - () => { - for (const id of batch) attendRead.add(id); - }, - () => { - if (closed) return; - const timer = setTimeout(() => { - timers.delete(timer); - retryAttended(); - updateInterests(); - }, ATTEND_RETRY_MS); - timers.add(timer); - }, - ) + .then(() => { + if (batch.some((id) => !channels.queries.get?.(id))) + retryAttendedLater(); + }, retryAttendedLater) .finally(updateInterests); } }; @@ -2334,23 +2339,21 @@ export function createRelaySession( ), ]; // Unread or refused before, it is read again. - for (const id of ids) { - attendChecked.delete(id); - attendRead.delete(id); - } + for (const id of ids) attendChecked.delete(id); attended.add(ids); updateInterests(); return () => { if (attended.delete(ids)) updateInterests(); }; }, - /** Whether the session does not know `channelId` yet, and has not read - * its metadata for `attend`: until then, a read of the channel returns - * none of its events. True of a channel not attended yet too, so a read - * that runs before its `attend` waits for it as well. */ + /** Whether the session does not know `channelId`: a read of the channel + * returns none of its events. True of a channel not attended yet, so a + * read that runs before its `attend` waits for it as well, and of one + * whose metadata read found nothing the viewer may read, until a later + * read finds it. */ attendPending(channelId: string): boolean { const channel = channels.queries.get?.(channelId); - return channel ? !!channel.pending : !attendRead.has(channelId); + return !channel || !!channel.pending; }, /** Verified, access-filtered live-phase events of any kind, after reconciliation. * Never finite reads or local intent, except the viewer's own messages in From 63e1fd467dba8597f2cc43b0a755119cfc372c7c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 3 Nov 2026 17:10:40 -0500 Subject: [PATCH 06/32] docs(agents): say an event watch hears public channels it has not joined Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- docs/agents.md | 2 +- docs/buzz-help.md | 4 +++- src/buzz-mcp/attention.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 7871ebea5b..641f569ce9 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1590,7 +1590,7 @@ The bundled Agents plugin owns the command producer and discovery tool. The GitH ## Attention delivery destinations -Event watches and timers accept an optional delivery `channel` and thread `root`. The event watch's `channels` still selects where to listen. With an address, the wake continues that conversation with the Interest's instructions. A thread root must be readable by the agent in that destination channel; a missing or mismatched root stops delivery before the harness builds context. Claude, Codex, Goose and Pi accept these Attention triggers. A channel without a root defaults to channel-level posting, while explicit reporting instructions in the Interest can direct another thread or channel within the agent's existing authority. Solo chats retain their personal-chat framing and reply restriction. The address owns the execution conversation and default destination; the Interest owns explicit reporting policy. +Event watches and timers accept an optional delivery `channel` and thread `root`. The event watch's `channels` still selects where to listen: channels the agent is a member of, and public channels it has not joined (`all` hears those through catch-up, about every 30 seconds). Listening does not let the agent post: a delivery address needs membership. With an address, the wake continues that conversation with the Interest's instructions. A thread root must be readable by the agent in that destination channel; a missing or mismatched root stops delivery before the harness builds context. Claude, Codex, Goose and Pi accept these Attention triggers. A channel without a root defaults to channel-level posting, while explicit reporting instructions in the Interest can direct another thread or channel within the agent's existing authority. Solo chats retain their personal-chat framing and reply restriction. The address owns the execution conversation and default destination; the Interest owns explicit reporting policy. Without an address, the subscription runs an independent turn in its Interest's lane. Timers retain their occurrence and expiry budgets. An addressed timer waits without spending an occurrence while destination membership is unconfirmed or absent. `watch_show` reports delivery as held or blocked with a reason; delivery resumes once membership is confirmed. Expiry still applies while held. Membership checks never join channels. The Attention panel shows listening channels separately from the delivery destination. diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 2254c62f06..5bc587f533 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -811,7 +811,9 @@ confirmation. “Runs in” names its execution conversation; its instructions can specify a different place to report. A mention can still wake an agent when Attention is off; turning Attention on does not grant new access. - On macOS and Linux, watched commands stop when their Buzz process exits. + An event watch hears the channels its agent may read, including public channels + the agent has not joined, but its turns run only in channels the agent is a + member of. On macOS and Linux, watched commands stop when their Buzz process exits. During a handoff between Buzz builds, the new copy waits for the previous command to stop, then continues the watch. - **Shared compute** is off by default; enable it in Settings → Plugins, then diff --git a/src/buzz-mcp/attention.ts b/src/buzz-mcp/attention.ts index 5f38a0046c..0f3c3621ee 100644 --- a/src/buzz-mcp/attention.ts +++ b/src/buzz-mcp/attention.ts @@ -167,7 +167,7 @@ export const ATTENTION_TOOLS: readonly Tool[] = [ "watch_add", `Add an event watch (since and channels), a timer (prompt and interval_secs), a source subscription (provider and config) to an Interest; only for a new watch. -Event watch: it sees only channels you are a member of, and "all" means all of those. It sees events that arrive while it is on: an earlier since does not replay older ones. Each matching event watch runs independently. Prefer deterministic selectors for hard rules; use the classifier only for judgment. authors keeps only those authors, exclude_authors: ["OWNER_HEX"] drops the owner's own messages, and is_reply: false keeps only top-level messages (true: only thread replies). A watch has authors or exclude_authors, not both. When a watch reports to the owner, exclude the owner's messages unless they are the signal. +Event watch: it hears the channels you may read: those you are a member of, and public channels you have not joined. "all" means all of those, but hears a public channel you have not joined only about 30 seconds late. Reading a channel does not let you post there: a delivery channel must be one you are a member of. It sees events that arrive while it is on: an earlier since does not replay older ones. Each matching event watch runs independently. Prefer deterministic selectors for hard rules; use the classifier only for judgment. authors keeps only those authors, exclude_authors: ["OWNER_HEX"] drops the owner's own messages, and is_reply: false keeps only top-level messages (true: only thread replies). A watch has authors or exclude_authors, not both. When a watch reports to the owner, exclude the owner's messages unless they are the signal. Classifier, for noisy event watches or source subscriptions: Jev tests relevance after the normal filters. It sees one event or emitted source output, not history, and has no tools. Use named yes/no questions, for example {"questions":{"release_blocked":{"question":"Does the event say the release cannot proceed until something is fixed?","true":"It reports a release blocker, in any form: a question, an instruction or a status report.","false":"Routine release progress, or a problem that does not stop the release."}}}. Write questions for the facts the Interest needs, not for message types, and accept any phrasing of those facts. Ask one thing per question. The event passes when any question says yes, and each added question can add extra passes, so keep them few. For "A but not B", write one question that says so. Optional per question: true and false descriptions, guidance for examples and exclusions, and threshold (default 0.2; lower passes more). Change a threshold only when eval cases support it. Keep actions in the Interest. Pass uncertain cases. Errors pass events through. Every answer says classifier: available or unavailable. Classifiers can be saved either way and run once one is available; until then a classified watch's events pass unchecked, and the turn they start says so in a block. From d58f1d5817d2184c7b9ec1ca8177b46f4d4dbefd Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 3 Nov 2026 17:59:25 -0500 Subject: [PATCH 07/32] fix(agents): read again an attended channel that turns private, and back off A channel whose first metadata read found it stayed checked. When newer metadata made it private, it left the live routes, and nothing read it again until a reconnect, so it stayed silent after it turned public. A channel known again now leaves the checked set, so it is read at once when it becomes unknown, and every attended channel still unknown has a read in flight or a wait. The wait starts at 30 s and doubles up to 5 minutes, and starts over on a reconnect or a new attend. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- docs/relay-queries.md | 5 +- src/features/relay/attend.session.test.ts | 59 +++++++++++++++++++- src/features/relay/session.ts | 65 +++++++++++++++-------- 3 files changed, 104 insertions(+), 25 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index bc0882565b..94ba7855f1 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -328,8 +328,9 @@ in the capacity those leave, and are never joined, listed or counted unread. Until the session knows a channel, `session.attendPending(id)` is true, also before the channel is attended: a read of it returns none of its events, so agent catch-up waits for it. A metadata read that fails, or finds nothing the -viewer may read (a private channel may turn public), is tried again after 30 s, -and after a reconnect. A read of +viewer may read (a private channel may turn public), is tried again after a wait +that starts at 30 s and doubles up to 5 minutes, and after a reconnect or a new +`attend`. A channel that newer metadata makes unknown again is read again at once. A read of every channel with `resolveChannels` resolves the channels its events name, of any kinds, before visibility filtering, as ranked search does for chat kinds; before discovery it fails and is tried again, rather than drop what it finds. diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 7e2aa98ae6..158479b6bc 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -58,7 +58,15 @@ function harness() { live.state({ status: "connected", routes: [] }); live.established(); }; - return { owner, wire, update, discover, reconnect, connect }; + return { + owner, + wire, + update, + discover, + reconnect, + connect, + receive: (events: readonly RelayEvent[]) => live.receive(events as never), + }; } /** The relay's signed metadata and roster of a channel only `other` is in. */ const channel = ( @@ -287,3 +295,52 @@ it("reads again a channel found unreadable, holding it pending, and keeps it liv await vi.waitFor(() => expect(pending()).toBe(false)); await vi.waitFor(() => expect(live()).toContain(OPEN)); }); + +it("reads again a channel that newer metadata makes private, at once and then after a growing wait, and hears it once it is public again", async () => { + const h = harness(); + const live = () => h.update.mock.lastCall?.[0] ?? []; + const pending = () => h.owner.session.attendPending(OPEN); + const lookup = () => + h.wire.pending.find( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ); + const answer = (events: readonly unknown[]) => { + const entry = lookup(); + if (!entry) throw new Error("No metadata read"); + h.wire.pending.splice(h.wire.pending.indexOf(entry), 1); + entry.respond(events as RelayEvent[]); + }; + h.owner.session.attend([OPEN]); + await h.discover(); + await vi.waitFor(() => expect(lookup()).toBeDefined()); + answer(channel(OPEN, "public")); + await vi.waitFor(() => expect(live()).toContain(OPEN)); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + // Newer metadata makes it private: it leaves the live routes, and its + // metadata is read again at once. + h.receive(channel(OPEN, "private", 20) as RelayEvent[]); + await vi.advanceTimersByTimeAsync(0); + expect(pending()).toBe(true); + expect(live()).not.toContain(OPEN); + expect(lookup()).toBeDefined(); + // Still private: read again after 30 s, then after 60 s. + answer(channel(OPEN, "private", 20)); + await vi.advanceTimersByTimeAsync(29_999); + expect(lookup()).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(lookup()).toBeDefined(); + answer(channel(OPEN, "private", 20)); + await vi.advanceTimersByTimeAsync(59_999); + expect(lookup()).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(lookup()).toBeDefined(); + // Public again: known, and live. + answer(channel(OPEN, "public", 30)); + await vi.advanceTimersByTimeAsync(0); + } finally { + vi.useRealTimers(); + } + await vi.waitFor(() => expect(pending()).toBe(false)); + await vi.waitFor(() => expect(live()).toContain(OPEN)); +}); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 25cbc9e936..34f0923d34 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1297,36 +1297,47 @@ export function createRelaySession( const followRevocations = new Set<() => void>(); /** Channels each `attend` keeps live for agents' watches, its own list. */ const attended = new Set(); - /** Attended channels whose metadata was read for them, or is being read. */ + /** Attended channels read since the last wait ended, in flight or done. + * One known again leaves it, so it is read at once if it is unknown again. */ const attendChecked = new Set(); - /** How long an attended channel still unknown after a read waits. */ + /** Attended channels whose metadata read is in flight. */ + const attendReading = new Set(); + /** The first wait before an attended channel still unknown is read again, + * and the most it grows to while channels stay unknown. */ const ATTEND_RETRY_MS = 30_000; + const ATTEND_RETRY_MAX_MS = 300_000; + let attendDelay = ATTEND_RETRY_MS; /** One wait for every channel still unknown, so reads do not multiply. */ let attendRetry: ReturnType | undefined; /** Forgets the reads made, and any wait for them, so channels still - * unknown are read again. */ - const retryAttended = () => { + * unknown are read again; a reconnect or new `attend` also starts the wait + * over from `ATTEND_RETRY_MS`. */ + const retryAttended = (restart = true) => { if (attendRetry) { clearTimeout(attendRetry); timers.delete(attendRetry); attendRetry = undefined; } attendChecked.clear(); + if (restart) attendDelay = ATTEND_RETRY_MS; }; const retryAttendedLater = () => { if (closed || attendRetry) return; + const delay = attendDelay; + attendDelay = Math.min(delay * 2, ATTEND_RETRY_MAX_MS); attendRetry = setTimeout(() => { - retryAttended(); + retryAttended(false); updateInterests(); - }, ATTEND_RETRY_MS); + }, delay); timers.add(attendRetry); }; - /** Reads the metadata of attended channels not known yet. Only into a - * ready list: a lookup may extend one, never stand in for initial - * discovery. A channel still unknown after its read, because the read - * failed or found nothing the viewer may read, is read again after - * `ATTEND_RETRY_MS`, or once discovery is after a reconnect: it may have - * turned public since. */ + /** Reads the metadata of attended channels not known. Only into a ready + * list: a lookup may extend one, never stand in for initial discovery. A + * channel still unknown after its read, because the read failed or found + * nothing the viewer may read, is read again after a wait that doubles up + * to `ATTEND_RETRY_MAX_MS`, or once discovery is after a reconnect: it may + * have turned public since. So is one that becomes unknown again, as when + * newer metadata makes it private. */ const resolveAttended = () => { if ( closed || @@ -1335,18 +1346,27 @@ export function createRelaySession( ) return; const unknown = [...new Set([...attended].flat())].filter( - (id) => !attendChecked.has(id) && !channels.queries.get?.(id), + (id) => !channels.queries.get?.(id), ); - for (const id of unknown) attendChecked.add(id); - for (let start = 0; start < unknown.length; start += 128) { - const batch = unknown.slice(start, start + 128); + for (const id of attendChecked) + if (channels.queries.get?.(id)) attendChecked.delete(id); + if (!unknown.length) attendDelay = ATTEND_RETRY_MS; + if (unknown.some((id) => attendChecked.has(id) && !attendReading.has(id))) + retryAttendedLater(); + const fresh = unknown.filter((id) => !attendChecked.has(id)); + for (const id of fresh) { + attendChecked.add(id); + attendReading.add(id); + } + for (let start = 0; start < fresh.length; start += 128) { + const batch = fresh.slice(start, start + 128); void channels.queries .resolve(batch, { signal: lifetime.signal }) - .then(() => { - if (batch.some((id) => !channels.queries.get?.(id))) - retryAttendedLater(); - }, retryAttendedLater) - .finally(updateInterests); + .catch(() => {}) + .finally(() => { + for (const id of batch) attendReading.delete(id); + updateInterests(); + }); } }; const liveListeners = new Set<() => void>(); @@ -2338,8 +2358,9 @@ export function createRelaySession( channelIds.filter((id) => /^[a-zA-Z0-9_-]{1,128}$/.test(id)), ), ]; - // Unread or refused before, it is read again. + // Unread or refused before, it is read again, and soon. for (const id of ids) attendChecked.delete(id); + attendDelay = ATTEND_RETRY_MS; attended.add(ids); updateInterests(); return () => { From e0d988efdfee87b47b63b3e44fbb459e95072960 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 3 Nov 2026 17:59:29 -0500 Subject: [PATCH 08/32] test(agents): pin that a hidden or DM public channel is not heard; fix posting wording The relay lets a nonmember post to an open channel, so the docs no longer say reading a channel does not let the agent post there: only a delivery channel needs membership. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- docs/agents.md | 2 +- docs/buzz-help.md | 5 ++-- src/buzz-mcp/attention.ts | 2 +- src/features/agents/service.test.ts | 43 +++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 641f569ce9..c4e0a1f25a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1590,7 +1590,7 @@ The bundled Agents plugin owns the command producer and discovery tool. The GitH ## Attention delivery destinations -Event watches and timers accept an optional delivery `channel` and thread `root`. The event watch's `channels` still selects where to listen: channels the agent is a member of, and public channels it has not joined (`all` hears those through catch-up, about every 30 seconds). Listening does not let the agent post: a delivery address needs membership. With an address, the wake continues that conversation with the Interest's instructions. A thread root must be readable by the agent in that destination channel; a missing or mismatched root stops delivery before the harness builds context. Claude, Codex, Goose and Pi accept these Attention triggers. A channel without a root defaults to channel-level posting, while explicit reporting instructions in the Interest can direct another thread or channel within the agent's existing authority. Solo chats retain their personal-chat framing and reply restriction. The address owns the execution conversation and default destination; the Interest owns explicit reporting policy. +Event watches and timers accept an optional delivery `channel` and thread `root`. The event watch's `channels` still selects where to listen: channels the agent is a member of, and public channels it has not joined (`all` hears those through catch-up, about every 30 seconds). A delivery address needs membership; the relay decides where the agent may post. With an address, the wake continues that conversation with the Interest's instructions. A thread root must be readable by the agent in that destination channel; a missing or mismatched root stops delivery before the harness builds context. Claude, Codex, Goose and Pi accept these Attention triggers. A channel without a root defaults to channel-level posting, while explicit reporting instructions in the Interest can direct another thread or channel within the agent's existing authority. Solo chats retain their personal-chat framing and reply restriction. The address owns the execution conversation and default destination; the Interest owns explicit reporting policy. Without an address, the subscription runs an independent turn in its Interest's lane. Timers retain their occurrence and expiry budgets. An addressed timer waits without spending an occurrence while destination membership is unconfirmed or absent. `watch_show` reports delivery as held or blocked with a reason; delivery resumes once membership is confirmed. Expiry still applies while held. Membership checks never join channels. The Attention panel shows listening channels separately from the delivery destination. diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 5bc587f533..1eb148c985 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -812,8 +812,9 @@ confirmation. different place to report. A mention can still wake an agent when Attention is off; turning Attention on does not grant new access. An event watch hears the channels its agent may read, including public channels - the agent has not joined, but its turns run only in channels the agent is a - member of. On macOS and Linux, watched commands stop when their Buzz process exits. + the agent has not joined. A watch's delivery channel must be one the agent is + a member of; the relay decides where the agent may post. On macOS and Linux, + watched commands stop when their Buzz process exits. During a handoff between Buzz builds, the new copy waits for the previous command to stop, then continues the watch. - **Shared compute** is off by default; enable it in Settings → Plugins, then diff --git a/src/buzz-mcp/attention.ts b/src/buzz-mcp/attention.ts index 0f3c3621ee..a8d46994ce 100644 --- a/src/buzz-mcp/attention.ts +++ b/src/buzz-mcp/attention.ts @@ -167,7 +167,7 @@ export const ATTENTION_TOOLS: readonly Tool[] = [ "watch_add", `Add an event watch (since and channels), a timer (prompt and interval_secs), a source subscription (provider and config) to an Interest; only for a new watch. -Event watch: it hears the channels you may read: those you are a member of, and public channels you have not joined. "all" means all of those, but hears a public channel you have not joined only about 30 seconds late. Reading a channel does not let you post there: a delivery channel must be one you are a member of. It sees events that arrive while it is on: an earlier since does not replay older ones. Each matching event watch runs independently. Prefer deterministic selectors for hard rules; use the classifier only for judgment. authors keeps only those authors, exclude_authors: ["OWNER_HEX"] drops the owner's own messages, and is_reply: false keeps only top-level messages (true: only thread replies). A watch has authors or exclude_authors, not both. When a watch reports to the owner, exclude the owner's messages unless they are the signal. +Event watch: it hears the channels you may read: those you are a member of, and public channels you have not joined. "all" means all of those, but hears a public channel you have not joined only about 30 seconds late. A delivery channel must be one you are a member of; the relay decides where you may post. It sees events that arrive while it is on: an earlier since does not replay older ones. Each matching event watch runs independently. Prefer deterministic selectors for hard rules; use the classifier only for judgment. authors keeps only those authors, exclude_authors: ["OWNER_HEX"] drops the owner's own messages, and is_reply: false keeps only top-level messages (true: only thread replies). A watch has authors or exclude_authors, not both. When a watch reports to the owner, exclude the owner's messages unless they are the signal. Classifier, for noisy event watches or source subscriptions: Jev tests relevance after the normal filters. It sees one event or emitted source output, not history, and has no tools. Use named yes/no questions, for example {"questions":{"release_blocked":{"question":"Does the event say the release cannot proceed until something is fixed?","true":"It reports a release blocker, in any form: a question, an instruction or a status report.","false":"Routine release progress, or a problem that does not stop the release."}}}. Write questions for the facts the Interest needs, not for message types, and accept any phrasing of those facts. Ask one thing per question. The event passes when any question says yes, and each added question can add extra passes, so keep them few. For "A but not B", write one question that says so. Optional per question: true and false descriptions, guidance for examples and exclusions, and threshold (default 0.2; lower passes more). Change a threshold only when eval cases support it. Keep actions in the Interest. Pass uncertain cases. Errors pass events through. Every answer says classifier: available or unavailable. Classifiers can be saved either way and run once one is available; until then a classified watch's events pass unchecked, and the turn they start says so in a block. diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 72d83600aa..3f054e15b3 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -8760,6 +8760,49 @@ it("listens in a public channel it is not in, but delivers a watch's turn only w expect(trigger.text).toContain('watch "here"'); }); +it("does not listen in a public channel it is not in that is hidden or a DM", async () => { + const { service, run, emit, session } = await setup(); + const hidden = "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b"; + const dm = "6f7a8b9c-0d1e-4f2a-9b3c-4d5e6f7a8b9c"; + const get = session.channels.get; + vi.spyOn(session.channels, "get").mockImplementation((id: string) => + id === hidden || id === dm + ? ({ + id, + name: id, + visibility: "public", + readOnly: true, + members: [other], + ...(id === hidden ? { hidden: true } : { channelType: "dm" }), + } as never) + : get?.(id), + ); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { attentionEnabled: true }); + await service.save(bot, { + attention: { + "watch/public": { + type: "event", + interest_id: "default", + enabled: true, + since: 1, + channels: [hidden, dm, plaza], + kinds: [9], + }, + }, + }); + emit({ + events: [ + event("hidden", { tags: [["h", hidden]] }), + event("dm", { tags: [["h", dm]] }), + event("plaza", { tags: [["h", plaza]] }), + ], + }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + await settle(); + expect(woke(run)).toEqual(["plaza"]); +}); + it("holds a public channel's catch-up until its metadata is known, and hears it in a watch of every channel", async () => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); try { From ceab1be95a3751e1c5630e19aa6f86f4f148491f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 13:13:42 -0500 Subject: [PATCH 09/32] fix(agents): hear a public channel the agent is not in only once its metadata is fresh Saved metadata may say public of a channel made private while Buzz was closed. A fresh roster used to make it count, so the owner's stream could hand the agent private events. The store now marks metadata it has only from disk, and such a channel holds a non-member's events until the relay confirms it. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/agents/service.test.ts | 42 +++++++++++++++++++++++++++++ src/features/agents/service.ts | 8 ++++-- src/features/relay/contracts.ts | 3 +++ src/features/relay/startup.test.ts | 13 +++++++++ src/features/relay/store.ts | 21 +++++++++++++-- 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 3f054e15b3..face7025eb 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -8803,6 +8803,48 @@ it("does not listen in a public channel it is not in that is hidden or a DM", as expect(woke(run)).toEqual(["plaza"]); }); +it.each([ + ["private", []], + ["public", ["lobby"]], +] as const)( + "holds an event of a public channel it is not in while the metadata is only saved, then hears it as fresh metadata says (%s)", + async (fresh, wakes) => { + const { service, run, emit, session, confirm } = await setup(); + // Saved metadata says public; the owner's roster is fresh, and the agent + // is not in it. The relay has not confirmed the metadata yet. + let metadata: "saved" | "private" | "public" = "saved"; + const list = session.channels.list; + vi.spyOn(session.channels, "list").mockImplementation(() => { + const current = list(); + return { + ...current, + channels: current.channels.map((row) => + row.id !== lobby || metadata === "private" + ? row + : { + ...row, + visibility: "public" as const, + ...(metadata === "saved" + ? { metadataCached: true as const } + : {}), + }, + ), + }; + }); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { attentionEnabled: true, attention: everything }); + emit({ events: [event("lobby", { tags: [["h", lobby]] })] }); + await settle(); + expect(run).not.toHaveBeenCalled(); + metadata = fresh; + confirm(lobby, [viewer]); + await settle(); + if (wakes.length) + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + expect(woke(run)).toEqual(wakes); + }, +); + it("holds a public channel's catch-up until its metadata is known, and hears it in a watch of every channel", async () => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); try { diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 351c252844..a35b34edea 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -6121,7 +6121,9 @@ export class AgentsService extends Service implements Agents { /** Whether the agent may read the event's channel: it is in it, or the * channel is public, as its relay-signed metadata says, and neither hidden * nor a DM, so the relay lets anyone read it. The stream is the owner's, - * so a watch, even of "all", hears only what the agent itself could read. */ + * so a watch, even of "all", hears only what the agent itself could read. + * Metadata only loaded from disk decides nothing for an agent not in the + * channel (undefined): the channel may have turned private since. */ private reads( agent: Agent, event: Pick, @@ -6133,7 +6135,9 @@ export class AgentsService extends Service implements Agents { !summary.hidden && summary.channelType !== "dm" ) - return true; + return summary.metadataCached + ? this.member(agent, event) || undefined + : true; return this.member(agent, event); } /** Whether the event's channel is a DM of exactly the agent and one other, diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index b702cc5a6e..6144d911b8 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -37,6 +37,9 @@ export type ChannelSummary = Readonly<{ readOnly?: true; /** Downloaded membership is display-only pending current relay confirmation. */ cached?: true; + /** Downloaded metadata the relay has not confirmed since: the channel may + * have turned private, so its saved visibility grants no one reading. */ + metadataCached?: true; /** A Solo chat this device is starting; the relay has not confirmed it yet. */ pending?: true; /** Members-only channel omitted from directories (NIP-29 `hidden`), such as a DM. */ diff --git a/src/features/relay/startup.test.ts b/src/features/relay/startup.test.ts index 53f32e3c21..639118c306 100644 --- a/src/features/relay/startup.test.ts +++ b/src/features/relay/startup.test.ts @@ -257,6 +257,8 @@ describe("device-local startup", () => { expect(owner.session.live.snapshot().roster.state).toBe("pending"); expect(channels.get?.("alpha")?.name).toBe("Alpha"); expect(channels.get?.("alpha")?.archived).toBeUndefined(); + // A failed refresh confirmed nothing of the saved metadata. + expect(channels.get?.("alpha")?.metadataCached).toBe(true); } finally { names.resolve([fresh]); } @@ -304,6 +306,12 @@ describe("device-local startup", () => { if (!included) { await vi.waitFor(() => expect(query).toHaveBeenCalledTimes(2)); expect(channels.get?.("alpha")?.cached).toBeUndefined(); + // The roster is fresh; the saved metadata is not confirmed yet. + expect(channels.get?.("alpha")?.metadataCached).toBe(true); + expect( + channels.list().channels.find(({ id }) => id === "alpha") + ?.metadataCached, + ).toBe(true); expect(owner.session.live.snapshot().roster.state).toBe("pending"); } } finally { @@ -317,6 +325,11 @@ describe("device-local startup", () => { archived: true, }); expect(channels.get?.("alpha")?.cached).toBeUndefined(); + expect(channels.get?.("alpha")?.metadataCached).toBeUndefined(); + expect( + channels.list().channels.find(({ id }) => id === "alpha") + ?.metadataCached, + ).toBeUndefined(); expect( query.mock.calls .flatMap(([filters]) => filters) diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 99bd1d5a47..38414e1ac9 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -387,6 +387,9 @@ export function createChannelStore( ? true : undefined; } + function savedMetadata(id: string) { + return pendingMetadata.has(id) ? (true as const) : undefined; + } function setList(next: ChannelList, discoveryChanged = false) { const previous = new Map( list.channels.map((channel) => [channel.id, channel]), @@ -396,6 +399,7 @@ export function createChannelStore( if (!retained.has(id)) checkedMetadata.delete(id); const channels = next.channels.map((channel) => { const pending = metadataPending(channel.id); + const metadataCached = savedMetadata(channel.id); const preview = windows.get(channel.id)?.preview ?? tails.peek(channel.id)?.preview ?? @@ -405,6 +409,7 @@ export function createChannelStore( old.name === channel.name && old.description === channel.description && old.metadataPending === pending && + old.metadataCached === metadataCached && old.visibility === channel.visibility && old.joinRole === channel.joinRole && old.viewerRole === channel.viewerRole && @@ -425,7 +430,12 @@ export function createChannelStore( sameKeys(old.owners, channel.owners) && sameKeys(old.participants, channel.participants) ? old - : Object.freeze({ ...channel, metadataPending: pending, preview }); + : Object.freeze({ + ...channel, + metadataPending: pending, + ...(metadataCached ? { metadataCached } : {}), + preview, + }); }); const sameChannels = channels.length === list.channels.length && @@ -3278,7 +3288,14 @@ export function createChannelStore( list: () => list, get: (id: string) => { const channel = discovery?.get(id); - return channel && { ...channel, metadataPending: metadataPending(id) }; + const metadataCached = savedMetadata(id); + return ( + channel && { + ...channel, + metadataPending: metadataPending(id), + ...(metadataCached ? { metadataCached } : {}), + } + ); }, start(channel: Parameters>[0]) { if (disposed || !transport || !discovery) return; From dfb8dd62d3b4e16941cc66bb0d25af1b1ca426e0 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 13:13:44 -0500 Subject: [PATCH 10/32] fix(agents): let the live owner decide which attended channels fit The session counted each joined channel as a wire, so with about 1,020 joined channels no watched public channel went live, though joined channels share batched wires. The live owner already counts wires and ranks opened channels first, so it now admits attended channels too. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/relay-queries.md | 6 ++-- src/features/relay/attend.session.test.ts | 40 +++++++++++++++++++++++ src/features/relay/live.test.ts | 28 ++++++++++++++++ src/features/relay/session.ts | 39 +++++++++------------- 4 files changed, 88 insertions(+), 25 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 94ba7855f1..43fe181462 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -323,8 +323,10 @@ its metadata and reuses the existing channel window/exact thread owners. It does not join, persist a public head, warm all search results, or add sidebar/unread membership. Only demanded retained previews enter the existing live interest set, and the public channels agents' watches name (`session.attend`), which the session -reads metadata for first. Attended channels come after joined and demanded ones, -in the capacity those leave, and are never joined, listed or counted unread. +reads metadata for first. Attended channels are previews that the live owner +admits after joined and demanded ones, in the wires those leave (joined channels +share batched wires); past that they are limited routes. They are never joined, +listed or counted unread. Until the session knows a channel, `session.attendPending(id)` is true, also before the channel is attended: a read of it returns none of its events, so agent catch-up waits for it. A metadata read that fails, or finds nothing the diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 158479b6bc..36539c8a2f 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -344,3 +344,43 @@ it("reads again a channel that newer metadata makes private, at once and then af await vi.waitFor(() => expect(pending()).toBe(false)); await vi.waitFor(() => expect(live()).toContain(OPEN)); }); + +it("leaves to the live owner, which batches joined channels, whether an attended channel fits", async () => { + const h = harness(); + const live = () => h.update.mock.lastCall?.[0] ?? []; + const joined = () => h.update.mock.lastCall?.[1] ?? []; + // 1,020 joined channels share one wire of up to 2,048, so a wire is free + // for a public channel the viewer has not joined. + const ids = Array.from({ length: 1_020 }, (_, i) => `joined-${i}`); + h.connect(); + await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); + h.wire.next().respond( + ids.flatMap((id) => [ + signed(relay, { + kind: 39000, + content: "", + created_at: 10, + tags: [["d", id], ["name", id], ["private"]], + }), + signed(relay, { + kind: 39002, + content: "", + created_at: 10, + tags: [ + ["d", id], + ["p", viewer.pubkey], + ], + }), + ]) as RelayEvent[], + ); + await vi.waitFor(() => expect(joined()).toHaveLength(1_020)); + h.owner.session.attend([OPEN]); + const read = () => + h.wire.pending.find( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ); + await vi.waitFor(() => expect(read()).toBeDefined()); + read()?.respond(channel(OPEN, "public") as RelayEvent[]); + await vi.waitFor(() => expect(live()).toContain(OPEN)); + expect(joined()).not.toContain(OPEN); +}, 30_000); diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts index 4e5d705cd3..44b2edef84 100644 --- a/src/features/relay/live.test.ts +++ b/src/features/relay/live.test.ts @@ -2590,6 +2590,34 @@ it("bounds interests and exposes every omitted ID without exceeding 1024 subscri h.owner.dispose(); expect(vi.getTimerCount()).toBe(0); }); +it("admits an opened preview before other previews past capacity, though it sorts last", async () => { + vi.useFakeTimers(); + // Agents' watched public channels are previews too; an opened one keeps its + // wire however many of them there are. + const ids = [ + ...Array.from( + { length: 1_100 }, + (_, i) => `attended-${i.toString().padStart(4, "0")}`, + ), + "zz-opened", + ]; + const h = setup(ids); + try { + h.owner.prioritize?.(["zz-opened"]); + await h.first.auth(); + const routes = h.callbacks.state.mock.lastCall?.[0].routes ?? []; + const limited = routes + .filter((r) => r.status === "limited") + .map((r) => r.channelId); + expect(limited).not.toContain("zz-opened"); + expect(limited.length).toBeGreaterThan(0); + expect(routes.find((r) => r.channelId === "zz-opened")?.status).not.toBe( + "limited", + ); + } finally { + h.owner.dispose(); + } +}); it("surfaces invalid signatures and terminal auth failure without an automatic policy loop", async () => { vi.useFakeTimers(); const h = setup(); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 34f0923d34..705bbd3932 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -92,12 +92,7 @@ import { import { PRODUCT_FEEDBACK_KIND } from "./product-feedback"; import { attemptEnded } from "./withdraw"; import type { ReadTransport } from "./transport"; -import { - LIVE_CHANNEL_CAPACITY, - type LiveGlobal, - type LiveSnapshot, - type LiveSubscription, -} from "./live"; +import type { LiveGlobal, LiveSnapshot, LiveSubscription } from "./live"; import { hasTag, type ReadFilter, @@ -2349,8 +2344,8 @@ export function createRelaySession( * watches: a readable public channel the viewer has not joined too, whose * metadata is read first when unknown. It is not joined, listed or counted * unread, as an opened preview is not. Its events reach `subscribeLive`. - * Joined and opened channels keep their room first; past it, the rest - * are left out. */ + * The live owner admits it after joined and opened channels; past its + * capacity, it gets finite reads only. */ attend(channelIds: readonly string[]): () => void { if (closed) return () => {}; const ids = [ @@ -3257,23 +3252,21 @@ export function createRelaySession( }), ]), ]; - // Channels agents' watches listen to come last, in the room the others - // leave (two globals may take some), so they never push one out. - const room = Math.max(0, LIVE_CHANNEL_CAPACITY - 2 - ids.length); + // Channels agents' watches listen to. The live owner counts its wires + // and admits them: joined channels share batched wires, and opened ones, + // ranked first by `prioritize`, keep theirs; the rest get finite reads. const listened = new Set(ids); ids.push( - ...[...new Set([...attended].flat())] - .filter((id) => { - const channel = channels.queries.get?.(id); - return ( - !listened.has(id) && - !!channel && - channels.canAccess(id) && - !channel.cached && - !channel.pending - ); - }) - .slice(0, room), + ...[...new Set([...attended].flat())].filter((id) => { + const channel = channels.queries.get?.(id); + return ( + !listened.has(id) && + !!channel && + channels.canAccess(id) && + !channel.cached && + !channel.pending + ); + }), ); resolveAttended(); const wanted = new Set(ids); From d0ec42f71c96e40f6950042d0dd5e2968a564a5b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 13:35:40 -0500 Subject: [PATCH 11/32] fix(agents): keep watched channels live across changes, and only for agents this copy runs Changing an agent's watches stopped the old attended set before the new one went in, so channels in both lost their live route and replayed history. The new set now goes in first. A copy that does not hold an agent, or cannot run its type, no longer keeps its watched channels live; the set is refreshed when the hold changes. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/agents/service.test.ts | 67 +++++++++++++++++++++++++++++ src/features/agents/service.ts | 24 +++++++---- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index face7025eb..cbbaadf468 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -8973,6 +8973,73 @@ it("keeps the channels its enabled watches name live while attention is on", asy expect([...attended]).toEqual([]); }); +it("keeps a channel live while its watches change, never dropping one both sets name", async () => { + const { service, attended } = await setup(); + // What is attended after each attend or stop, in order. + const seen: string[][] = []; + const union = () => [...new Set([...attended].flat())].sort(); + const add = attended.add.bind(attended); + const remove = attended.delete.bind(attended); + attended.add = (ids) => { + add(ids); + seen.push(union()); + return attended; + }; + attended.delete = (ids) => { + const removed = remove(ids); + seen.push(union()); + return removed; + }; + await service.create({ type: "example/echo", name: "Echo" }); + const watch = { + type: "event", + interest_id: "default", + enabled: true, + since: 1, + kinds: [9], + } as const; + await service.save(bot, { + attentionEnabled: true, + attention: { "watch/plaza": { ...watch, channels: [plaza] } }, + }); + await settle(); + expect(union()).toContain(plaza); + expect(union()).not.toContain(lobby); + seen.length = 0; + await service.save(bot, { + attention: { "watch/plaza": { ...watch, channels: [plaza, lobby] } }, + }); + await settle(); + expect(union()).toEqual(expect.arrayContaining([lobby, plaza])); + expect(seen.length).toBeGreaterThan(0); + for (const ids of seen) expect(ids).toContain(plaza); +}); + +it("keeps no channel live for an agent another copy of the app holds", async () => { + const { service, attended, native, emit } = await setup(); + native.claim.mockResolvedValue(false); + await service.create({ type: "example/echo", name: "Echo" }); + const watch = { + type: "event", + interest_id: "default", + enabled: true, + since: 1, + kinds: [9], + } as const; + await service.save(bot, { + attentionEnabled: true, + attention: { "watch/plaza": { ...watch, channels: [plaza] } }, + }); + emit({ events: [event("1", { tags: [["p", bot]] })] }); + await vi.waitFor(() => expect(native.claim).toHaveBeenCalled()); + await settle(); + expect([...attended]).toEqual([]); + // Once this copy holds it, its watched channels go live. + native.claim.mockResolvedValue(true); + emit({ events: [event("2", { tags: [["p", bot]] })] }); + await vi.waitFor(() => expect([...attended].flat()).toContain(plaza)); +}); + it("answers an unmentioned one-to-one DM as conversation even when a watch matches it", async () => { const { service, run, emit } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index a35b34edea..ecea780348 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -1929,7 +1929,11 @@ export class AgentsService extends Service implements Agents { /** The catch-up progress this program last read or saved. */ let savedProgress: Checkpoints = {}; this.runners = new AgentRunners({ - ownershipChanged: () => this.notify(), + // Only the copy that runs an agent keeps its watched channels live. + ownershipChanged: () => { + this.attend(this.state.running); + this.notify(); + }, ready: (key) => this.ready(key), agent: (key) => this.agent(key), member: (agent, event) => this.member(agent, event), @@ -5986,10 +5990,10 @@ export class AgentsService extends Service implements Agents { ); } - /** Keeps live, in each community, the channels that its running agents' - * enabled watches name, so a watch also hears a public channel the owner - * has not joined. A watch of "all" hears what the session already listens - * to. */ + /** Keeps live, in each community, the channels that the enabled watches + * of the agents this copy runs name, so a watch also hears a public channel + * the owner has not joined. A watch of "all" hears what the session already + * listens to. A copy that does not hold an agent leaves its channels out. */ private attend(running: readonly Agent[]) { const wanted = new Map>(); for (const { binding } of this.bound.values()) @@ -5997,7 +6001,8 @@ export class AgentsService extends Service implements Agents { for (const agent of running) { const channels = this.bindingOf(agent); const ids = channels && wanted.get(channels); - if (!ids || !agent.attentionEnabled) continue; + if (!ids || !agent.attentionEnabled || !this.runners.runs(agent.key)) + continue; for (const { value } of Object.values(agent.attention)) if (value.type === "event" && value.enabled && value.channels !== "all") for (const id of value.channels) ids.add(id); @@ -6005,10 +6010,13 @@ export class AgentsService extends Service implements Agents { for (const [binding, ids] of wanted) { const key = JSON.stringify([...ids].sort()); if (binding.attending?.key === key) continue; - binding.attending?.stop(); - delete binding.attending; + // The new set goes in before the old one goes out, so a channel in + // both keeps its live route. + const previous = binding.attending; if (ids.size) binding.attending = { key, stop: binding.session.attend([...ids]) }; + else delete binding.attending; + previous?.stop(); } } From 12871e909321abf0aa4a1a85af1aa3dd3121c237 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 14:07:16 -0500 Subject: [PATCH 12/32] fix(agents): hold a non-member's event while its channel's metadata is unconfirmed Only saved public metadata held events. Metadata not read yet, or saved as private for a channel since made public, dropped them, and catch-up moved past them. Any unconfirmed metadata now holds a non-member's event until the relay confirms it; a member's goes through. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/agents/service.test.ts | 41 +++++++++++++++++------------ src/features/agents/service.ts | 17 ++++++------ 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index cbbaadf468..27f68dd52b 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -8804,31 +8804,38 @@ it("does not listen in a public channel it is not in that is hidden or a DM", as }); it.each([ - ["private", []], - ["public", ["lobby"]], + ["saved public", "private", []], + ["saved public", "public", ["lobby"]], + ["saved private", "public", ["lobby"]], + ["not read yet", "public", ["lobby"]], ] as const)( - "holds an event of a public channel it is not in while the metadata is only saved, then hears it as fresh metadata says (%s)", - async (fresh, wakes) => { + "holds an event of a channel it is not in while the metadata is %s, then hears it as fresh metadata says (%s)", + async (saved, fresh, wakes) => { const { service, run, emit, session, confirm } = await setup(); - // Saved metadata says public; the owner's roster is fresh, and the agent - // is not in it. The relay has not confirmed the metadata yet. + // The owner's roster is fresh, and the agent is not in it. The relay has + // not confirmed the channel's metadata yet. let metadata: "saved" | "private" | "public" = "saved"; const list = session.channels.list; vi.spyOn(session.channels, "list").mockImplementation(() => { const current = list(); return { ...current, - channels: current.channels.map((row) => - row.id !== lobby || metadata === "private" - ? row - : { - ...row, - visibility: "public" as const, - ...(metadata === "saved" - ? { metadataCached: true as const } - : {}), - }, - ), + channels: current.channels.map((row) => { + if (row.id !== lobby) return row; + const { visibility: _, ...rest } = row; + if (metadata === "private") return rest; + if (metadata === "public") + return { ...rest, visibility: "public" as const }; + if (saved === "not read yet") + return { ...rest, metadataPending: true }; + return { + ...rest, + ...(saved === "saved public" + ? { visibility: "public" as const } + : {}), + metadataCached: true as const, + }; + }), }; }); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index ecea780348..c19224478e 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -6130,23 +6130,22 @@ export class AgentsService extends Service implements Agents { * channel is public, as its relay-signed metadata says, and neither hidden * nor a DM, so the relay lets anyone read it. The stream is the owner's, * so a watch, even of "all", hears only what the agent itself could read. - * Metadata only loaded from disk decides nothing for an agent not in the - * channel (undefined): the channel may have turned private since. */ + * For an agent not in the channel, metadata not yet read, or only loaded + * from disk, decides nothing (undefined): the channel may have turned + * public or private since. */ private reads( agent: Agent, event: Pick, ): boolean | undefined { + const member = this.member(agent, event); + if (member !== false) return member; const summary = this.channelOf(agent, event); - if ( - !summary?.cached && + if (summary?.metadataCached || summary?.metadataPending) return undefined; + return ( summary?.visibility === "public" && !summary.hidden && summary.channelType !== "dm" - ) - return summary.metadataCached - ? this.member(agent, event) || undefined - : true; - return this.member(agent, event); + ); } /** Whether the event's channel is a DM of exactly the agent and one other, * by the same roster. A group DM, or one whose roster is unknown, is not. */ From 762290a80e9d42daab196d387c7d0ef3894fd594 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 14:07:16 -0500 Subject: [PATCH 13/32] fix(agents): read attended channels' metadata in the background lane Attendance lookups used the default foreground priority, so three batches could fill every reader slot and delay an interactive read. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/relay-queries.md | 2 +- src/features/relay/attend.session.test.ts | 20 ++++++++++++++++++++ src/features/relay/session.ts | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 43fe181462..aa06804bd7 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -323,7 +323,7 @@ its metadata and reuses the existing channel window/exact thread owners. It does not join, persist a public head, warm all search results, or add sidebar/unread membership. Only demanded retained previews enter the existing live interest set, and the public channels agents' watches name (`session.attend`), which the session -reads metadata for first. Attended channels are previews that the live owner +reads metadata for first, as background reads. Attended channels are previews that the live owner admits after joined and demanded ones, in the wires those leave (joined channels share batched wires); past that they are limited routes. They are never joined, listed or counted unread. diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 36539c8a2f..e1a52068c4 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -384,3 +384,23 @@ it("leaves to the live owner, which batches joined channels, whether an attended await vi.waitFor(() => expect(live()).toContain(OPEN)); expect(joined()).not.toContain(OPEN); }, 30_000); + +it("reads unknown attended channels one batch at a time, in the background lane", async () => { + const h = harness(); + const ids = Array.from( + { length: 257 }, + (_, i) => `attended-${i.toString().padStart(3, "0")}`, + ); + const lookups = () => + h.wire.pending.filter( + (entry) => + !entry.signal?.aborted && ids.some((id) => asks(entry.filters, id)), + ); + await h.discover(); + h.owner.session.attend(ids); + await vi.waitFor(() => expect(lookups()).toHaveLength(1)); + await flush(); + // Three batches, but only one background read at a time: the rest of the + // reader stays free for what the owner opens. + expect(lookups()).toHaveLength(1); +}); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 705bbd3932..7540cb98b5 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1356,7 +1356,8 @@ export function createRelaySession( for (let start = 0; start < fresh.length; start += 128) { const batch = fresh.slice(start, start + 128); void channels.queries - .resolve(batch, { signal: lifetime.signal }) + // In the background lane, so an interactive read never waits behind it. + .resolve(batch, { signal: lifetime.signal, priority: "background" }) .catch(() => {}) .finally(() => { for (const id of batch) attendReading.delete(id); From d3fc416ecafd4469a18e652371b389b19ec182e5 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 15:53:11 -0500 Subject: [PATCH 14/32] fix(agents): keep mention turns as without watches' attendance Attendance keeps live, and looks up, public channels that agents' watches name. An addressed event from one of those channels made a mention turn, which main never gave because the owner's app never heard that channel. The session now records which channels it hears only for agents: channels the live routes carry only because they are attended, and channels it knows only from attendance lookups or from a read of every channel that resolves kinds the owner's reads would not. Mentions and one-to-one DM turns come only from other channels. Watches still hear these events. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/agents.md | 2 +- docs/relay-queries.md | 6 ++ src/features/agents/runner.ts | 25 ++++-- src/features/agents/service-testing.ts | 8 ++ src/features/agents/service.test.ts | 95 +++++++++++++++++++++++ src/features/agents/service.ts | 5 ++ src/features/relay/attend.session.test.ts | 64 +++++++++++++++ src/features/relay/session.ts | 49 ++++++++---- 8 files changed, 232 insertions(+), 22 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index c4e0a1f25a..55fe7d2ca2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1590,7 +1590,7 @@ The bundled Agents plugin owns the command producer and discovery tool. The GitH ## Attention delivery destinations -Event watches and timers accept an optional delivery `channel` and thread `root`. The event watch's `channels` still selects where to listen: channels the agent is a member of, and public channels it has not joined (`all` hears those through catch-up, about every 30 seconds). A delivery address needs membership; the relay decides where the agent may post. With an address, the wake continues that conversation with the Interest's instructions. A thread root must be readable by the agent in that destination channel; a missing or mismatched root stops delivery before the harness builds context. Claude, Codex, Goose and Pi accept these Attention triggers. A channel without a root defaults to channel-level posting, while explicit reporting instructions in the Interest can direct another thread or channel within the agent's existing authority. Solo chats retain their personal-chat framing and reply restriction. The address owns the execution conversation and default destination; the Interest owns explicit reporting policy. +Event watches and timers accept an optional delivery `channel` and thread `root`. The event watch's `channels` still selects where to listen: channels the agent is a member of, and public channels it has not joined (`all` hears those through catch-up, about every 30 seconds). A delivery address needs membership; the relay decides where the agent may post. Watches do not change who a mention wakes: a channel the owner's app hears only because a watch names it gives no mention turn, though the watch still hears its events. With an address, the wake continues that conversation with the Interest's instructions. A thread root must be readable by the agent in that destination channel; a missing or mismatched root stops delivery before the harness builds context. Claude, Codex, Goose and Pi accept these Attention triggers. A channel without a root defaults to channel-level posting, while explicit reporting instructions in the Interest can direct another thread or channel within the agent's existing authority. Solo chats retain their personal-chat framing and reply restriction. The address owns the execution conversation and default destination; the Interest owns explicit reporting policy. Without an address, the subscription runs an independent turn in its Interest's lane. Timers retain their occurrence and expiry budgets. An addressed timer waits without spending an occurrence while destination membership is unconfirmed or absent. `watch_show` reports delivery as held or blocked with a reason; delivery resumes once membership is confirmed. Expiry still applies while held. Membership checks never join channels. The Attention panel shows listening channels separately from the delivery destination. diff --git a/docs/relay-queries.md b/docs/relay-queries.md index aa06804bd7..f973d13144 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -336,6 +336,12 @@ that starts at 30 s and doubles up to 5 minutes, and after a reconnect or a new every channel with `resolveChannels` resolves the channels its events name, of any kinds, before visibility filtering, as ranked search does for chat kinds; before discovery it fails and is tried again, rather than drop what it finds. +`session.unattended(id, live)` says whether an event of a channel reached the +session as it would have with neither: false for a channel known only from those +lookups, and, for a live event, one the live routes carry only because it is +attended. Agents take mentions and one-to-one DM turns only from events for +which it is true, so watches do not change who a mention wakes; they still hear +the rest. Shared composers and reactions remain unavailable for nonmembers; message helpers and workflows do not acquire write eligibility from public metadata. The relay remains the final write authority, including low-level outbox intent. diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index fdbaedd035..340eedfabb 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -421,6 +421,9 @@ export type RunnerHost = { reads(agent: Agent, event: Pick): boolean | undefined; /** Whether the event's channel is a DM between the agent and one other. */ dm(agent: Agent, event: RelayEvent): boolean; + /** Whether the event came as it would have with no watch keeping channels + * live or looking them up; one heard `live`, or read by catch-up. */ + unattended(agent: Agent, event: RelayEvent, live: boolean): boolean; /** While the owner is still starting this chat, settles once it can take * the agent's events; undefined otherwise. */ starting?(agent: Agent, channelId: string): Promise | undefined; @@ -1603,7 +1606,7 @@ export class AgentRunners { (!fanning && this.hasSeen(runner, event.id, channel)) || event.pubkey === agent.pubkey ? { triggers: [] } - : this.heard(runner, agent, event, fanning?.done); + : this.heard(runner, agent, event, fanning?.done, false); if (heard.discarded?.length) { this.heardLive(agent, event, new Set(heard.discarded)); runner.fanning = { @@ -2045,30 +2048,38 @@ export class AgentRunners { }); return pass ? result : undefined; } - /** What `event` wakes now, and whether other matching watches still wait - * for its channel's roster to be confirmed. */ + /** What `event`, heard `live` or read by catch-up, wakes now, and whether + * other matching watches still wait for its channel's roster to be + * confirmed. */ private heard( runner: Runner, agent: Agent, event: RelayEvent, done: ReadonlySet = new Set(), + live = true, ): Heard { if (this.stopped[runner.key]?.[event.id] !== undefined) return { triggers: [] }; + // Mentions wake an agent only in what the owner's app hears for the + // owner. An event it has only because watches keep its channel live, or + // looked the channel up, addresses no one: only watches hear it. + const addressable = this.host.unattended(agent, event, live); // A one-to-one DM is always addressed to the other member, whoever else it // names, so no mention is needed. Only new messages: an edit is not a new // turn, and a reaction is not conversation. A group DM still needs the // mention. if ( + addressable && event.kind === 9 && event.pubkey !== agent.pubkey && this.host.dm(agent, event) ) return { triggers: [{ type: "mention", event }] }; - // Addressed events never reach a watch. Only conversation starts a run, not - // an encrypted DM (kind 4) it cannot read yet. A reaction to its message is - // not addressed, so a watch can still hear it. - if (addressedTo(event, agent.pubkey)) + // An addressed event the owner's app hears for the owner never reaches a + // watch. Only conversation starts a run, not an encrypted DM (kind 4) it + // cannot read yet. A reaction to its message is not addressed, so a watch + // can still hear it. + if (addressable && addressedTo(event, agent.pubkey)) return CHAT_KINDS.includes(event.kind) ? { triggers: [{ type: "mention", event }] } : { triggers: [] }; diff --git a/src/features/agents/service-testing.ts b/src/features/agents/service-testing.ts index d9e6b6ddbb..1a483106bf 100644 --- a/src/features/agents/service-testing.ts +++ b/src/features/agents/service-testing.ts @@ -102,6 +102,8 @@ export function fakeRelay(origin = "https://relay.example.test") { const pending = new Set(); /** Channels of the list live does not route. */ const unrouted = new Set(); + /** Channels it knows only from agents' lookups. */ + const learned = new Set(); const statuses = new Set<() => void>(); /** The viewer's kind 30177 records the community returns. */ const listed: RelayEvent[] = []; @@ -173,6 +175,11 @@ export function fakeRelay(origin = "https://relay.example.test") { return () => attended.delete(ids); }), attendPending: (id: string) => pending.has(id), + // The list's channels are the viewer's; attended ones live only for agents. + unattended: (id: string, live: boolean) => + rows.some((row) => row.id === id) || + (!learned.has(id) && + (!live || ![...attended].some((ids) => ids.includes(id)))), live: { // Each channel of the list is live while connected, unless `unrouted`. snapshot: () => ({ @@ -274,6 +281,7 @@ export function fakeRelay(origin = "https://relay.example.test") { listed, unrouted, pending, + learned, history, memories, session, diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 27f68dd52b..611b25f5bc 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -339,6 +339,7 @@ async function setup({ reconnecting: fake.reconnecting, recover: fake.recover, unrouted: fake.unrouted, + learned: fake.learned, pending: fake.pending, memories: fake.memories, archives: fake.archives, @@ -8605,6 +8606,100 @@ it("answers a mention catch-up reads from while it listened, not one from before } }); +it.each([ + ["its watch does not match it, nothing", [7], []], + ["its watch matches it, the watch", [9], ["watch"]], +] as const)( + "gives no mention turn from a channel the app hears only for a watch: when %s wakes", + async (_, kinds, types) => { + const { service, run, emit, attended } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { + attentionEnabled: true, + attention: { + "watch/plaza": { + type: "event", + interest_id: "default", + enabled: true, + since: 1, + channels: [plaza], + kinds: [...kinds], + }, + }, + }); + await vi.waitFor(() => expect([...attended].flat()).toContain(plaza)); + // Without the watch, the app would not hear plaza: it is neither joined + // nor opened. A mention in a channel the owner is in still wakes it. + emit({ + events: [ + event("plaza", { + tags: [ + ["h", plaza], + ["p", bot], + ], + }), + event("open", { + tags: [ + ["h", channel], + ["p", bot], + ], + }), + ], + }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1 + types.length)); + await settle(); + expect( + run.mock.calls.map(([delivery]) => [ + delivery.trigger.type, + delivery.trigger.type === "mention" || delivery.trigger.type === "watch" + ? delivery.trigger.event.id.replace(/0+$/, "") + : undefined, + ]), + ).toEqual( + expect.arrayContaining([ + ["mention", "open"], + ...types.map((type) => [type, "plaza"]), + ]), + ); + expect(run).toHaveBeenCalledTimes(1 + types.length); + }, +); + +it.each([ + ["the owner's app knew it", false, "mention"], + ["only agents' lookups found it", true, "catch-up"], +] as const)( + "answers a mention catch-up reads in a public channel only if %s", + async (_, learnedOnly, type) => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const start = nowSeconds(); + const { service, run, history, recover, learned } = await setup({ + identities: [echo], + }); + await service.save(bot, { + attentionEnabled: true, + attention: { + "watch/all": { ...chat["watch/chat"], channels: "all" }, + }, + }); + await settle(); + if (learnedOnly) learned.add(plaza); + vi.setSystemTime((start + 60) * 1000); + history.push( + eventDto(message(author, plaza, "new", start + 30, [["p", bot]])), + ); + recover(); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + await settle(); + expect(heard(run)).toEqual(["new"]); + expect(run.mock.calls[0]?.[0]).toMatchObject({ trigger: { type } }); + } finally { + vi.useRealTimers(); + } + }, +); + it("wakes nothing from the time its attention was off", async () => { vi.useFakeTimers({ toFake: ["Date"] }); try { diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index c19224478e..2bf3402881 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -1939,6 +1939,11 @@ export class AgentsService extends Service implements Agents { member: (agent, event) => this.member(agent, event), reads: (agent, event) => this.reads(agent, event), dm: (agent, event) => this.dm(agent, event), + unattended: (agent, event, live) => { + const channel = event.tags.find((tag) => tag[0] === "h")?.[1]; + const session = this.bindingOf(agent)?.session; + return !channel || !session || session.unattended(channel, live); + }, starting: (agent, channelId) => this.bindingOf(agent)?.session.channelWrites.starting(channelId), community: (agent) => this.bindingOf(agent), diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index e1a52068c4..7cb58d6de8 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -221,6 +221,70 @@ it("resolves the channels a read of every channel finds, of any kinds, once disc expect((await reading).map(({ kind }) => kind).sort()).toEqual([7, 9]); }); +it.each([ + ["of chat, as any read of messages does, the viewer's own", [9], true], + ["of other kinds too, only agents'", [9, 7], false], +] as const)( + "counts a channel a read of every channel resolves %s", + async (_, kinds, viewers) => { + const h = harness(); + await h.discover(); + expect(h.owner.session.unattended(OPEN, false)).toBe(true); + const reading = h.owner.session.read( + [{ kinds: [...kinds], since: 11, until: 20, limit: 16 }], + { fresh: true, resolveChannels: true }, + ); + const asked = () => + h.wire.pending.findIndex((entry) => + entry.filters.some((filter) => filter.kinds?.includes(9)), + ); + await vi.waitFor(() => expect(asked()).not.toBe(-1)); + h.wire.pending.splice(asked(), 1)[0]?.respond([ + signed(other, { + kind: 9, + content: "message", + created_at: 15, + tags: [["h", OPEN]], + }), + ] as RelayEvent[]); + await vi.waitFor(() => + expect(h.wire.pending.some((entry) => asks(entry.filters, OPEN))).toBe( + true, + ), + ); + h.wire.pending + .find((entry) => asks(entry.filters, OPEN)) + ?.respond(channel(OPEN, "public") as RelayEvent[]); + expect(await reading).toHaveLength(1); + // Not attended, so live says the same; catch-up reads tell them apart. + expect(h.owner.session.unattended(OPEN, false)).toBe(viewers); + expect(h.owner.session.unattended(OPEN, true)).toBe(viewers); + }, +); + +it("counts an attended channel the viewer has not joined or opened as heard only for agents, live and read", async () => { + const h = harness(); + const stop = h.owner.session.attend([OPEN]); + await h.discover(); + await vi.waitFor(() => + expect(h.wire.pending.some((entry) => asks(entry.filters, OPEN))).toBe( + true, + ), + ); + h.wire.pending + .find((entry) => asks(entry.filters, OPEN)) + ?.respond(channel(OPEN, "public") as RelayEvent[]); + await vi.waitFor(() => + expect(h.update.mock.lastCall?.[0] ?? []).toContain(OPEN), + ); + expect(h.owner.session.unattended(OPEN, true)).toBe(false); + // Its metadata came only from the attendance's lookup. + expect(h.owner.session.unattended(OPEN, false)).toBe(false); + // A channel no agent attends is heard as without attendance. + expect(h.owner.session.unattended(CLOSED, true)).toBe(true); + stop(); +}); + it("does not answer a read of every channel that resolves its channels before discovery, rather than drop what it cannot place", async () => { const h = harness(); // Discovery has not succeeded: its roster read failed. diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 7540cb98b5..c985eaf12e 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -686,20 +686,16 @@ export function createRelaySession( // So does a read of every channel that asks for it, of any kinds. const chat = (kind: number) => [9, 40002, 40008].includes(kind); const broad = !!settings?.resolveChannels; - if ( - channelTraffic && - filters.every( - (filter) => - (broad || - filter.search !== undefined || - !!filter.authors?.length || - filter.since !== undefined || - filter.until !== undefined) && - !filter["#h"]?.length && - !!filter.kinds?.length && - (broad || filter.kinds.every(chat)), - ) - ) { + const resolves = (filter: ReadFilter, broad: boolean) => + (broad || + filter.search !== undefined || + !!filter.authors?.length || + filter.since !== undefined || + filter.until !== undefined) && + !filter["#h"]?.length && + !!filter.kinds?.length && + (broad || filter.kinds.every(chat)); + if (channelTraffic && filters.every((filter) => resolves(filter, broad))) { // A lookup may extend a discovered list, never stand in for it. if (broad && channels.queries.list().status !== "ready") throw new ReadError("unavailable", "Channels are not discovered yet."); @@ -715,6 +711,11 @@ export function createRelaySession( }), ), ]; + // What only `resolveChannels` looks up is known for agents alone. + if (!filters.every((filter) => resolves(filter, false))) { + for (const id of ids) + if (!channels.queries.get?.(id)) learnedForAgents.add(id); + } else for (const id of ids) learnedForAgents.delete(id); await channels.queries.resolve?.(ids, settings); // A read of every channel keeps what visibility keeps below. if (!broad) @@ -1292,6 +1293,14 @@ export function createRelaySession( const followRevocations = new Set<() => void>(); /** Channels each `attend` keeps live for agents' watches, its own list. */ const attended = new Set(); + /** The channels the live routes carry for the viewer: joined and opened, + * as without any `attend`. */ + let viewerLive: ReadonlySet = new Set(); + /** Channels the session knows only because agents' watches looked them + * up: an attended channel's metadata read, or a read of every channel that + * resolved channels the viewer's own reads would not. One leaves once the + * viewer's traffic knows it too: joined, opened or looked up. */ + const learnedForAgents = new Set(); /** Attended channels read since the last wait ended, in flight or done. * One known again leaves it, so it is read at once if it is unknown again. */ const attendChecked = new Set(); @@ -1352,6 +1361,7 @@ export function createRelaySession( for (const id of fresh) { attendChecked.add(id); attendReading.add(id); + learnedForAgents.add(id); } for (let start = 0; start < fresh.length; start += 128) { const batch = fresh.slice(start, start + 128); @@ -2372,6 +2382,15 @@ export function createRelaySession( const channel = channels.queries.get?.(channelId); return !channel || !!channel.pending; }, + /** Whether an event of `channelId` reaches agents as it would with no + * `attend` and no `resolveChannels` read: not from a channel the session + * knows only from agents' lookups, nor, heard `live`, one the live routes + * carry only because it is attended. */ + unattended(channelId: string, live: boolean): boolean { + if (viewerLive.has(channelId)) return true; + if (learnedForAgents.has(channelId)) return false; + return !live || ![...attended].some((ids) => ids.includes(channelId)); + }, /** Verified, access-filtered live-phase events of any kind, after reconciliation. * Never finite reads or local intent, except the viewer's own messages in * a Solo chat this device is starting: those arrive here before the relay @@ -3257,6 +3276,8 @@ export function createRelaySession( // and admits them: joined channels share batched wires, and opened ones, // ranked first by `prioritize`, keep theirs; the rest get finite reads. const listened = new Set(ids); + viewerLive = listened; + for (const id of listened) learnedForAgents.delete(id); ids.push( ...[...new Set([...attended].flat())].filter((id) => { const channel = channels.queries.get?.(id); From 5c984e02bdb0e55af2a9eec17533736fac0090bb Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 17:39:15 -0500 Subject: [PATCH 15/32] fix(agents): keep a mention's provenance from arrival, and the owner's lookup only once it succeeds An event's addressability is now recorded when it arrives: a replay that waits in the backlog, an event held for its roster, and a catch-up event that waits for room keep it, so closing a preview or finishing a lookup meanwhile does not change what the event wakes. A channel that only agents' reads found becomes the owner's once the owner's own lookup succeeds, not when it starts: a failed or cancelled lookup would have left it unknown without agents. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/relay-queries.md | 5 +- src/features/agents/runner.ts | 58 +++++++++++---- src/features/agents/service-testing.ts | 4 ++ src/features/agents/service.test.ts | 88 +++++++++++++++++++++-- src/features/relay/attend.session.test.ts | 66 +++++++++++++++++ src/features/relay/session.ts | 7 +- 6 files changed, 205 insertions(+), 23 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index f973d13144..4037c6eaa2 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -340,8 +340,9 @@ before discovery it fails and is tried again, rather than drop what it finds. session as it would have with neither: false for a channel known only from those lookups, and, for a live event, one the live routes carry only because it is attended. Agents take mentions and one-to-one DM turns only from events for -which it is true, so watches do not change who a mention wakes; they still hear -the rest. +which it is true when it arrived, kept while the event waits, so watches do not +change who a mention wakes; they still hear the rest. A channel leaves the +agents-only set once a lookup of the viewer's own succeeds, not when it starts. Shared composers and reactions remain unavailable for nonmembers; message helpers and workflows do not acquire write eligibility from public metadata. The relay remains the final write authority, including low-level outbox intent. diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 340eedfabb..9ce4d913a9 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -257,6 +257,10 @@ type PendingEvent = { event: RelayEvent; channelId?: string; done?: ReadonlySet; + /** Whether it arrived as it would have with no watch keeping channels live + * (`unattended`), kept from arrival so a channel closed while it waits does + * not change what it wakes. */ + addressable: boolean; }; type Heard = { triggers: Trigger[]; held?: true; discarded?: string[] }; @@ -551,6 +555,9 @@ export type RunnerHost = { }; export class AgentRunners { + /** Whether each event catch-up read may address its agent, as `unattended` + * said when it was read: one waiting for room keeps what it arrived as. */ + private readonly readAddressable = new WeakMap(); private readonly sourceJobs = new Map(); private readonly failedSources = new WeakSet(); /** A terminal producer retains its registration until every queued turn settles. */ @@ -984,6 +991,7 @@ export class AgentRunners { runner.backlog.push({ event, ...(batch.channelId ? { channelId: batch.channelId } : {}), + addressable: this.host.unattended(agent, event, true), }); } if (overflow) @@ -1106,16 +1114,16 @@ export class AgentRunners { this.submit(runner, agent, job); } while (runner.backlog.length) { - const { event, channelId, done } = runner + const { event, channelId, done, addressable } = runner .backlog[0] as Runner["backlog"][0]; - const heard = this.heard(runner, agent, event, done); + const heard = this.heard(runner, agent, event, addressable, done); if ( (heard.held && runner.held.size >= HELD_LIMIT) || !this.takes(runner, heard.triggers) ) return; runner.backlog.shift(); - this.deliver(runner, agent, event, channelId, heard, done); + this.deliver(runner, agent, event, channelId, addressable, heard, done); } } /** Gives the agent `event` once, however many times it arrives. */ @@ -1129,7 +1137,13 @@ export class AgentRunners { if (this.hasSeen(runner, event.id, channel)) return; this.markSeen(runner, event.id, channel); if (this.isOwn(agent, event)) return; - this.deliver(runner, agent, event, channelId); + this.deliver( + runner, + agent, + event, + channelId, + this.host.unattended(agent, event, true), + ); } /** Saves `event` as heard by each of the agent's streams it is in and has * not passed yet, so catch-up after a restart does not give it again. */ @@ -1474,7 +1488,14 @@ export class AgentRunners { signal.throwIfAborted(); // Only what the relay signed: a read also returns the owner's own // messages not yet sent, which can be older than its page. - return events.filter(hasEventProof) as RelayEvent[]; + const signed = events.filter(hasEventProof) as RelayEvent[]; + const agent = this.host.agent(runner.key); + for (const event of signed) + this.readAddressable.set( + event, + !!agent && this.host.unattended(agent, event, false), + ); + return signed; }; const progress = (key: string, checkpoint: Checkpoint) => { this.setCheckpoint(key, checkpoint); @@ -1606,7 +1627,14 @@ export class AgentRunners { (!fanning && this.hasSeen(runner, event.id, channel)) || event.pubkey === agent.pubkey ? { triggers: [] } - : this.heard(runner, agent, event, fanning?.done, false); + : this.heard( + runner, + agent, + event, + this.readAddressable.get(event) ?? + this.host.unattended(agent, event, false), + fanning?.done, + ); if (heard.discarded?.length) { this.heardLive(agent, event, new Set(heard.discarded)); runner.fanning = { @@ -1806,7 +1834,8 @@ export class AgentRunners { agent: Agent, event: RelayEvent, channelId: string | undefined, - heard = this.heard(runner, agent, event), + addressable: boolean, + heard = this.heard(runner, agent, event, addressable), done: ReadonlySet = new Set(), ) { const { triggers } = heard; @@ -1822,6 +1851,7 @@ export class AgentRunners { event, ...(channelId ? { channelId } : {}), done: completed, + addressable, }); if (runner.held.size > HELD_LIMIT) { runner.held.delete(runner.held.keys().next().value as string); @@ -2048,22 +2078,20 @@ export class AgentRunners { }); return pass ? result : undefined; } - /** What `event`, heard `live` or read by catch-up, wakes now, and whether - * other matching watches still wait for its channel's roster to be - * confirmed. */ + /** What `event` wakes now, and whether other matching watches still wait + * for its channel's roster to be confirmed. Mentions wake an agent only in + * what the owner's app hears for the owner: an event it has only because + * watches keep its channel live, or looked the channel up, is not + * `addressable`, and only watches hear it. */ private heard( runner: Runner, agent: Agent, event: RelayEvent, + addressable: boolean, done: ReadonlySet = new Set(), - live = true, ): Heard { if (this.stopped[runner.key]?.[event.id] !== undefined) return { triggers: [] }; - // Mentions wake an agent only in what the owner's app hears for the - // owner. An event it has only because watches keep its channel live, or - // looked the channel up, addresses no one: only watches hear it. - const addressable = this.host.unattended(agent, event, live); // A one-to-one DM is always addressed to the other member, whoever else it // names, so no mention is needed. Only new messages: an edit is not a new // turn, and a reaction is not conversation. A group DM still needs the diff --git a/src/features/agents/service-testing.ts b/src/features/agents/service-testing.ts index 1a483106bf..00942648ac 100644 --- a/src/features/agents/service-testing.ts +++ b/src/features/agents/service-testing.ts @@ -104,6 +104,8 @@ export function fakeRelay(origin = "https://relay.example.test") { const unrouted = new Set(); /** Channels it knows only from agents' lookups. */ const learned = new Set(); + /** Channels the viewer has open, live as the list's are. */ + const opened = new Set(); const statuses = new Set<() => void>(); /** The viewer's kind 30177 records the community returns. */ const listed: RelayEvent[] = []; @@ -178,6 +180,7 @@ export function fakeRelay(origin = "https://relay.example.test") { // The list's channels are the viewer's; attended ones live only for agents. unattended: (id: string, live: boolean) => rows.some((row) => row.id === id) || + opened.has(id) || (!learned.has(id) && (!live || ![...attended].some((ids) => ids.includes(id)))), live: { @@ -282,6 +285,7 @@ export function fakeRelay(origin = "https://relay.example.test") { unrouted, pending, learned, + opened, history, memories, session, diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 611b25f5bc..d8ed46134e 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -340,6 +340,7 @@ async function setup({ recover: fake.recover, unrouted: fake.unrouted, learned: fake.learned, + opened: fake.opened, pending: fake.pending, memories: fake.memories, archives: fake.archives, @@ -8665,18 +8666,97 @@ it.each([ }, ); +it("answers a replayed mention that waited for room as it arrived, though its channel was closed since", async () => { + vi.useFakeTimers({ now: 1_000_000_000 }); + try { + const { service, run, emit, attended, opened } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { + attentionEnabled: true, + attention: { + "watch/plaza": { + type: "event", + interest_id: "default", + enabled: true, + since: 1, + channels: [plaza], + kinds: [7], + }, + }, + }); + await vi.waitFor(() => expect([...attended].flat()).toContain(plaza)); + // The owner has plaza open, so the app hears it for the owner. + opened.add(plaza); + const now = Math.floor(Date.now() / 1000); + const ids = Array.from({ length: 70 }, (_, index) => `m${index}x`); + emit({ + replay: true, + events: [ + ...ids.map((id) => + event(id, { + created_at: now + 1, + tags: [ + ["h", channel], + ["p", bot], + ], + }), + ), + event("plaza", { + created_at: now + 1, + tags: [ + ["h", plaza], + ["p", bot], + ], + }), + ], + }); + await vi.advanceTimersByTimeAsync(1_000); + // Some wait for the next rate window, plaza's last among them. + expect(woke(run)).not.toContain("plaza"); + expect(run.mock.calls.length).toBeLessThan(ids.length + 1); + // Closed while it waits; the watch still keeps plaza live. + opened.delete(plaza); + await vi.advanceTimersByTimeAsync(70_000); + expect(woke(run)).toContain("plaza"); + expect( + run.mock.calls.find( + ([delivery]) => + delivery.trigger.type === "mention" && + delivery.trigger.event.id.startsWith("plaza"), + ), + ).toBeDefined(); + } finally { + vi.useRealTimers(); + } +}); + it.each([ - ["the owner's app knew it", false, "mention"], - ["only agents' lookups found it", true, "catch-up"], + ["the owner's app knew it", false, false, "mention"], + ["only agents' lookups found it", true, false, "catch-up"], + [ + "only agents' lookups found it when read, though the owner's did since", + true, + true, + "catch-up", + ], ] as const)( "answers a mention catch-up reads in a public channel only if %s", - async (_, learnedOnly, type) => { + async (_, learnedOnly, learnedSince, type) => { vi.useFakeTimers({ toFake: ["Date"] }); try { const start = nowSeconds(); - const { service, run, history, recover, learned } = await setup({ + const { service, run, history, recover, learned, session } = await setup({ identities: [echo], }); + if (learnedSince) { + // The owner's own lookup finds it right after the read asks. + const unattended = session.unattended.bind(session); + vi.spyOn(session, "unattended").mockImplementation((id, live) => { + const result = unattended(id, live); + if (id === plaza && !live) learned.delete(plaza); + return result; + }); + } await service.save(bot, { attentionEnabled: true, attention: { diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 7cb58d6de8..6f5f5b24ec 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -262,6 +262,72 @@ it.each([ }, ); +it("counts a channel only agents' reads found as the viewer's only once the viewer's own lookup of it succeeds", async () => { + const h = harness(); + await h.discover(); + const message = signed(other, { + kind: 9, + content: "message", + created_at: 15, + tags: [["h", OPEN]], + }) as RelayEvent; + /** Reads with `filter`, answers the read with the message, and answers + * its metadata lookup with `lookup`. */ + const read = async ( + filter: Record, + resolveChannels: boolean, + lookup: (entry: (typeof h.wire.pending)[number]) => void, + ) => { + const reading = h.owner.session.read([filter as never], { + fresh: true, + ...(resolveChannels ? { resolveChannels } : {}), + }); + const asked = () => + h.wire.pending.findIndex( + (entry) => + !entry.signal?.aborted && + entry.filters.some((item) => item.kinds?.includes(9)), + ); + await vi.waitFor(() => expect(asked()).not.toBe(-1)); + h.wire.pending.splice(asked(), 1)[0]?.respond([message]); + const metadata = () => + h.wire.pending.findIndex( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ); + await vi.waitFor(() => expect(metadata()).not.toBe(-1)); + const entry = h.wire.pending.splice(metadata(), 1)[0]; + if (entry) lookup(entry); + return reading.then( + () => "read", + () => "failed", + ); + }; + // An agent's read of every channel, of more than chat, finds it first. + expect( + await read( + { kinds: [9, 7], since: 11, until: 20, limit: 16 }, + true, + (entry) => entry.respond(channel(OPEN, "public") as RelayEvent[]), + ), + ).toBe("read"); + expect(h.owner.session.unattended(OPEN, false)).toBe(false); + // The viewer's search finds it too, but its lookup fails: without agents, + // the channel would still be unknown. + expect( + await read({ kinds: [9], search: "message", limit: 16 }, false, (entry) => + entry.fail(new Error("timed out")), + ), + ).toBe("failed"); + expect(h.owner.session.unattended(OPEN, false)).toBe(false); + // Once a lookup of the viewer's own succeeds, it is the viewer's. + expect( + await read({ kinds: [9], search: "message", limit: 16 }, false, (entry) => + entry.respond(channel(OPEN, "public") as RelayEvent[]), + ), + ).toBe("read"); + expect(h.owner.session.unattended(OPEN, false)).toBe(true); +}); + it("counts an attended channel the viewer has not joined or opened as heard only for agents, live and read", async () => { const h = harness(); const stop = h.owner.session.attend([OPEN]); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index c985eaf12e..9b4e8796bc 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -712,10 +712,10 @@ export function createRelaySession( ), ]; // What only `resolveChannels` looks up is known for agents alone. - if (!filters.every((filter) => resolves(filter, false))) { + const viewers = filters.every((filter) => resolves(filter, false)); + if (!viewers) for (const id of ids) if (!channels.queries.get?.(id)) learnedForAgents.add(id); - } else for (const id of ids) learnedForAgents.delete(id); await channels.queries.resolve?.(ids, settings); // A read of every channel keeps what visibility keeps below. if (!broad) @@ -729,6 +729,9 @@ export function createRelaySession( settings?.signal?.throwIfAborted(); if (closed || epoch !== accessEpoch) throw new DOMException("Stale relay search", "AbortError"); + // Known to the viewer's own reads only once their lookup succeeded: one + // that failed would have left the channel unknown without agents. + if (viewers) for (const id of ids) learnedForAgents.delete(id); } const visible = accept( events, From 12e7a8fab4dc7127502ca262601364867f3f5310 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 18:04:35 -0500 Subject: [PATCH 16/32] fix(relay): retire agents-only provenance on any viewer lookup A channel an agent's watch discovered stayed agents-only after the owner found it with a public-channel name search, so a later catch-up mention from it became watch work instead of the mention turn main gives. The store now reports every exact lookup it makes; only lookups marked forAgents (attended-channel reads and resolveChannels reads of more than chat) keep the mark. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/relay-queries.md | 4 +- src/features/relay/attend.session.test.ts | 67 ++++++++++++++++++++++- src/features/relay/contracts.ts | 7 ++- src/features/relay/session.ts | 20 +++++-- src/features/relay/store.ts | 4 ++ 5 files changed, 93 insertions(+), 9 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 4037c6eaa2..26ef48afd6 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -342,7 +342,9 @@ lookups, and, for a live event, one the live routes carry only because it is attended. Agents take mentions and one-to-one DM turns only from events for which it is true when it arrived, kept while the event waits, so watches do not change who a mention wakes; they still hear the rest. A channel leaves the -agents-only set once a lookup of the viewer's own succeeds, not when it starts. +agents-only set once a lookup of the viewer's own succeeds, not when it starts: +any exact lookup the store makes without `forAgents`, such as a name search, a +label lookup or a link, reports through `resolved`. Shared composers and reactions remain unavailable for nonmembers; message helpers and workflows do not acquire write eligibility from public metadata. The relay remains the final write authority, including low-level outbox intent. diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 6f5f5b24ec..89c9196491 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, assert, expect, it, vi } from "vitest"; import { createRelaySession } from "./session"; import type { RelayEvent } from "./events"; import type { LiveCallbacks } from "./live"; @@ -328,6 +328,71 @@ it("counts a channel only agents' reads found as the viewer's only once the view expect(h.owner.session.unattended(OPEN, false)).toBe(true); }); +it("counts a channel only agents' reads found as the viewer's once a name search finds it, as without agents", async () => { + const h = harness(); + await h.discover(); + const pending = ( + match: (entry: (typeof h.wire.pending)[number]) => boolean, + ) => + h.wire.pending.findIndex((entry) => !entry.signal?.aborted && match(entry)); + const take = async ( + match: (entry: (typeof h.wire.pending)[number]) => boolean, + ) => { + await vi.waitFor(() => expect(pending(match)).not.toBe(-1)); + const entry = h.wire.pending.splice(pending(match), 1)[0]; + assert(entry); + return entry; + }; + const lookup = (entry: (typeof h.wire.pending)[number]) => + asks(entry.filters, OPEN); + // An agent's read of every channel, of more than chat, finds it first. + const reading = h.owner.session.read( + [{ kinds: [9, 7], since: 11, until: 20, limit: 16 }], + { fresh: true, resolveChannels: true }, + ); + ( + await take((entry) => + entry.filters.some((filter) => filter.kinds?.includes(9)), + ) + ).respond([ + signed(other, { + kind: 9, + content: "message", + created_at: 15, + tags: [["h", OPEN]], + }), + ] as RelayEvent[]); + (await take(lookup)).respond(channel(OPEN, "public") as RelayEvent[]); + expect(await reading).toHaveLength(1); + expect(h.owner.session.unattended(OPEN, false)).toBe(false); + const search = () => { + const found = h.owner.session.channels.searchPublic?.("3333"); + assert(found); + return found.then( + ({ channels }) => channels.map(({ id }) => id), + () => "failed", + ); + }; + const directory = (entry: (typeof h.wire.pending)[number]) => + entry.filters.some( + (filter) => filter.kinds?.includes(39000) && !filter["#d"], + ); + // The viewer's name search lists it, but its lookup fails: without agents, + // the channel would still be unknown. + const failing = search(); + (await take(directory)).respond(channel(OPEN, "public") as RelayEvent[]); + (await take(lookup)).fail(new Error("timed out")); + expect(await failing).toBe("failed"); + expect(h.owner.session.unattended(OPEN, false)).toBe(false); + // Once the search's lookup succeeds, the channel is the viewer's, though + // never opened. + // It ranks the directory page already read. + const finding = search(); + (await take(lookup)).respond(channel(OPEN, "public") as RelayEvent[]); + expect(await finding).toEqual([OPEN]); + expect(h.owner.session.unattended(OPEN, false)).toBe(true); +}); + it("counts an attended channel the viewer has not joined or opened as heard only for agents, live and read", async () => { const h = harness(); const stop = h.owner.session.attend([OPEN]); diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 6144d911b8..52a9a92d11 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -220,8 +220,11 @@ export type ChannelWindow = Readonly<{ hasNewer?: boolean | undefined; loadingNewer?: boolean | undefined; }>; -/** Post-write discovery opts into writer reads; browsing keeps the default. */ -export type ChannelReadOptions = ReadOptions & Pick; +/** Post-write discovery opts into writer reads; browsing keeps the default. + * `forAgents` marks a lookup only agents' watches make, so the channels it + * finds stay unknown to the viewer's own traffic. */ +export type ChannelReadOptions = ReadOptions & + Pick & { forAgents?: boolean }; export type PublicChannelSearch = Readonly<{ channels: readonly ChannelSummary[]; /** The metadata read stopped at its bound, so some channels were not checked. */ diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 9b4e8796bc..181e9a1104 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -716,7 +716,10 @@ export function createRelaySession( if (!viewers) for (const id of ids) if (!channels.queries.get?.(id)) learnedForAgents.add(id); - await channels.queries.resolve?.(ids, settings); + await channels.queries.resolve?.( + ids, + viewers ? settings : { ...settings, forAgents: true }, + ); // A read of every channel keeps what visibility keeps below. if (!broad) events = events.filter((event) => { @@ -729,9 +732,6 @@ export function createRelaySession( settings?.signal?.throwIfAborted(); if (closed || epoch !== accessEpoch) throw new DOMException("Stale relay search", "AbortError"); - // Known to the viewer's own reads only once their lookup succeeded: one - // that failed would have left the channel unknown without agents. - if (viewers) for (const id of ids) learnedForAgents.delete(id); } const visible = accept( events, @@ -1083,6 +1083,12 @@ export function createRelaySession( media: (url, size) => transport.media(url, size), revokeAccess, rosterComplete: dropStrandedSends, + // Any lookup but agents' own makes a channel known to the viewer's + // traffic: a name search, a label lookup or a link, as without + // any `attend`. + resolved: (ids, forAgents) => { + if (!forAgents) for (const id of ids) learnedForAgents.delete(id); + }, visible: (events) => events.filter(visibility(events)), restored: (events) => unread.accept(events), verifyStored: transport.verifyStored?.bind(transport), @@ -1370,7 +1376,11 @@ export function createRelaySession( const batch = fresh.slice(start, start + 128); void channels.queries // In the background lane, so an interactive read never waits behind it. - .resolve(batch, { signal: lifetime.signal, priority: "background" }) + .resolve(batch, { + signal: lifetime.signal, + priority: "background", + forAgents: true, + }) .catch(() => {}) .finally(() => { for (const id of batch) attendReading.delete(id); diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 38414e1ac9..0bfb8159ca 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -222,6 +222,9 @@ export function createChannelStore( rosterChanged?(strong?: boolean): void; /** The relay advertises NIP-CL; label lookups are refused without it. */ channelIdentity?: true | undefined; + /** An exact lookup of these channels read their metadata. `forAgents` + * as the lookup asked. */ + resolved?(ids: readonly string[], forAgents: boolean): void; }) | null, directory: ProfileDirectory, @@ -2206,6 +2209,7 @@ export function createChannelStore( } else if (!discovery.named(id)) throw new Error("Channel metadata capacity unavailable"); } + transport.resolved?.(ids, settings?.forAgents === true); if (settings?.consistency === "strong") await readCreations( ids.flatMap((id) => { From 38254468e92a4e3f59008a3348df8f78fa7fd0c7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 18:21:59 -0500 Subject: [PATCH 17/32] fix(relay): keep agents-only provenance through rechecks ebf033ce6 retired the agents-only mark on any lookup not marked forAgents, so preview revalidation after an access-revoked CLOSED made a watch-only channel the viewer's without any lookup of theirs. Invert the marker: only the viewer's own lookups (name search, label lookup, mounted links, search reads) pass viewerLookup and retire the mark; rechecks and agents' lookups leave it as it was. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/relay-queries.md | 6 ++- src/features/relay/attend.session.test.ts | 47 +++++++++++++++++++++++ src/features/relay/contracts.ts | 8 ++-- src/features/relay/session.ts | 17 +++----- src/features/relay/store.ts | 21 +++++----- 5 files changed, 73 insertions(+), 26 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 26ef48afd6..145f52d1b0 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -343,8 +343,10 @@ attended. Agents take mentions and one-to-one DM turns only from events for which it is true when it arrived, kept while the event waits, so watches do not change who a mention wakes; they still hear the rest. A channel leaves the agents-only set once a lookup of the viewer's own succeeds, not when it starts: -any exact lookup the store makes without `forAgents`, such as a name search, a -label lookup or a link, reports through `resolved`. +an exact lookup marked `viewerLookup` (a name search, a label lookup, a mounted +link or a search read) reports through `resolved`. Rechecks, such as preview +revalidation after an access-revoked CLOSED or a member-added confirmation, +leave it as it was. Shared composers and reactions remain unavailable for nonmembers; message helpers and workflows do not acquire write eligibility from public metadata. The relay remains the final write authority, including low-level outbox intent. diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 89c9196491..4bca910f62 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -66,6 +66,8 @@ function harness() { reconnect, connect, receive: (events: readonly RelayEvent[]) => live.receive(events as never), + state: (snapshot: Parameters[0]) => + live.state(snapshot), }; } /** The relay's signed metadata and roster of a channel only `other` is in. */ @@ -416,6 +418,51 @@ it("counts an attended channel the viewer has not joined or opened as heard only stop(); }); +it("keeps an attended channel heard only for agents when a recheck after an access-revoked CLOSED finds it public again", async () => { + const h = harness(); + const stop = h.owner.session.attend([OPEN]); + await h.discover(); + const lookup = () => + h.wire.pending.findIndex( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ); + await vi.waitFor(() => expect(lookup()).not.toBe(-1)); + h.wire.pending + .splice(lookup(), 1)[0] + ?.respond(channel(OPEN, "public") as RelayEvent[]); + await vi.waitFor(() => + expect(h.update.mock.lastCall?.[0] ?? []).toContain(OPEN), + ); + expect(h.owner.session.unattended(OPEN, false)).toBe(false); + h.state({ + status: "connected", + routes: [ + { + id: "attended", + channelId: OPEN, + status: "error", + replay: "unknown", + error: "restricted: channel access revoked", + }, + ], + }); + // The recheck, and attendance's own read of the suspended channel, find it + // public again; the viewer has still not looked it up. + await vi.waitFor(() => expect(lookup()).not.toBe(-1)); + await flush(); + expect( + h.wire.pending.filter((entry) => asks(entry.filters, OPEN)), + ).toHaveLength(2); + while (lookup() !== -1) + h.wire.pending + .splice(lookup(), 1)[0] + ?.respond(channel(OPEN, "public") as RelayEvent[]); + await flush(); + expect(h.owner.session.channels.get?.(OPEN)).toBeDefined(); + expect(h.owner.session.unattended(OPEN, false)).toBe(false); + stop(); +}); + it("does not answer a read of every channel that resolves its channels before discovery, rather than drop what it cannot place", async () => { const h = harness(); // Discovery has not succeeded: its roster read failed. diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 52a9a92d11..601a30a71e 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -221,10 +221,12 @@ export type ChannelWindow = Readonly<{ loadingNewer?: boolean | undefined; }>; /** Post-write discovery opts into writer reads; browsing keeps the default. - * `forAgents` marks a lookup only agents' watches make, so the channels it - * finds stay unknown to the viewer's own traffic. */ + * `viewerLookup` marks a lookup the viewer's own action asks for (a name + * search, label lookup, mounted link or search read): the channels it finds + * become known to the viewer's traffic. Rechecks and agents' lookups leave + * that as it was. */ export type ChannelReadOptions = ReadOptions & - Pick & { forAgents?: boolean }; + Pick & { viewerLookup?: boolean }; export type PublicChannelSearch = Readonly<{ channels: readonly ChannelSummary[]; /** The metadata read stopped at its bound, so some channels were not checked. */ diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 181e9a1104..a12d504282 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -718,7 +718,7 @@ export function createRelaySession( if (!channels.queries.get?.(id)) learnedForAgents.add(id); await channels.queries.resolve?.( ids, - viewers ? settings : { ...settings, forAgents: true }, + viewers ? { ...settings, viewerLookup: true } : settings, ); // A read of every channel keeps what visibility keeps below. if (!broad) @@ -1083,11 +1083,10 @@ export function createRelaySession( media: (url, size) => transport.media(url, size), revokeAccess, rosterComplete: dropStrandedSends, - // Any lookup but agents' own makes a channel known to the viewer's - // traffic: a name search, a label lookup or a link, as without - // any `attend`. - resolved: (ids, forAgents) => { - if (!forAgents) for (const id of ids) learnedForAgents.delete(id); + // The viewer's own lookups make a channel known to the viewer's + // traffic, as without any `attend`; rechecks do not. + resolved: (ids) => { + for (const id of ids) learnedForAgents.delete(id); }, visible: (events) => events.filter(visibility(events)), restored: (events) => unread.accept(events), @@ -1376,11 +1375,7 @@ export function createRelaySession( const batch = fresh.slice(start, start + 128); void channels.queries // In the background lane, so an interactive read never waits behind it. - .resolve(batch, { - signal: lifetime.signal, - priority: "background", - forAgents: true, - }) + .resolve(batch, { signal: lifetime.signal, priority: "background" }) .catch(() => {}) .finally(() => { for (const id of batch) attendReading.delete(id); diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 0bfb8159ca..33d1b2558b 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -222,9 +222,8 @@ export function createChannelStore( rosterChanged?(strong?: boolean): void; /** The relay advertises NIP-CL; label lookups are refused without it. */ channelIdentity?: true | undefined; - /** An exact lookup of these channels read their metadata. `forAgents` - * as the lookup asked. */ - resolved?(ids: readonly string[], forAgents: boolean): void; + /** A `viewerLookup` read these channels' metadata. */ + resolved?(ids: readonly string[]): void; }) | null, directory: ProfileDirectory, @@ -2209,7 +2208,7 @@ export function createChannelStore( } else if (!discovery.named(id)) throw new Error("Channel metadata capacity unavailable"); } - transport.resolved?.(ids, settings?.forAgents === true); + if (settings?.viewerLookup) transport.resolved?.(ids); if (settings?.consistency === "strong") await readCreations( ids.flatMap((id) => { @@ -2686,10 +2685,11 @@ export function createChannelStore( : 0, ); if (unresolved.length) { - await resolve( - unresolved, - readSignal ? { ...settings, signal: readSignal } : settings, - ); + await resolve(unresolved, { + ...settings, + ...(readSignal ? { signal: readSignal } : {}), + viewerLookup: true, + }); const stale = new Set( unresolved.filter((id) => { const current = discovery.metadataVersion(id); @@ -2866,7 +2866,8 @@ export function createChannelStore( }), ), ]; - if (ids.length) await resolve(ids, settings, true); + if (ids.length) + await resolve(ids, { ...settings, viewerLookup: true }, true); if (disposed || generation !== epoch) throw new DOMException("Stale channel lookup", "AbortError"); return ids @@ -3234,7 +3235,7 @@ export function createChannelStore( for (let start = 0; start < ids.length; start += 128) { const batch = ids.slice(start, start + 128); for (const id of batch) state.pending.add(id); - resolve(batch, { priority: "background" }).then( + resolve(batch, { priority: "background", viewerLookup: true }).then( () => { if (generation !== epoch) return; for (const id of batch) { From b9712ee6ea82a7b25a24266e4cd48a56fdb039ff Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 18:30:30 -0500 Subject: [PATCH 18/32] fix(relay): count a link to an agent-known channel as the viewer's A channel-only link to a public channel an agent's watch had already resolved found a readable answer, so the link neither demanded the channel nor caused a lookup, and the agents-only mark stayed: later catch-up mentions from it stayed watch work, unlike main, where the link's lookup makes the channel known. Mounted links now hold their demand even when readable, and the store reports a demanded channel it already knows through `resolved`, with no read. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/relay-queries.md | 4 +- .../conversation/BuzzLinkPreview.test.tsx | 11 ++++- .../conversation/channel-reference.test.tsx | 8 ++++ .../conversation/channel-reference.ts | 14 +++--- src/features/relay/attend.session.test.ts | 43 +++++++++++++++++++ src/features/relay/store.ts | 8 +++- 6 files changed, 77 insertions(+), 11 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 145f52d1b0..3acce5115a 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -344,7 +344,9 @@ which it is true when it arrived, kept while the event waits, so watches do not change who a mention wakes; they still hear the rest. A channel leaves the agents-only set once a lookup of the viewer's own succeeds, not when it starts: an exact lookup marked `viewerLookup` (a name search, a label lookup, a mounted -link or a search read) reports through `resolved`. Rechecks, such as preview +link or a search read) reports through `resolved`, and so does a mounted link +to a channel already known, which needs no lookup: links hold their demand +even when readable. Rechecks, such as preview revalidation after an access-revoked CLOSED or a member-added confirmation, leave it as it was. Shared composers and reactions remain unavailable for nonmembers; message helpers diff --git a/src/features/conversation/BuzzLinkPreview.test.tsx b/src/features/conversation/BuzzLinkPreview.test.tsx index aa865c7d9b..30e63bfb2a 100644 --- a/src/features/conversation/BuzzLinkPreview.test.tsx +++ b/src/features/conversation/BuzzLinkPreview.test.tsx @@ -70,8 +70,13 @@ function setup( let described = reference; const listListeners = new Set<() => void>(); // As in the store, a demanded lookup is on the way at once, and its batch - // tells list subscribers from a microtask. A found channel keeps its name. + // tells list subscribers from a microtask. A found channel keeps its name; + // a readable one is not looked up. + let lookups = 0; const refer = vi.fn(() => { + if (described?.state === "found" && described.readable !== false) + return () => {}; + lookups++; described = described?.state === "found" ? { ...described, looking: true } @@ -115,6 +120,7 @@ function setup( thread, views, refer, + lookups: () => lookups, /** The channel lookup answers, or discovery moves the list on, as the * store notifies list subscribers. */ answer(next: ChannelReference, nextStatus = list.status) { @@ -296,7 +302,8 @@ it("looks up a channel the viewer left before trusting a refused read, then read expect(text(h.tree)).toBe("Loading message…"); h.views[1]?.publish({ ...base, status: "ready", root }); expect(h.tree.textContent).toContain("reconciled body"); - expect(h.refer).toHaveBeenCalledOnce(); + // The readable answer still holds the link's demand, without a lookup. + expect(h.lookups()).toBe(1); }); it("keeps loading while a read is genuinely in flight", () => { const loading = setup({ diff --git a/src/features/conversation/channel-reference.test.tsx b/src/features/conversation/channel-reference.test.tsx index 6c4e827f19..396b3d3133 100644 --- a/src/features/conversation/channel-reference.test.tsx +++ b/src/features/conversation/channel-reference.test.tsx @@ -173,6 +173,14 @@ it("keeps the authored label while the answer is unknown", async () => { expect(screen.queryByText("Private channel")).toBeNull(); }); +it("holds a readable channel's demand too, without the store looking it up", async () => { + const t = fixture(); + t.answer("open", crew); + render(link(t.session, "open")); + await act(async () => {}); + expect(t.refer.mock.calls).toEqual([["open"]]); +}); + it("asks only once the list is ready, and never from a composer decoration", async () => { const t = fixture("loading"); render( diff --git a/src/features/conversation/channel-reference.ts b/src/features/conversation/channel-reference.ts index 27e30f46da..6e05353e1c 100644 --- a/src/features/conversation/channel-reference.ts +++ b/src/features/conversation/channel-reference.ts @@ -7,8 +7,8 @@ const noop = () => () => {}; /** * What a link can show about a channel the reader may not have joined. The * store owns the lookup, its batching, expiry and retry; this hook only - * demands one while the list is ready and the answer isn't a readable - * `found`, because discovery owns access. + * demands the channel while the list is ready. The store looks up only an + * answer that isn't a readable `found`, because discovery owns access. */ export function useChannelReference( queries: ChannelQueries | undefined, @@ -32,12 +32,14 @@ export function useChannelReference( ); // Mounted links hold the demand, so the store rechecks them when an answer // expires or a failure's backoff ends. Moving between answers that aren't - // readable keeps the same demand. + // readable keeps the same demand; becoming readable or not asks again. A + // readable answer needs no lookup, but its demand still tells the store the + // viewer's own traffic names the channel. const readable = reference.state === "found" && reference.readable !== false; + const demand = ready ? (readable ? "named" : "lookup") : undefined; useEffect(() => { - if (active && channelId && ready && !readable) - return active.refer?.(channelId); - }, [active, channelId, ready, readable]); + if (active && channelId && demand) return active.refer?.(channelId); + }, [active, channelId, demand]); return reference; } diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 4bca910f62..14453471df 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -418,6 +418,49 @@ it("counts an attended channel the viewer has not joined or opened as heard only stop(); }); +it("counts a channel only agents' reads found as the viewer's once a link to it mounts, with no lookup", async () => { + const h = harness(); + await h.discover(); + const reading = h.owner.session.read( + [{ kinds: [9, 7], since: 11, until: 20, limit: 16 }], + { fresh: true, resolveChannels: true }, + ); + const asked = () => + h.wire.pending.findIndex((entry) => + entry.filters.some((filter) => filter.kinds?.includes(9)), + ); + await vi.waitFor(() => expect(asked()).not.toBe(-1)); + h.wire.pending.splice(asked(), 1)[0]?.respond([ + signed(other, { + kind: 9, + content: "message", + created_at: 15, + tags: [["h", OPEN]], + }), + ] as RelayEvent[]); + await vi.waitFor(() => + expect(h.wire.pending.some((entry) => asks(entry.filters, OPEN))).toBe( + true, + ), + ); + h.wire.pending + .find((entry) => asks(entry.filters, OPEN)) + ?.respond(channel(OPEN, "public") as RelayEvent[]); + expect(await reading).toHaveLength(1); + expect(h.owner.session.unattended(OPEN, false)).toBe(false); + // Without agents, the link's lookup would have made it known; here it is + // already, so nothing is read. + const release = h.owner.session.channels.refer?.(OPEN); + await flush(); + expect( + h.wire.pending.some( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ), + ).toBe(false); + expect(h.owner.session.unattended(OPEN, false)).toBe(true); + release?.(); +}); + it("keeps an attended channel heard only for agents when a recheck after an access-revoked CLOSED finds it public again", async () => { const h = harness(); const stop = h.owner.session.attend([OPEN]); diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 33d1b2558b..b73ef7ec9c 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -222,7 +222,8 @@ export function createChannelStore( rosterChanged?(strong?: boolean): void; /** The relay advertises NIP-CL; label lookups are refused without it. */ channelIdentity?: true | undefined; - /** A `viewerLookup` read these channels' metadata. */ + /** A `viewerLookup` read these channels' metadata, or a mounted + * link named a channel already known. */ resolved?(ids: readonly string[]): void; }) | null, @@ -3213,7 +3214,10 @@ export function createChannelStore( if (disposed || !transport || !discovery || options.cachedOnly) return; // Discovery's admission, not a found name, decides: a left channel's // messages need a fresh lookup. - if (list.status !== "ready" || discovery.get(id)) return; + if (list.status !== "ready") return; + // A known channel needs no lookup, but a link names it in the viewer's + // own traffic, as the lookup would have had it been unknown. + if (discovery.get(id)) return transport.resolved?.([id]); const state = currentReferences(); if ( state.queue.has(id) || From 63ce7c3cccd54e6d58f3be800bedc8a9642fad1c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 18:46:26 -0500 Subject: [PATCH 19/32] fix(agents): keep scoped searches and re-enabled watch replays as on main Two gaps from review of 498fe06b1: - An in: search resolves its channel through session.channels.resolve without viewerLookup, so a channel an agent's watch found first stayed agents-only after the viewer searched it. The session's outside resolve is only ever the viewer's, so it now marks every call as a viewer lookup. - Turning a watch back on recreates its channel's route, whose replay of the last minutes woke the watch for events sent while it was off. The runner now records when each stream starts in this copy (turned on, added, or shown), and a replayed event skips each watch whose stream started after it. Streams that stay on keep their replays as before. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/relay-queries.md | 3 +- src/features/agents/runner.ts | 24 ++++++++++- src/features/agents/service.test.ts | 49 ++++++++++++++++++++++ src/features/relay/attend.session.test.ts | 51 +++++++++++++++++++++++ src/features/relay/session.ts | 14 ++++++- 5 files changed, 138 insertions(+), 3 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 3acce5115a..fea2206cf8 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -344,7 +344,8 @@ which it is true when it arrived, kept while the event waits, so watches do not change who a mention wakes; they still hear the rest. A channel leaves the agents-only set once a lookup of the viewer's own succeeds, not when it starts: an exact lookup marked `viewerLookup` (a name search, a label lookup, a mounted -link or a search read) reports through `resolved`, and so does a mounted link +link, a search read, or any `session.channels.resolve` call, which only the +viewer's own surfaces make) reports through `resolved`, and so does a mounted link to a channel already known, which needs no lookup: links hold their demand even when readable. Rechecks, such as preview revalidation after an access-revoked CLOSED or a member-added confirmation, diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 9ce4d913a9..76b2e36c3d 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -606,6 +606,10 @@ export class AgentRunners { * nothing from the time it was off; across a restart, its saved * checkpoint, moved while it was off, does the same. */ private quiet = new Set(); + /** When each stream started in this copy: turned on, added, or shown. A + * route its watch's channel then gets replays a few minutes from before + * that, which must not wake it. */ + private started = new Map(); /** Whether startup is over for Attention: plugins have finished starting, * or Attention has been shown, since this copy started. */ private settled = false; @@ -988,9 +992,11 @@ export class AgentRunners { if (this.isOwn(agent, event)) continue; // A replay waits for room rather than take the drops a burst of // live traffic would. + const done = this.before(runner, agent, event); runner.backlog.push({ event, ...(batch.channelId ? { channelId: batch.channelId } : {}), + ...(done.size ? { done } : {}), addressable: this.host.unattended(agent, event, true), }); } @@ -1001,6 +1007,19 @@ export class AgentRunners { if (batch.replay) this.admit(runner); } } + /** The agent's watches whose streams started after a replayed `event`. */ + private before(runner: Runner, agent: Agent, event: RelayEvent) { + return new Set( + this.watches(runner, agent).flatMap(({ slug, watch }) => { + const started = this.started.get( + streamOf(runner.key, { slug, watch, event }), + ); + return started !== undefined && event.created_at < started + ? [slug] + : []; + }), + ); + } private hasSeen(runner: Runner, id: string, channel: string | undefined) { return ( runner.seen.has(id) || @@ -1242,6 +1261,7 @@ export class AgentRunners { // Saved too, so one turned on after a restart starts from about // when this copy last saw it off, or from when Attention is shown. this.quiet.add(stream.key); + this.started.delete(stream.key); if ( (checkpoint?.through ?? 0) < now || (!shown && !checkpoint?.hidden) @@ -1251,10 +1271,12 @@ export class AgentRunners { this.quiet.delete(stream.key) || !checkpoint || checkpoint.hidden - ) + ) { // A stream new to catch-up, as every one is on an upgrade, starts // now: an earlier `since` does not replay older events. move(stream.key, { through: now }); + this.started.set(stream.key, now); + } // Until its channel's metadata is known, a read returns none of its // events: it waits, with its checkpoint where it was. else if ( diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index d8ed46134e..aebc283624 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -8666,6 +8666,55 @@ it.each([ }, ); +it("wakes a watch turned back on for nothing its channel's new route replays from the time it was off", async () => { + vi.useFakeTimers({ now: 1_000_000_000 }); + try { + const { service, run, emit, attended } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const watch = { + type: "event", + interest_id: "default", + enabled: true, + since: 1, + channels: [plaza], + kinds: [], + } as const; + const enable = (enabled: boolean) => + service.save(bot, { + attentionEnabled: true, + attention: { "watch/plaza": { ...watch, enabled } }, + }); + const kept = () => [...attended].flat().includes(plaza); + await enable(true); + await vi.waitFor(() => expect(kept()).toBe(true)); + await vi.advanceTimersByTimeAsync(10_000); + // Off: its channel is no longer kept live, so nothing there is heard. + await enable(false); + await vi.waitFor(() => expect(kept()).toBe(false)); + await vi.advanceTimersByTimeAsync(30_000); + const off = Math.floor(Date.now() / 1000); + await vi.advanceTimersByTimeAsync(30_000); + await enable(true); + await vi.waitFor(() => expect(kept()).toBe(true)); + await vi.advanceTimersByTimeAsync(1_000); + const on = Math.floor(Date.now() / 1000); + // The new route sends its last minutes again: one sent while the watch + // was off, and one since. + emit({ + replay: true, + channelId: plaza, + events: [ + event("off", { created_at: off, tags: [["h", plaza]] }), + event("fresh", { created_at: on + 1, tags: [["h", plaza]] }), + ], + }); + await vi.advanceTimersByTimeAsync(5_000); + expect(woke(run)).toEqual(["fresh"]); + } finally { + vi.useRealTimers(); + } +}); + it("answers a replayed mention that waited for room as it arrived, though its channel was closed since", async () => { vi.useFakeTimers({ now: 1_000_000_000 }); try { diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 14453471df..d9612d3c29 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -461,6 +461,57 @@ it("counts a channel only agents' reads found as the viewer's once a link to it release?.(); }); +it("counts a channel only agents' reads found as the viewer's once a scoped search's lookup of it succeeds", async () => { + const h = harness(); + await h.discover(); + const reading = h.owner.session.read( + [{ kinds: [9, 7], since: 11, until: 20, limit: 16 }], + { fresh: true, resolveChannels: true }, + ); + const lookup = () => + h.wire.pending.findIndex( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ); + const asked = () => + h.wire.pending.findIndex((entry) => + entry.filters.some((filter) => filter.kinds?.includes(9)), + ); + await vi.waitFor(() => expect(asked()).not.toBe(-1)); + h.wire.pending.splice(asked(), 1)[0]?.respond([ + signed(other, { + kind: 9, + content: "message", + created_at: 15, + tags: [["h", OPEN]], + }), + ] as RelayEvent[]); + await vi.waitFor(() => expect(lookup()).not.toBe(-1)); + h.wire.pending + .splice(lookup(), 1)[0] + ?.respond(channel(OPEN, "public") as RelayEvent[]); + expect(await reading).toHaveLength(1); + expect(h.owner.session.unattended(OPEN, false)).toBe(false); + // `in:` search resolves the channel itself before its read. A failed + // lookup leaves it as it was. + const search = () => + (h.owner.session.channels.resolve?.([OPEN]) ?? Promise.resolve()).then( + () => "read", + () => "failed", + ); + const failing = search(); + await vi.waitFor(() => expect(lookup()).not.toBe(-1)); + h.wire.pending.splice(lookup(), 1)[0]?.fail(new Error("timed out")); + expect(await failing).toBe("failed"); + expect(h.owner.session.unattended(OPEN, false)).toBe(false); + const finding = search(); + await vi.waitFor(() => expect(lookup()).not.toBe(-1)); + h.wire.pending + .splice(lookup(), 1)[0] + ?.respond(channel(OPEN, "public") as RelayEvent[]); + expect(await finding).toBe("read"); + expect(h.owner.session.unattended(OPEN, false)).toBe(true); +}); + it("keeps an attended channel heard only for agents when a recheck after an access-revoked CLOSED finds it public again", async () => { const h = harness(); const stop = h.owner.session.attend([OPEN]); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index a12d504282..9ced84851f 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -73,7 +73,11 @@ import type { } from "./incoming"; import { objectBody } from "./body"; import { agentAudience } from "./audience"; -import type { ChannelList, ChannelSummary } from "./contracts"; +import type { + ChannelList, + ChannelReadOptions, + ChannelSummary, +} from "./contracts"; import { createChannelActivity } from "./channel-activity"; import { readSidebarPreferences } from "./sidebar-preferences"; import { sidebarSortNeedsActivity } from "./sidebar-sort"; @@ -1172,6 +1176,14 @@ export function createRelaySession( const projectedChannels = new WeakMap(); const channelQueries = Object.freeze({ ...channels.queries, + // Only the viewer's own surfaces call it from outside the session (a + // scoped search, a requested channel, a repository page), so what they + // look up becomes the viewer's, as without any `attend`. + ...(channels.queries.resolve && { + resolve: (ids: readonly string[], options?: ChannelReadOptions) => + channels.queries.resolve?.(ids, { ...options, viewerLookup: true }) ?? + Promise.resolve(), + }), list() { const snapshot = channels.queries.list(); const activityRevision = channelActivity.revision(); From 80cecbb715323a2eea83749499af7b3ef5230620 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 19:05:38 -0500 Subject: [PATCH 20/32] fix(relay): look up attended channels one batch at a time The reader times a read from when it is queued and runs one background read at a time. Attendance queued every 128-channel batch at once, so a slow first batch spent a later batch's deadline: with 129 unknown channels and 6-second reads, the last batch timed out, and each retry queued the same order again. Batches now go through one chain, each queued after the previous one settles, so each gets the full deadline. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/relay/attend.session.test.ts | 35 +++++++++++++++++++++++ src/features/relay/session.ts | 26 +++++++++-------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index d9612d3c29..e01268bf52 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -175,6 +175,41 @@ it("says a channel is pending until its metadata is read, attended or not yet, a await vi.waitFor(() => expect(pending()).toBe(false)); }); +it("gives each batch of attended channels' lookups its own read deadline, so a slow first batch does not time out the next", async () => { + const h = harness(); + const unknown = Array.from( + { length: 128 }, + (_, index) => + `55555555-5555-4555-8555-${index.toString(16).padStart(12, "0")}`, + ); + const live = () => h.update.mock.lastCall?.[0] ?? []; + const lookup = (id: string) => + h.wire.pending.find( + (entry) => !entry.signal?.aborted && asks(entry.filters, id), + ); + // From the start, so the reader's deadlines run on this clock. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + h.owner.session.attend([...unknown, OPEN]); + await h.discover(); + await vi.waitFor(() => expect(lookup(unknown[0] ?? "")).toBeDefined()); + // The first batch takes 6 of the reader's 10 seconds and finds nothing. + await vi.advanceTimersByTimeAsync(6_000); + const first = lookup(unknown[0] ?? ""); + assert(first); + h.wire.pending.splice(h.wire.pending.indexOf(first), 1); + first.respond([]); + await vi.advanceTimersByTimeAsync(0); + // The next one still has its 10 seconds after 5 more. + await vi.advanceTimersByTimeAsync(5_000); + lookup(OPEN)?.respond(channel(OPEN, "public") as RelayEvent[]); + await vi.advanceTimersByTimeAsync(0); + } finally { + vi.useRealTimers(); + } + await vi.waitFor(() => expect(live()).toContain(OPEN)); +}); + it("resolves the channels a read of every channel finds, of any kinds, once discovery is done", async () => { const h = harness(); const read = () => diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 9ced84851f..190bc53460 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1355,6 +1355,10 @@ export function createRelaySession( }, delay); timers.add(attendRetry); }; + /** Attendance lookups in order, one at a time: the reader times a read + * from when it is queued, so a batch queued behind another background + * read would spend its deadline waiting. */ + let attendLookups = Promise.resolve(); /** Reads the metadata of attended channels not known. Only into a ready * list: a lookup may extend one, never stand in for initial discovery. A * channel still unknown after its read, because the read failed or found @@ -1363,11 +1367,8 @@ export function createRelaySession( * have turned public since. So is one that becomes unknown again, as when * newer metadata makes it private. */ const resolveAttended = () => { - if ( - closed || - !channels.queries.resolve || - channels.queries.list().status !== "ready" - ) + const resolve = channels.queries.resolve; + if (closed || !resolve || channels.queries.list().status !== "ready") return; const unknown = [...new Set([...attended].flat())].filter( (id) => !channels.queries.get?.(id), @@ -1385,14 +1386,15 @@ export function createRelaySession( } for (let start = 0; start < fresh.length; start += 128) { const batch = fresh.slice(start, start + 128); - void channels.queries + const lookUp = () => // In the background lane, so an interactive read never waits behind it. - .resolve(batch, { signal: lifetime.signal, priority: "background" }) - .catch(() => {}) - .finally(() => { - for (const id of batch) attendReading.delete(id); - updateInterests(); - }); + resolve(batch, { signal: lifetime.signal, priority: "background" }) + .catch(() => {}) + .finally(() => { + for (const id of batch) attendReading.delete(id); + updateInterests(); + }); + attendLookups = attendLookups.then(lookUp, lookUp); } }; const liveListeners = new Set<() => void>(); From 9fa6cf2098bd56e41bada3d5ef8f2c2732ac9c76 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 19:26:31 -0500 Subject: [PATCH 21/32] fix(relay): read an attended channel once while its read is in flight A new attendance set (a watch-set change attends before it releases the old set) and the retry timer both clear the checked channels, and the next pass queued a second read of a channel still being read. The first read's end then cleared the shared in-flight mark, which armed the retry early, and each retry appended more duplicate batches to the chain. A channel whose read is in flight now counts as checked and is not queued again; once that read ends, an unknown channel waits for the retry as before. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/relay/attend.session.test.ts | 48 +++++++++++++++++++++++ src/features/relay/session.ts | 11 ++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index e01268bf52..c5f4149cfc 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -210,6 +210,54 @@ it("gives each batch of attended channels' lookups its own read deadline, so a s await vi.waitFor(() => expect(live()).toContain(OPEN)); }); +it("reads an attended channel once while its read is in flight, through a new attendance and a retry", async () => { + const h = harness(); + // Five batches of channels the relay never finds. + const ids = Array.from( + { length: 640 }, + (_, index) => + `66666666-6666-4666-8666-${index.toString(16).padStart(12, "0")}`, + ); + const last = ids[512] ?? ""; + const metadata = () => + h.wire.pending.filter( + (entry) => + !entry.signal?.aborted && entry.filters.some((filter) => filter["#d"]), + ); + let reads = 0; + const answer = () => { + const [read] = metadata(); + assert(read); + if (asks(read.filters, last)) reads += 1; + h.wire.pending.splice(h.wire.pending.indexOf(read), 1); + read.respond([]); + }; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + h.owner.session.attend(ids); + await h.discover(); + await vi.waitFor(() => expect(metadata()).toHaveLength(1)); + // A new watch set attends the same channels before the old one stops. + h.owner.session.attend(ids); + await vi.advanceTimersByTimeAsync(0); + // Each read takes 9 seconds. The first one's end arms the 30-second + // retry, which fires while the last batch is still in flight. + for (let batch = 0; batch < 5; batch += 1) { + await vi.advanceTimersByTimeAsync(9_000); + answer(); + await vi.advanceTimersByTimeAsync(0); + } + // What the retry queued: the first four batches again, not the fifth. + while (metadata().length) { + answer(); + await vi.advanceTimersByTimeAsync(0); + } + } finally { + vi.useRealTimers(); + } + expect(reads).toBe(1); +}); + it("resolves the channels a read of every channel finds, of any kinds, once discovery is done", async () => { const h = harness(); const read = () => diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 190bc53460..e13fb366d3 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1378,9 +1378,13 @@ export function createRelaySession( if (!unknown.length) attendDelay = ATTEND_RETRY_MS; if (unknown.some((id) => attendChecked.has(id) && !attendReading.has(id))) retryAttendedLater(); - const fresh = unknown.filter((id) => !attendChecked.has(id)); + // One whose read is still in flight is checked by that read: a new + // attendance or a retry must not queue a second read of it. + const fresh = unknown.filter( + (id) => !attendChecked.has(id) && !attendReading.has(id), + ); + for (const id of unknown) attendChecked.add(id); for (const id of fresh) { - attendChecked.add(id); attendReading.add(id); learnedForAgents.add(id); } @@ -2386,7 +2390,8 @@ export function createRelaySession( channelIds.filter((id) => /^[a-zA-Z0-9_-]{1,128}$/.test(id)), ), ]; - // Unread or refused before, it is read again, and soon. + // Unread or refused before, it is read again, and soon; one read in + // flight now answers for it. for (const id of ids) attendChecked.delete(id); attendDelay = ATTEND_RETRY_MS; attended.add(ids); From c58cfd45508ebb4fb0350e144934c94029d2e391 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 20:10:35 -0500 Subject: [PATCH 22/32] fix(agents): apply a replay's start floor when it is admitted The floor that keeps a watch from waking for history replayed from before its stream started was taken once, as the replay entered the backlog. A replay that waited there for room, or for its channel's roster, could then wake a watch turned on or added meanwhile for an event from before it started. Replay entries now carry a replay mark, also through a roster hold, and the floor is taken from the watches as they are when the entry is admitted. Live events keep their rules. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/agents/runner.ts | 29 ++++++-- src/features/agents/service.test.ts | 111 ++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 76b2e36c3d..c2eab8ab95 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -261,6 +261,8 @@ type PendingEvent = { * (`unattended`), kept from arrival so a channel closed while it waits does * not change what it wakes. */ addressable: boolean; + /** Replayed history: it wakes no watch whose stream starts after it. */ + replay?: true; }; type Heard = { triggers: Trigger[]; held?: true; discarded?: string[] }; @@ -992,12 +994,11 @@ export class AgentRunners { if (this.isOwn(agent, event)) continue; // A replay waits for room rather than take the drops a burst of // live traffic would. - const done = this.before(runner, agent, event); runner.backlog.push({ event, ...(batch.channelId ? { channelId: batch.channelId } : {}), - ...(done.size ? { done } : {}), addressable: this.host.unattended(agent, event, true), + replay: true, }); } if (overflow) @@ -1007,7 +1008,9 @@ export class AgentRunners { if (batch.replay) this.admit(runner); } } - /** The agent's watches whose streams started after a replayed `event`. */ + /** The agent's watches whose streams started after a replayed `event`. + * Asked as it is admitted: an attention change starts its streams in the + * same call that saves it, before anything is admitted again. */ private before(runner: Runner, agent: Agent, event: RelayEvent) { return new Set( this.watches(runner, agent).flatMap(({ slug, watch }) => { @@ -1133,8 +1136,13 @@ export class AgentRunners { this.submit(runner, agent, job); } while (runner.backlog.length) { - const { event, channelId, done, addressable } = runner + const { event, channelId, addressable, replay, ...entry } = runner .backlog[0] as Runner["backlog"][0]; + // Matched against the watches as they are now: one turned on while a + // replay waited does not wake for it. + const done = replay + ? new Set([...(entry.done ?? []), ...this.before(runner, agent, event)]) + : entry.done; const heard = this.heard(runner, agent, event, addressable, done); if ( (heard.held && runner.held.size >= HELD_LIMIT) || @@ -1142,7 +1150,16 @@ export class AgentRunners { ) return; runner.backlog.shift(); - this.deliver(runner, agent, event, channelId, addressable, heard, done); + this.deliver( + runner, + agent, + event, + channelId, + addressable, + heard, + done, + replay, + ); } } /** Gives the agent `event` once, however many times it arrives. */ @@ -1859,6 +1876,7 @@ export class AgentRunners { addressable: boolean, heard = this.heard(runner, agent, event, addressable), done: ReadonlySet = new Set(), + replay?: true, ) { const { triggers } = heard; const completed = new Set([ @@ -1874,6 +1892,7 @@ export class AgentRunners { ...(channelId ? { channelId } : {}), done: completed, addressable, + ...(replay && { replay }), }); if (runner.held.size > HELD_LIMIT) { runner.held.delete(runner.held.keys().next().value as string); diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index aebc283624..354bcecb3a 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -8715,6 +8715,117 @@ it("wakes a watch turned back on for nothing its channel's new route replays fro } }); +it("wakes a watch turned on while a replay waits for room for nothing from before it started", async () => { + vi.useFakeTimers({ now: 1_000_000_000 }); + try { + const { service, run, emit, attended } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const watch = { + type: "event", + interest_id: "default", + since: 1, + channels: [plaza], + kinds: [], + } as const; + const save = (late: boolean) => + service.save(bot, { + attentionEnabled: true, + attention: { + "watch/early": { ...watch, enabled: true }, + "watch/late": { ...watch, enabled: late }, + }, + }); + const by = (slug: string) => + run.mock.calls.flatMap(([input]) => { + const { trigger } = input as { + trigger: { slug?: string; event: RelayEvent }; + }; + return trigger.slug === slug + ? [trigger.event.id.replace(/0+$/, "")] + : []; + }); + await save(false); + await vi.waitFor(() => expect([...attended].flat()).toContain(plaza)); + await vi.advanceTimersByTimeAsync(1_000); + const sent = Math.floor(Date.now() / 1000); + const ids = Array.from({ length: 80 }, (_, index) => `w${index}x`); + emit({ + replay: true, + channelId: plaza, + events: ids.map((id) => + event(id, { created_at: sent, tags: [["h", plaza]] }), + ), + }); + await vi.advanceTimersByTimeAsync(0); + // More than the agent takes at once: the rest wait for room. + expect(by("watch/early").length).toBeLessThan(80); + await vi.advanceTimersByTimeAsync(5_000); + await save(true); + await vi.advanceTimersByTimeAsync(1_000); + const on = Math.floor(Date.now() / 1000); + emit({ + replay: true, + channelId: plaza, + events: [event("after", { created_at: on + 1, tags: [["h", plaza]] })], + }); + await vi.advanceTimersByTimeAsync(10 * 60_000); + expect(by("watch/early")).toHaveLength(81); + expect(by("watch/late")).toEqual(["after"]); + } finally { + vi.useRealTimers(); + } +}); + +it("wakes a watch turned on while a replay waits for its roster for nothing from before it started", async () => { + vi.useFakeTimers({ now: 1_000_000_000 }); + try { + const { service, run, emit, confirm } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const watch = { + type: "event", + interest_id: "default", + since: 1, + channels: "all", + kinds: [], + } as const; + const save = (late: boolean) => + service.save(bot, { + attentionEnabled: true, + attention: { + "watch/early": { ...watch, enabled: true }, + "watch/late": { ...watch, enabled: late }, + }, + }); + const by = (slug: string) => + run.mock.calls.flatMap(([input]) => { + const { trigger } = input as { + trigger: { slug?: string; event: RelayEvent }; + }; + return trigger.slug === slug + ? [trigger.event.id.replace(/0+$/, "")] + : []; + }); + await save(false); + await vi.advanceTimersByTimeAsync(1_000); + const sent = Math.floor(Date.now() / 1000); + emit({ + replay: true, + events: [event("held", { created_at: sent, tags: [["h", "saved"]] })], + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(run).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(5_000); + await save(true); + await vi.advanceTimersByTimeAsync(1_000); + confirm("saved", [viewer, bot]); + await vi.advanceTimersByTimeAsync(60_000); + expect(by("watch/early")).toEqual(["held"]); + expect(by("watch/late")).toEqual([]); + } finally { + vi.useRealTimers(); + } +}); + it("answers a replayed mention that waited for room as it arrived, though its channel was closed since", async () => { vi.useFakeTimers({ now: 1_000_000_000 }); try { From 339eda6b41aa52564f09b623a1978762bf90880b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 4 Nov 2026 20:35:52 -0500 Subject: [PATCH 23/32] test(relay): cover viewer precedence and link-lookup provenance Adds session cases for a joined channel that agents also attend, which must stay heard for the viewer when live, and for an attended channel its read did not find, which becomes the viewer's once a link's own lookup of it succeeds. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/relay/attend.session.test.ts | 65 +++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index c5f4149cfc..07433afc24 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -501,6 +501,71 @@ it("counts an attended channel the viewer has not joined or opened as heard only stop(); }); +it("counts a joined channel that agents also attend as heard for the viewer, live and read", async () => { + const h = harness(); + const joined = () => h.update.mock.lastCall?.[1] ?? []; + h.connect(); + await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); + h.wire.next().respond([ + signed(relay, { + kind: 39000, + content: "", + created_at: 10, + tags: [["d", OPEN], ["name", "open"], ["public"]], + }), + signed(relay, { + kind: 39002, + content: "", + created_at: 10, + tags: [ + ["d", OPEN], + ["p", viewer.pubkey], + ], + }), + ] as RelayEvent[]); + await vi.waitFor(() => expect(joined()).toContain(OPEN)); + const stop = h.owner.session.attend([OPEN]); + await flush(); + // Known already, so attendance reads nothing; the live routes carry it for + // the viewer as without attendance, so a live mention is heard as on main. + expect( + h.wire.pending.some( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ), + ).toBe(false); + expect(h.owner.session.unattended(OPEN, true)).toBe(true); + expect(h.owner.session.unattended(OPEN, false)).toBe(true); + stop(); +}); + +it("counts an attended channel its read did not find as the viewer's once a link's lookup of it succeeds", async () => { + const h = harness(); + const stop = h.owner.session.attend([OPEN]); + await h.discover(); + const lookup = () => + h.wire.pending.findIndex( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ); + await vi.waitFor(() => expect(lookup()).not.toBe(-1)); + // Attendance's read finds nothing the viewer may read. + h.wire.pending.splice(lookup(), 1)[0]?.respond([]); + await flush(); + expect(h.owner.session.channels.get?.(OPEN)?.pending ?? true).toBe(true); + expect(h.owner.session.unattended(OPEN, false)).toBe(false); + // A link to it mounts, and the viewer's own lookup finds it public. + const release = h.owner.session.channels.refer?.(OPEN); + await vi.waitFor(() => expect(lookup()).not.toBe(-1)); + h.wire.pending + .splice(lookup(), 1)[0] + ?.respond(channel(OPEN, "public") as RelayEvent[]); + await vi.waitFor(() => + expect(h.owner.session.channels.get?.(OPEN)).toBeDefined(), + ); + expect(h.owner.session.unattended(OPEN, false)).toBe(true); + release?.(); + stop(); +}); + it("counts a channel only agents' reads found as the viewer's once a link to it mounts, with no lookup", async () => { const h = harness(); await h.discover(); From e54cc699a2d367702b22fe737ae2dd7f07c2acf0 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 5 Nov 2026 21:06:38 -0500 Subject: [PATCH 24/32] Recheck joined metadata after a drop; keep attendance provenance and batches current A dropped stream now marks each joined channel's metadata as only saved until discovery reads it again, so a non-member agent's replay is held when the channel may have turned private meanwhile. A suspended preview's recheck keeps the provenance the viewer had, so its catch-up mentions are not counted as agents-only. A queued attendance batch is filtered when it runs: channels no watch wants, or known since, are not read, so a replacement watch's lookup does not wait behind withdrawn batches. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/relay/attend.session.test.ts | 141 ++++++++++++++++++++++ src/features/relay/contracts.ts | 5 +- src/features/relay/session.ts | 29 ++++- src/features/relay/store.ts | 19 +++ 4 files changed, 188 insertions(+), 6 deletions(-) diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 07433afc24..653c8af4c4 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -888,3 +888,144 @@ it("reads unknown attended channels one batch at a time, in the background lane" // reader stays free for what the owner opens. expect(lookups()).toHaveLength(1); }); + +it("counts a joined channel's metadata as only saved from a stream drop until discovery reads it again", async () => { + const h = harness(); + const metadata = (visibility: "public" | "private", created_at: number) => + signed(relay, { + kind: 39000, + content: "", + created_at, + tags: [["d", OPEN], ["name", "open"], [visibility]], + }); + const roster = signed(relay, { + kind: 39002, + content: "", + created_at: 10, + tags: [ + ["d", OPEN], + ["p", viewer.pubkey], + ], + }); + const summary = () => h.owner.session.channels.get?.(OPEN); + const take = async ( + match: (entry: (typeof h.wire.pending)[number]) => boolean, + ) => { + const index = () => + h.wire.pending.findIndex( + (entry) => !entry.signal?.aborted && match(entry), + ); + await vi.waitFor(() => expect(index()).not.toBe(-1)); + const entry = h.wire.pending.splice(index(), 1)[0]; + assert(entry); + return entry; + }; + h.connect(); + (await take(() => true)).respond([ + metadata("public", 10), + roster, + ] as RelayEvent[]); + await vi.waitFor(() => expect(summary()?.visibility).toBe("public")); + expect(summary()?.metadataCached).toBeUndefined(); + // The channel may turn private while the stream is down, and the replay + // can arrive before discovery reads it: its public grant is only saved. + h.state({ status: "retrying", routes: [] }); + expect(summary()?.metadataCached).toBe(true); + h.connect(); + ( + await take((entry) => !entry.filters.some((filter) => filter["#d"])) + ).respond([roster] as RelayEvent[]); + // Discovery reads it again, and that read decides. + (await take((entry) => asks(entry.filters, OPEN))).respond([ + metadata("private", 11), + ] as RelayEvent[]); + await vi.waitFor(() => expect(summary()?.visibility).toBe("private")); + expect(summary()?.metadataCached).toBeUndefined(); +}); + +it("keeps a channel the viewer looked up heard for the viewer when a recheck after an access-revoked CLOSED finds it public again", async () => { + const h = harness(); + await h.discover(); + const lookup = () => + h.wire.pending.findIndex( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ); + // The viewer's own lookup, for a link since unmounted, finds it before + // agents attend it. + const release = h.owner.session.channels.refer?.(OPEN); + await vi.waitFor(() => expect(lookup()).not.toBe(-1)); + h.wire.pending + .splice(lookup(), 1)[0] + ?.respond(channel(OPEN, "public") as RelayEvent[]); + await vi.waitFor(() => + expect(h.owner.session.channels.get?.(OPEN)).toBeDefined(), + ); + release?.(); + const stop = h.owner.session.attend([OPEN]); + await vi.waitFor(() => + expect(h.update.mock.lastCall?.[0] ?? []).toContain(OPEN), + ); + expect(h.owner.session.unattended(OPEN, false)).toBe(true); + h.state({ + status: "connected", + routes: [ + { + id: "attended", + channelId: OPEN, + status: "error", + replay: "unknown", + error: "restricted: channel access revoked", + }, + ], + }); + // The recheck, and attendance's own read of the suspended channel, find it + // public again; neither is news to the viewer. + await vi.waitFor(() => expect(lookup()).not.toBe(-1)); + await flush(); + while (lookup() !== -1) + h.wire.pending + .splice(lookup(), 1)[0] + ?.respond(channel(OPEN, "public") as RelayEvent[]); + await flush(); + expect(h.owner.session.channels.get?.(OPEN)).toBeDefined(); + expect(h.owner.session.unattended(OPEN, false)).toBe(true); + stop(); +}); + +it("skips the queued lookups of attended channels no watch wants any more, so a new watch's lookup does not wait behind them", async () => { + const h = harness(); + // Five batches of channels the relay never finds. + const ids = Array.from( + { length: 640 }, + (_, index) => + `77777777-7777-4777-8777-${index.toString(16).padStart(12, "0")}`, + ); + const metadata = () => + h.wire.pending.filter( + (entry) => + !entry.signal?.aborted && entry.filters.some((filter) => filter["#d"]), + ); + await h.discover(); + const stop = h.owner.session.attend(ids); + await vi.waitFor(() => expect(metadata()).toHaveLength(1)); + // The watch set changes while the first batch is in flight. + stop(); + const next = h.owner.session.attend([OPEN]); + await flush(); + const [first] = metadata(); + assert(first); + expect(asks(first.filters, ids[0] ?? "")).toBe(true); + h.wire.pending.splice(h.wire.pending.indexOf(first), 1); + first.respond([]); + // The next read is the new watch's, not the withdrawn batches'. + await vi.waitFor(() => expect(metadata()).toHaveLength(1)); + const [after] = metadata(); + assert(after); + expect(asks(after.filters, OPEN)).toBe(true); + expect(ids.some((id) => asks(after.filters, id))).toBe(false); + after.respond(channel(OPEN, "public") as RelayEvent[]); + await vi.waitFor(() => + expect(h.update.mock.lastCall?.[0] ?? []).toContain(OPEN), + ); + next(); +}); diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 601a30a71e..33709f8f38 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -37,8 +37,9 @@ export type ChannelSummary = Readonly<{ readOnly?: true; /** Downloaded membership is display-only pending current relay confirmation. */ cached?: true; - /** Downloaded metadata the relay has not confirmed since: the channel may - * have turned private, so its saved visibility grants no one reading. */ + /** Metadata the relay has not confirmed since it was downloaded or since the + * stream last dropped: the channel may have turned private, so its saved + * visibility grants no one reading. */ metadataCached?: true; /** A Solo chat this device is starting; the relay has not confirmed it yet. */ pending?: true; diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index e13fb366d3..a207a506d4 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1384,20 +1384,40 @@ export function createRelaySession( (id) => !attendChecked.has(id) && !attendReading.has(id), ); for (const id of unknown) attendChecked.add(id); + // A suspended preview is unknown only until its recheck; it was known + // before, so its read keeps the provenance it had. + const suspended = new Set(channels.suspendedPreviews()); for (const id of fresh) { attendReading.add(id); - learnedForAgents.add(id); + if (!suspended.has(id)) learnedForAgents.add(id); } for (let start = 0; start < fresh.length; start += 128) { - const batch = fresh.slice(start, start + 128); - const lookUp = () => + const queued = fresh.slice(start, start + 128); + const lookUp = () => { + // A channel no attendance wants now, or known since, is not read: + // its batch would delay the reads that current watches wait for. + const wanted = new Set([...attended].flat()); + const batch = queued.filter( + (id) => wanted.has(id) && !channels.queries.get?.(id), + ); + for (const id of queued) + if (!batch.includes(id)) { + attendReading.delete(id); + if (!wanted.has(id) && !channels.queries.get?.(id)) + learnedForAgents.delete(id); + } + if (!batch.length) return Promise.resolve(); // In the background lane, so an interactive read never waits behind it. - resolve(batch, { signal: lifetime.signal, priority: "background" }) + return resolve(batch, { + signal: lifetime.signal, + priority: "background", + }) .catch(() => {}) .finally(() => { for (const id of batch) attendReading.delete(id); updateInterests(); }); + }; attendLookups = attendLookups.then(lookUp, lookUp); } }; @@ -3698,6 +3718,7 @@ export function createRelaySession( workflows.interrupt(); streamDropped = true; channels.staleHeads(); + channels.staleMetadata(); for (const thread of threads) thread.stale(); unread.stale(); inboxFeed.stale(); diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index b73ef7ec9c..9348155b45 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -4025,6 +4025,25 @@ export function createChannelStore( accept, clearCache, dispose, + /** A dropped stream also ends the relay's confirmation of each joined + * channel's metadata: the channel may turn private meanwhile, and the + * replay after a reconnect can arrive before discovery reads it again. + * Until a fresh read, the metadata counts as only saved + * (`metadataCached`), and discovery's next read asks for it again. */ + staleMetadata() { + if (disposed || !transport || !discovery) return; + let changed = false; + for (const [id, roster] of discovery.rosterVersions()) + if ( + hasTag(roster, "p", transport.viewer) && + discovery.metadataVersion(id) && + !pendingMetadata.has(id) + ) { + pendingMetadata.add(id); + changed = true; + } + if (changed) setList(list, true); + }, /** A disconnected stream invalidates a head's freshness lease, not its content. * Inactive cached heads will revalidate when demanded, without an all-roster read. * The session drops its queued catch-ups with the stream, so an empty window From 667e3817b70ee06da418779445703d8d6a2f65ef Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 3 Nov 2026 15:19:41 -0500 Subject: [PATCH 25/32] fix(agents): refuse watches on unreachable channels Co-authored-by: Thomas Signed-off-by: John Tennant --- docs/agents.md | 2 +- docs/buzz-help.md | 2 +- src/buzz-mcp/attention.ts | 2 +- src/features/agents/runner.ts | 23 +++++ src/features/agents/service.test.ts | 155 ++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 3 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 55fe7d2ca2..bfda3af406 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1594,7 +1594,7 @@ Event watches and timers accept an optional delivery `channel` and thread `root` Without an address, the subscription runs an independent turn in its Interest's lane. Timers retain their occurrence and expiry budgets. An addressed timer waits without spending an occurrence while destination membership is unconfirmed or absent. `watch_show` reports delivery as held or blocked with a reason; delivery resumes once membership is confirmed. Expiry still applies while held. Membership checks never join channels. The Attention panel shows listening channels separately from the delivery destination. -Event matches wait for confirmation of both listening and destination rosters. Confirmed destination nonmembership discards the match. Addressed wakes refresh the Interest instructions after harness startup and queue waits, immediately before submission, while retaining cancellation checks. +Agent watch writes refuse enabled watches whose explicit listening channels cannot deliver events under the same policy used for event delivery, or whose delivery destination lacks confirmed membership. Disabled watches remain editable and removable. Event matches wait for confirmation of both listening and destination rosters. Confirmed destination nonmembership discards the match. Addressed wakes refresh the Interest instructions after harness startup and queue waits, immediately before submission, while retaining cancellation checks. Conversation wakes are local, have no signed message to resume, and never publish their synthetic anchor ids. Pi saves an Attention wake's reply destination separately from its tool context so a recovered answer keeps its destination. Ordinary plugin wakes retain the separate contract above. diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 1eb148c985..1b273e0cdf 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -813,7 +813,7 @@ confirmation. mention can still wake an agent when Attention is off; turning Attention on does not grant new access. An event watch hears the channels its agent may read, including public channels the agent has not joined. A watch's delivery channel must be one the agent is - a member of; the relay decides where the agent may post. On macOS and Linux, + a member of; the relay decides where the agent may post. Agents cannot enable a watch for an unreadable listening channel or a delivery channel without confirmed membership. On macOS and Linux, watched commands stop when their Buzz process exits. During a handoff between Buzz builds, the new copy waits for the previous command to stop, then continues the watch. diff --git a/src/buzz-mcp/attention.ts b/src/buzz-mcp/attention.ts index a8d46994ce..2ab0890158 100644 --- a/src/buzz-mcp/attention.ts +++ b/src/buzz-mcp/attention.ts @@ -167,7 +167,7 @@ export const ATTENTION_TOOLS: readonly Tool[] = [ "watch_add", `Add an event watch (since and channels), a timer (prompt and interval_secs), a source subscription (provider and config) to an Interest; only for a new watch. -Event watch: it hears the channels you may read: those you are a member of, and public channels you have not joined. "all" means all of those, but hears a public channel you have not joined only about 30 seconds late. A delivery channel must be one you are a member of; the relay decides where you may post. It sees events that arrive while it is on: an earlier since does not replay older ones. Each matching event watch runs independently. Prefer deterministic selectors for hard rules; use the classifier only for judgment. authors keeps only those authors, exclude_authors: ["OWNER_HEX"] drops the owner's own messages, and is_reply: false keeps only top-level messages (true: only thread replies). A watch has authors or exclude_authors, not both. When a watch reports to the owner, exclude the owner's messages unless they are the signal. +Event watch: it hears the channels you may read: those you are a member of, and public channels you have not joined. "all" means all of those, but hears a public channel you have not joined only about 30 seconds late. A delivery channel must be one you are a member of; the relay decides where you may post. Enabling or editing an enabled watch refuses unreadable listening channels and delivery channels where membership is absent or unconfirmed. It sees events that arrive while it is on: an earlier since does not replay older ones. Each matching event watch runs independently. Prefer deterministic selectors for hard rules; use the classifier only for judgment. authors keeps only those authors, exclude_authors: ["OWNER_HEX"] drops the owner's own messages, and is_reply: false keeps only top-level messages (true: only thread replies). A watch has authors or exclude_authors, not both. When a watch reports to the owner, exclude the owner's messages unless they are the signal. Classifier, for noisy event watches or source subscriptions: Jev tests relevance after the normal filters. It sees one event or emitted source output, not history, and has no tools. Use named yes/no questions, for example {"questions":{"release_blocked":{"question":"Does the event say the release cannot proceed until something is fixed?","true":"It reports a release blocker, in any form: a question, an instruction or a status report.","false":"Routine release progress, or a problem that does not stop the release."}}}. Write questions for the facts the Interest needs, not for message types, and accept any phrasing of those facts. Ask one thing per question. The event passes when any question says yes, and each added question can add extra passes, so keep them few. For "A but not B", write one question that says so. Optional per question: true and false descriptions, guidance for examples and exclusions, and threshold (default 0.2; lower passes more). Change a threshold only when eval cases support it. Keep actions in the Interest. Pass uncertain cases. Errors pass events through. Every answer says classifier: available or unavailable. Classifiers can be saved either way and run once one is available; until then a classified watch's events pass unchecked, and the turn they start says so in a block. diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index c2eab8ab95..21c4ab03b4 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -51,6 +51,7 @@ import { } from "./attention"; import { checkedWrite, + AttentionError, interestIds, show, watchSummaries, @@ -3202,6 +3203,28 @@ export class AgentRunners { ); lifetime.throwIfAborted(); check?.(); + if (value && value.type !== "interest" && value.enabled) { + const agent = this.host.agent(key); + if (!agent) throw new Error("This agent is no longer running"); + if (value.type === "event" && value.channels !== "all") + for (const channel of value.channels) + if ( + this.host.reads(agent, { tags: [["h", channel]] }) !== true + ) + throw new AttentionError( + "invalid-operation", + `Cannot receive events from channel ${channel}. Join it and wait for confirmed channel metadata before enabling this watch.`, + ); + if ( + value.channel && + this.host.member(agent, { tags: [["h", value.channel]] }) !== + true + ) + throw new AttentionError( + "invalid-operation", + `Agent is not a member of the delivery channel ${value.channel}, or membership is unconfirmed. Join it and wait for confirmation before enabling this watch.`, + ); + } return next; }); return show(after, slug, seconds()); diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 354bcecb3a..233b34ece5 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -11187,6 +11187,161 @@ it("lets a run read and write its attention with expected-state tokens", async ( ).rejects.toThrow(/^conflict:/); }); +/** Valid tool channel ids for nonmember, cached and unknown metadata. */ +const refusedChannels = [ + "10000000-0000-4000-8000-000000000001", + "10000000-0000-4000-8000-000000000002", + "10000000-0000-4000-8000-000000000003", +] as const; +function watchChannelMetadata(session: RelaySession) { + const snapshot = session.channels.list(); + vi.spyOn(session.channels, "list").mockReturnValue({ + ...snapshot, + channels: snapshot.channels.map((row) => ({ + ...row, + id: + row.id === "private" + ? refusedChannels[0] + : row.id === "saved" + ? refusedChannels[1] + : row.id, + })), + }); +} + +it("refuses watch channels delivery cannot receive, without saving add or update", async () => { + const { service, run, emit, session } = await setup(); + watchChannelMetadata(session); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { attentionEnabled: true }); + const { attention } = await handleOf(run, emit); + for (const target of refusedChannels) { + await expect( + callAttentionTool(attention, "watch_add", { + id: "unreachable", + interest: "default", + expected_state: "object-v1:absent", + type: "event", + channels: [channel, target], + kinds: [9], + since: "now", + }), + ).rejects.toThrow(/cannot receive events.*channel/i); + expect((await attention.show("watch/unreachable")).object).toBeNull(); + } + await callAttentionTool(attention, "watch_add", { + id: "reachable", + interest: "default", + expected_state: "object-v1:absent", + type: "event", + channels: [channel], + kinds: [9], + since: "now", + }); + const before = await attention.show("watch/reachable"); + await expect( + callAttentionTool(attention, "watch_update", { + id: "reachable", + interest: "default", + expected_state: before.expected_state, + channels: [refusedChannels[0]], + since: "now", + }), + ).rejects.toThrow(/cannot receive events.*channel/i); + expect(await attention.show("watch/reachable")).toEqual(before); +}); + +it.each(["event", "timer", "source"] as const)( + "refuses an unreachable %s delivery channel", + async (type) => { + const { service, run, emit, session } = await setup(); + watchChannelMetadata(session); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { attentionEnabled: true }); + const { attention } = await handleOf(run, emit); + await expect( + callAttentionTool(attention, "watch_add", { + id: "unreachable", + interest: "default", + expected_state: "object-v1:absent", + type, + channel: refusedChannels[0], + ...(type === "event" + ? { channels: [channel], since: "now" } + : type === "timer" + ? { interval_secs: 60, prompt: "Check" } + : { provider: "attention/command", config: { command: ["true"] } }), + }), + ).rejects.toThrow(/not a member.*delivery channel/i); + expect((await attention.show("watch/unreachable")).object).toBeNull(); + }, +); + +it("uses one listening policy for watch admission and event delivery", async () => { + const { service, run, emit, session } = await setup(); + watchChannelMetadata(session); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { attentionEnabled: true }); + const { attention } = await handleOf(run, emit); + // Model the broader access policy of PR1854 without changing destination membership. + vi.spyOn( + service as unknown as { + reads(agent: Agent, event: Pick): boolean; + }, + "reads", + ).mockReturnValue(true); + await callAttentionTool(attention, "watch_add", { + id: "policy", + interest: "default", + expected_state: "object-v1:absent", + type: "event", + channels: [refusedChannels[0]], + kinds: [9], + since: 1, + }); + const called = run.mock.calls.length; + emit({ + events: [event("policy-event", { tags: [["h", refusedChannels[0]]] })], + }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(called + 1)); + expect(run.mock.calls.at(-1)?.[0].trigger).toMatchObject({ + type: "watch", + slug: "watch/policy", + }); +}); + +it("can disable an unreachable watch, but cannot enable it until channel membership is confirmed", async () => { + const { service, run, emit, session } = await setup(); + watchChannelMetadata(session); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { attentionEnabled: true }); + const { attention } = await handleOf(run, emit); + await callAttentionTool(attention, "watch_add", { + id: "disabled", + interest: "default", + expected_state: "object-v1:absent", + type: "event", + enabled: false, + channels: [refusedChannels[0]], + kinds: [9], + since: "now", + }); + const before = await attention.show("watch/disabled"); + await expect( + callAttentionTool(attention, "watch_enable", { + id: "disabled", + interest: "default", + expected_state: before.expected_state, + }), + ).rejects.toThrow(/cannot receive events.*channel/i); + await callAttentionTool(attention, "watch_remove", { + id: "disabled", + interest: "default", + expected_state: before.expected_state, + }); + expect((await attention.show("watch/disabled")).object).toBeNull(); +}); + it("fails an agent's attention write that agent storage could not save, and keeps the saved value", async () => { let failing = false; const storage = fileStore(new Map(), async () => { From 9e6ac9c7c3c8d0314292d5f578086707920eb899 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 3 Nov 2026 15:23:36 -0500 Subject: [PATCH 26/32] test(agents): describe watch admission policy invariant Co-authored-by: Thomas Signed-off-by: John Tennant --- src/features/agents/service.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 233b34ece5..aa3488d0d4 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -11283,7 +11283,7 @@ it("uses one listening policy for watch admission and event delivery", async () await service.create({ type: "example/echo", name: "Echo" }); await service.save(bot, { attentionEnabled: true }); const { attention } = await handleOf(run, emit); - // Model the broader access policy of PR1854 without changing destination membership. + // Listening access may be broader than destination membership; admission and delivery share the policy. vi.spyOn( service as unknown as { reads(agent: Agent, event: Pick): boolean; From dce81d742fb93845c577b2aaac98542889d5bd04 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 3 Nov 2026 16:05:39 -0500 Subject: [PATCH 27/32] fix(agents): preserve GitHub watches on refused moves Move a helper subscription between Interests in the same checked write as destination admission, preserving the previous object on refusal. Co-authored-by: Thomas Signed-off-by: John Tennant --- src/bundled/github/watcher.ts | 56 ++++++------- src/features/agents/attention-objects.test.ts | 43 ++++++++++ src/features/agents/attention-objects.ts | 7 +- src/features/agents/service.test.ts | 81 +++++++++++++++++++ 4 files changed, 158 insertions(+), 29 deletions(-) diff --git a/src/bundled/github/watcher.ts b/src/bundled/github/watcher.ts index b314a04bee..44a03437fd 100644 --- a/src/bundled/github/watcher.ts +++ b/src/bundled/github/watcher.ts @@ -512,33 +512,35 @@ export function watchPulls( // github-pulls Interest this plugin used to make, moves to this one. const owner = object && object.type !== "interest" ? object.interest_id : undefined; - let expected = priorObject.expected_state; - if (object && owner !== interest) - expected = ( - JSON.parse( - await callAttentionTool(attention, "watch_remove", { - id, - interest: owner, - expected_state: expected, - }), - ) as { expected_state: string } - ).expected_state; - const update = object && owner === interest; - await callAttentionTool( - attention, - update ? "watch_update" : "watch_add", - { - id, - interest, - expected_state: expected, - ...(update - ? fields.root - ? {} - : { clear: ["root"] } - : { type: "source" }), - ...fields, - }, - ); + if (object && object.type !== "interest" && owner !== interest) { + // Admission and the transfer share one write; a refusal keeps the old watch. + await attention.write( + `watch/${id}`, + { type: "source", interest_id: interest, ...fields }, + { + expected: priorObject.expected_state, + interest: object.interest_id, + move: true, + }, + ); + } else { + const update = !!object; + await callAttentionTool( + attention, + update ? "watch_update" : "watch_add", + { + id, + interest, + expected_state: priorObject.expected_state, + ...(update + ? fields.root + ? {} + : { clear: ["root"] } + : { type: "source" }), + ...fields, + }, + ); + } return [ `Watching ${pull.url}. You will be woken here${moved ? ", no longer in the conversation that asked before," : ""} when CI finishes on its latest commit or new feedback arrives.`, ...(object && owner !== interest diff --git a/src/features/agents/attention-objects.test.ts b/src/features/agents/attention-objects.test.ts index 3eec9ef4a5..316cab4390 100644 --- a/src/features/agents/attention-objects.test.ts +++ b/src/features/agents/attention-objects.test.ts @@ -137,6 +137,49 @@ describe("checked writes", () => { ); }); + it("lets an internal helper move a watch with its current owner's token", async () => { + const start = record(); + const expected = (await show(start, "watch/asks", now)).expected_state; + const next = watch({ interest_id: "other" }); + const options = { expected, interest: "triage", move: true }; + const moved = await checkedWrite(start, "watch/asks", next, options, now); + expect(moved.attention["watch/asks"]?.value).toEqual(next); + await expect( + checkedWrite( + start, + "watch/asks", + next, + { ...options, interest: "other" }, + now, + ), + ).rejects.toThrow(/^wrong-scope:/); + await expect( + checkedWrite(moved, "watch/asks", next, options, now), + ).rejects.toThrow(/^wrong-scope:/); + const edited = await write(start, "watch/asks", watch({ name: "Edited" })); + await expect( + checkedWrite(edited, "watch/asks", next, options, now), + ).rejects.toThrow(/^conflict:/); + await expect( + checkedWrite( + start, + "watch/asks", + watch({ interest_id: "missing" }), + options, + now, + ), + ).rejects.toThrow(/^not-found:/); + await expect( + checkedWrite( + start, + "watch/asks", + timer({ interest_id: "other" }), + options, + now, + ), + ).rejects.toThrow(/^invalid-operation:.*type/); + }); + it("remove an Interest only after its watches", async () => { const start = record(); await expect(write(start, "interest/triage", null)).rejects.toThrow( diff --git a/src/features/agents/attention-objects.ts b/src/features/agents/attention-objects.ts index 3f671f6c42..eb1a4e5ef4 100644 --- a/src/features/agents/attention-objects.ts +++ b/src/features/agents/attention-objects.ts @@ -269,6 +269,8 @@ export type WriteOptions = Readonly<{ /** A timer rearm: its deadline is set from `armed_at` even when that is * unchanged, keeping the occurrences used. */ rearm?: boolean; + /** Internal helpers may transfer a watch atomically, scoped to its current Interest. */ + move?: boolean; }>; /** The record after one checked write: replaces (or with `null` removes) the @@ -277,7 +279,7 @@ export async function checkedWrite( record: AgentRecord, slug: string, value: AttentionValue | null, - { expected, interest, rearm }: WriteOptions, + { expected, interest, rearm, move }: WriteOptions, now: number, ): Promise { const parsed = parseSlug(slug); @@ -337,7 +339,8 @@ export async function checkedWrite( value && prior.type !== "interest" && value.type !== "interest" && - prior.interest_id !== value.interest_id + prior.interest_id !== value.interest_id && + !move ) throw new AttentionError( "invalid-operation", diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index aa3488d0d4..d978d2bad0 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -1,3 +1,7 @@ +import { watchPulls } from "../../bundled/github/watcher"; +import type { PullStates } from "../../bundled/github/pull-states"; +import type { AddedTool } from "../../buzz-mcp/session-tools"; +import type { BuzzClient } from "../../buzz-mcp/client"; import { followUpQueue } from "../messages/follow-up-queue"; import { registerAttention } from "./attention-command"; import { canMessageAgent, createAgentChoices } from "./choices"; @@ -28,6 +32,7 @@ import { bytesToHex } from "nostr-tools/utils"; import { finalizeEvent, generateSecretKey } from "nostr-tools"; import { afterEach, + onTestFinished, beforeAll, beforeEach, describe, @@ -11277,6 +11282,82 @@ it.each(["event", "timer", "source"] as const)( }, ); +it.each(refusedChannels)( + "preserves a GitHub watch when a move to %s is refused", + async (target) => { + const { service, run, emit, session } = await setup(); + watchChannelMetadata(session); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { attentionEnabled: true }); + const { attention } = await handleOf(run, emit); + vi.stubGlobal("localStorage", { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + const tools: AddedTool[] = []; + watchPulls( + { + agents: { + addAttentionSource: () => {}, + addTool: (tool: AddedTool) => tools.push(tool), + }, + effect: () => {}, + } as unknown as Context, + { + read: async () => ({ + state: { + state: "open", + head: "1111111111", + feedback: { viewer: "alice", ids: [], items: [] }, + }, + }), + } as unknown as PullStates, + undefined, + ); + const tool = tools[0]; + if (!tool) throw new Error("Missing GitHub watch tool"); + const client = { key: bot, pubkey: bot, attention } as BuzzClient; + const url = "https://github.com/block/buzz-app/pull/2311"; + await tool.call( + client, + { channel, topLevel: true }, + { url, interest: "default" }, + ); + const watch = attention.watches({ interest: "default", type: "source" })[0]; + if (!watch) throw new Error("Missing GitHub watch"); + const slug = `watch/${watch.id}`; + const before = await attention.show(slug); + await attention.write( + "interest/moved", + { type: "interest", instructions: "Follow the PR here" }, + { expected: "object-v1:absent" }, + ); + await expect( + tool.call( + client, + { channel: target, topLevel: true }, + { url, interest: "moved" }, + ), + ).rejects.toThrow(/not a member.*delivery channel/i); + expect(await attention.show(slug)).toEqual(before); + // Retry after returning to a confirmed destination, using the same watch. + await tool.call( + client, + { channel, topLevel: true }, + { url, interest: "moved" }, + ); + expect((await attention.show(slug)).object).toMatchObject({ + interest_id: "moved", + channel, + }); + expect(attention.watches({ type: "source" })).toHaveLength(1); + }, +); + it("uses one listening policy for watch admission and event delivery", async () => { const { service, run, emit, session } = await setup(); watchChannelMetadata(session); From 88a61082dcc164b2fa0b1d72bd99dce3a1dea825 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 3 Nov 2026 16:11:04 -0500 Subject: [PATCH 28/32] fix(github): respect Attention cancellation during watch moves Recheck Attention after reading GitHub before the atomic transfer, preserving the same cancellation behavior as the public watch tool path. Co-authored-by: Thomas Signed-off-by: John Tennant --- src/bundled/github/watcher.test.ts | 27 +++++++++++++++++++++++++++ src/bundled/github/watcher.ts | 2 ++ 2 files changed, 29 insertions(+) diff --git a/src/bundled/github/watcher.test.ts b/src/bundled/github/watcher.test.ts index 051b611993..4eddb737df 100644 --- a/src/bundled/github/watcher.test.ts +++ b/src/bundled/github/watcher.test.ts @@ -504,6 +504,33 @@ describe("github_watch_pr", () => { app.dispose(); }); + it("keeps the old Interest watch if Attention turns off during a move", async () => { + const app = setup(); + await app.call({ url }); + const id = Object.keys(app.records[agent] ?? {}).find((slug) => + slug.startsWith("watch/"), + ); + if (!id) throw new Error("Missing watch"); + const watch = app.records[agent]?.[id]; + if (watch?.type !== "source") throw new Error("Missing source"); + await app.save(agent, { + attention: { + "interest/github-pulls": { type: "interest", instructions: "Follow" }, + [id]: { ...watch, interest_id: "github-pulls" }, + }, + }); + const before = app.records[agent]?.[id]; + const read = app.states.read.bind(app.states); + vi.spyOn(app.states, "read").mockImplementationOnce(async (...args) => { + const result = await read(...args); + app.attentionEnabled(false); + return result; + }); + await expect(app.call({ url }, true)).rejects.toThrow(/Turn on Attention/); + expect(app.records[agent]?.[id]).toEqual(before); + app.dispose(); + }); + it("drops watches saved before Attention instead of importing them", async () => { localStorage.setItem( "buzz.github.pull-watches.v1", diff --git a/src/bundled/github/watcher.ts b/src/bundled/github/watcher.ts index 44a03437fd..a37d94030b 100644 --- a/src/bundled/github/watcher.ts +++ b/src/bundled/github/watcher.ts @@ -514,6 +514,8 @@ export function watchPulls( object && object.type !== "interest" ? object.interest_id : undefined; if (object && object.type !== "interest" && owner !== interest) { // Admission and the transfer share one write; a refusal keeps the old watch. + if (!attention.enabled()) + throw new Error("Turn on Attention to watch pull requests"); await attention.write( `watch/${id}`, { type: "source", interest_id: interest, ...fields }, From 0057ed8c5723bda4281e40c334543cafcb8dfffe Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 4 Nov 2026 12:38:06 -0500 Subject: [PATCH 29/32] Resolve listening metadata before watch admission Co-authored-by: Thomas Signed-off-by: John Tennant --- src/features/agents/runner.ts | 18 +++++- src/features/agents/service.test.ts | 94 +++++++++++++++++++++++++++++ src/features/agents/service.ts | 7 +++ 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 21c4ab03b4..72e5e2d0ae 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -426,6 +426,12 @@ export type RunnerHost = { * channel is public; undefined as for `member`. Only a member may be * delivered to. */ reads(agent: Agent, event: Pick): boolean | undefined; + /** Resolves listening metadata before an enabled watch is admitted. */ + resolveChannels?( + agent: Agent, + channels: readonly string[], + signal: AbortSignal, + ): Promise; /** Whether the event's channel is a DM between the agent and one other. */ dm(agent: Agent, event: RelayEvent): boolean; /** Whether the event came as it would have with no watch keeping channels @@ -3206,15 +3212,23 @@ export class AgentRunners { if (value && value.type !== "interest" && value.enabled) { const agent = this.host.agent(key); if (!agent) throw new Error("This agent is no longer running"); - if (value.type === "event" && value.channels !== "all") + if (value.type === "event" && value.channels !== "all") { + await this.host.resolveChannels?.( + agent, + value.channels, + lifetime, + ); + lifetime.throwIfAborted(); + check?.(); for (const channel of value.channels) if ( this.host.reads(agent, { tags: [["h", channel]] }) !== true ) throw new AttentionError( "invalid-operation", - `Cannot receive events from channel ${channel}. Join it and wait for confirmed channel metadata before enabling this watch.`, + `Cannot receive events from channel ${channel}. Check channel access and wait for confirmed metadata before enabling this watch.`, ); + } if ( value.channel && this.host.member(agent, { tags: [["h", value.channel]] }) !== diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index d978d2bad0..e8e9b6e71d 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -11358,6 +11358,100 @@ it.each(refusedChannels)( }, ); +it("resolves an unopened public listening channel before admission and delivers its events", async () => { + const { service, run, emit, session } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { attentionEnabled: true }); + const { attention } = await handleOf(run, emit); + const target = refusedChannels[2]; + let resolved = false; + const get = session.channels.get?.bind(session.channels); + vi.spyOn(session.channels, "get").mockImplementation((id) => + id === target + ? resolved + ? { id, name: id, visibility: "public", members: [other] } + : undefined + : get?.(id), + ); + const resolve = vi.fn(async (ids: readonly string[]) => { + expect(ids).toEqual([target]); + resolved = true; + }); + Object.assign(session.channels, { resolve }); + await callAttentionTool(attention, "watch_add", { + id: "unopened", + interest: "default", + expected_state: "object-v1:absent", + type: "event", + channels: [target], + kinds: [9], + since: 1, + }); + expect(resolve).toHaveBeenCalledOnce(); + const called = run.mock.calls.length; + emit({ events: [event("unopened-event", { tags: [["h", target]] })] }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(called + 1)); + expect(run.mock.calls.at(-1)?.[0].trigger).toMatchObject({ + type: "watch", + slug: "watch/unopened", + }); + await expect( + callAttentionTool(attention, "watch_update", { + id: "unopened", + interest: "default", + expected_state: (await attention.show("watch/unopened")).expected_state, + channel: target, + }), + ).rejects.toThrow(/not a member.*delivery channel/i); +}); + +it.each(["private", "empty", "failed"] as const)( + "preserves a watch when listening metadata resolves %s", + async (result) => { + const { service, run, emit, session } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + await service.save(bot, { attentionEnabled: true }); + const { attention } = await handleOf(run, emit); + await callAttentionTool(attention, "watch_add", { + id: "kept", + interest: "default", + expected_state: "object-v1:absent", + type: "event", + channels: [channel], + kinds: [9], + since: 1, + }); + const before = await attention.show("watch/kept"); + const target = refusedChannels[2]; + let resolved = false; + const get = session.channels.get?.bind(session.channels); + vi.spyOn(session.channels, "get").mockImplementation((id) => + id === target && resolved && result === "private" + ? { id, name: id, visibility: "private", members: [other] } + : get?.(id), + ); + const resolve = vi.fn(async () => { + if (result === "failed") throw new Error("Metadata read failed"); + resolved = true; + }); + Object.assign(session.channels, { resolve }); + await expect( + callAttentionTool(attention, "watch_update", { + id: "kept", + interest: "default", + expected_state: before.expected_state, + channels: [target], + }), + ).rejects.toThrow( + result === "failed" + ? /Metadata read failed/ + : /cannot receive events.*channel/i, + ); + expect(resolve).toHaveBeenCalledOnce(); + expect(await attention.show("watch/kept")).toEqual(before); + }, +); + it("uses one listening policy for watch admission and event delivery", async () => { const { service, run, emit, session } = await setup(); watchChannelMetadata(session); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 2bf3402881..b6e5f90146 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -1938,6 +1938,13 @@ export class AgentsService extends Service implements Agents { agent: (key) => this.agent(key), member: (agent, event) => this.member(agent, event), reads: (agent, event) => this.reads(agent, event), + resolveChannels: async (agent, ids, signal) => { + const channels = this.bindingOf(agent)?.session.channels; + const unknown = ids.filter( + (id) => !this.channelOf(agent, { tags: [["h", id]] }), + ); + if (unknown.length) await channels?.resolve?.(unknown, { signal }); + }, dm: (agent, event) => this.dm(agent, event), unattended: (agent, event, live) => { const channel = event.tags.find((tag) => tag[0] === "h")?.[1]; From 67780182ee07a98a48ccf20fbcf0118acbf18887 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 6 Nov 2026 14:09:25 -0500 Subject: [PATCH 30/32] fix(agents): preserve watch metadata provenance during admission Refuse agent metadata lookups until channel discovery is complete, and keep explicit agent lookups separate from viewer discovery. Co-authored-by: Thomas Signed-off-by: John Tennant --- src/features/agents/service.test.ts | 5 ++- src/features/agents/service.ts | 3 +- src/features/relay/attend.session.test.ts | 50 +++++++++++++++++++++++ src/features/relay/session.ts | 21 +++++++--- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index e8e9b6e71d..5a74e3879a 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -11387,7 +11387,10 @@ it("resolves an unopened public listening channel before admission and delivers kinds: [9], since: 1, }); - expect(resolve).toHaveBeenCalledOnce(); + expect(resolve).toHaveBeenCalledWith( + [target], + expect.objectContaining({ viewerLookup: false }), + ); const called = run.mock.calls.length; emit({ events: [event("unopened-event", { tags: [["h", target]] })] }); await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(called + 1)); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index b6e5f90146..ba47905a5a 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -1943,7 +1943,8 @@ export class AgentsService extends Service implements Agents { const unknown = ids.filter( (id) => !this.channelOf(agent, { tags: [["h", id]] }), ); - if (unknown.length) await channels?.resolve?.(unknown, { signal }); + if (unknown.length) + await channels?.resolve?.(unknown, { signal, viewerLookup: false }); }, dm: (agent, event) => this.dm(agent, event), unattended: (agent, event, live) => { diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 653c8af4c4..749f294333 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -1029,3 +1029,53 @@ it("skips the queued lookups of attended channels no watch wants any more, so a ); next(); }); + +it("keeps explicit agent metadata admission out of viewer provenance", async () => { + const h = harness(); + await h.discover(); + const resolving = h.owner.session.channels.resolve?.([OPEN], { + viewerLookup: false, + }); + assert(resolving); + await vi.waitFor(() => + expect(h.wire.pending.some((entry) => asks(entry.filters, OPEN))).toBe( + true, + ), + ); + h.wire.pending + .splice( + h.wire.pending.findIndex((entry) => asks(entry.filters, OPEN)), + 1, + )[0] + ?.respond(channel(OPEN, "public") as RelayEvent[]); + await resolving; + h.owner.session.attend([OPEN]); + expect(h.owner.session.unattended(OPEN, false)).toBe(false); +}); + +it.each(["loading", "error"] as const)( + "refuses agent metadata admission while discovery is %s", + async (status) => { + const h = harness(); + h.connect(); + await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); + if (status === "error") h.wire.next().fail(new Error("Discovery failed")); + await vi.waitFor(() => + expect(h.owner.session.channels.list().status).toBe(status), + ); + const before = h.owner.session.channels.list(); + const resolving = h.owner.session.channels.resolve?.([OPEN], { + viewerLookup: false, + }); + assert(resolving); + const outcome = resolving.then( + () => "resolved", + (error: Error) => error.message, + ); + await flush(); + const exact = h.wire.pending.find((entry) => asks(entry.filters, OPEN)); + exact?.respond(channel(OPEN, "public") as RelayEvent[]); + expect(await outcome).toMatch(/not discovered/i); + expect(h.owner.session.channels.list()).toEqual(before); + }, +); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index a207a506d4..e8e22efee0 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1176,13 +1176,22 @@ export function createRelaySession( const projectedChannels = new WeakMap(); const channelQueries = Object.freeze({ ...channels.queries, - // Only the viewer's own surfaces call it from outside the session (a - // scoped search, a requested channel, a repository page), so what they - // look up becomes the viewer's, as without any `attend`. + // Viewer surfaces promote their lookups by default. Explicit agent lookups + // extend discovery without turning watch-only channels into mention targets. ...(channels.queries.resolve && { - resolve: (ids: readonly string[], options?: ChannelReadOptions) => - channels.queries.resolve?.(ids, { ...options, viewerLookup: true }) ?? - Promise.resolve(), + resolve: async (ids: readonly string[], options?: ChannelReadOptions) => { + const viewerLookup = options?.viewerLookup !== false; + if (!viewerLookup) { + if (channels.queries.list().status !== "ready") + throw new ReadError( + "unavailable", + "Channels are not discovered yet.", + ); + for (const id of ids) + if (!channels.queries.get?.(id)) learnedForAgents.add(id); + } + await channels.queries.resolve?.(ids, { ...options, viewerLookup }); + }, }), list() { const snapshot = channels.queries.list(); From 974e2aa6bf4622dc4ce6fbf4661436737ac2a525 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 6 Nov 2026 14:19:36 -0500 Subject: [PATCH 31/32] fix(relay): retain viewer provenance through suspended admission Keep previously viewer-resolved previews out of agent-only discovery during their access recheck. Cover recovery both with and without an admission lookup. Co-authored-by: Thomas Signed-off-by: John Tennant --- src/features/relay/attend.session.test.ts | 98 ++++++++++++----------- src/features/relay/session.ts | 4 +- 2 files changed, 56 insertions(+), 46 deletions(-) diff --git a/src/features/relay/attend.session.test.ts b/src/features/relay/attend.session.test.ts index 749f294333..4a56160fcb 100644 --- a/src/features/relay/attend.session.test.ts +++ b/src/features/relay/attend.session.test.ts @@ -943,54 +943,62 @@ it("counts a joined channel's metadata as only saved from a stream drop until di expect(summary()?.metadataCached).toBeUndefined(); }); -it("keeps a channel the viewer looked up heard for the viewer when a recheck after an access-revoked CLOSED finds it public again", async () => { - const h = harness(); - await h.discover(); - const lookup = () => - h.wire.pending.findIndex( - (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), - ); - // The viewer's own lookup, for a link since unmounted, finds it before - // agents attend it. - const release = h.owner.session.channels.refer?.(OPEN); - await vi.waitFor(() => expect(lookup()).not.toBe(-1)); - h.wire.pending - .splice(lookup(), 1)[0] - ?.respond(channel(OPEN, "public") as RelayEvent[]); - await vi.waitFor(() => - expect(h.owner.session.channels.get?.(OPEN)).toBeDefined(), - ); - release?.(); - const stop = h.owner.session.attend([OPEN]); - await vi.waitFor(() => - expect(h.update.mock.lastCall?.[0] ?? []).toContain(OPEN), - ); - expect(h.owner.session.unattended(OPEN, false)).toBe(true); - h.state({ - status: "connected", - routes: [ - { - id: "attended", - channelId: OPEN, - status: "error", - replay: "unknown", - error: "restricted: channel access revoked", - }, - ], - }); - // The recheck, and attendance's own read of the suspended channel, find it - // public again; neither is news to the viewer. - await vi.waitFor(() => expect(lookup()).not.toBe(-1)); - await flush(); - while (lookup() !== -1) +it.each([false, true])( + "keeps a viewer preview heard after access recovery with admission lookup %s", + async (admit) => { + const h = harness(); + await h.discover(); + const lookup = () => + h.wire.pending.findIndex( + (entry) => !entry.signal?.aborted && asks(entry.filters, OPEN), + ); + // The viewer's own lookup, for a link since unmounted, finds it before + // agents attend it. + const release = h.owner.session.channels.refer?.(OPEN); + await vi.waitFor(() => expect(lookup()).not.toBe(-1)); h.wire.pending .splice(lookup(), 1)[0] ?.respond(channel(OPEN, "public") as RelayEvent[]); - await flush(); - expect(h.owner.session.channels.get?.(OPEN)).toBeDefined(); - expect(h.owner.session.unattended(OPEN, false)).toBe(true); - stop(); -}); + await vi.waitFor(() => + expect(h.owner.session.channels.get?.(OPEN)).toBeDefined(), + ); + release?.(); + const stop = h.owner.session.attend([OPEN]); + await vi.waitFor(() => + expect(h.update.mock.lastCall?.[0] ?? []).toContain(OPEN), + ); + expect(h.owner.session.unattended(OPEN, false)).toBe(true); + h.state({ + status: "connected", + routes: [ + { + id: "attended", + channelId: OPEN, + status: "error", + replay: "unknown", + error: "restricted: channel access revoked", + }, + ], + }); + // Admission may resolve the temporarily hidden preview before recovery. + expect(h.owner.session.channels.get?.(OPEN)).toBeUndefined(); + const admission = admit + ? h.owner.session.channels.resolve?.([OPEN], { viewerLookup: false }) + : undefined; + // The recheck, attendance and admission preserve the viewer's provenance. + await vi.waitFor(() => expect(lookup()).not.toBe(-1)); + await flush(); + while (lookup() !== -1) + h.wire.pending + .splice(lookup(), 1)[0] + ?.respond(channel(OPEN, "public") as RelayEvent[]); + await flush(); + await admission; + expect(h.owner.session.channels.get?.(OPEN)).toBeDefined(); + expect(h.owner.session.unattended(OPEN, false)).toBe(true); + stop(); + }, +); it("skips the queued lookups of attended channels no watch wants any more, so a new watch's lookup does not wait behind them", async () => { const h = harness(); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index e8e22efee0..6d1b4688f8 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1187,8 +1187,10 @@ export function createRelaySession( "unavailable", "Channels are not discovered yet.", ); + const suspended = new Set(channels.suspendedPreviews()); for (const id of ids) - if (!channels.queries.get?.(id)) learnedForAgents.add(id); + if (!channels.queries.get?.(id) && !suspended.has(id)) + learnedForAgents.add(id); } await channels.queries.resolve?.(ids, { ...options, viewerLookup }); }, From 704b0793184f84e8c14e9523669e2ecb9cbc1f6a Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 6 Nov 2026 17:20:49 -0500 Subject: [PATCH 32/32] docs: distinguish watch admission from viewer channel lookups Co-authored-by: Darcy <756b3e94a13d7979c09d32291e4fd4da46848bdaa53340021ac24fb843e28d57@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- docs/relay-queries.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 5f9ce97ee8..235fd5a398 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -347,12 +347,12 @@ which it is true when it arrived, kept while the event waits, so watches do not change who a mention wakes; they still hear the rest. A channel leaves the agents-only set once a lookup of the viewer's own succeeds, not when it starts: an exact lookup marked `viewerLookup` (a name search, a label lookup, a mounted -link, a search read, or any `session.channels.resolve` call, which only the -viewer's own surfaces make) reports through `resolved`, and so does a mounted link +link, a search read, or a `session.channels.resolve` call with its default viewer lookup) reports through `resolved`, and so does a mounted link to a channel already known, which needs no lookup: links hold their demand even when readable. Rechecks, such as preview revalidation after an access-revoked CLOSED or a member-added confirmation, -leave it as it was. +leave it as it was. Watch admission calls `session.channels.resolve` with +`viewerLookup: false`, preserving agent-only provenance instead of promoting the channel into viewer mention traffic. Shared composers and reactions remain unavailable for nonmembers; message helpers and workflows do not acquire write eligibility from public metadata. The relay remains the final write authority, including low-level outbox intent.