Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7ab14ae
fix(agents): let watches hear public channels the agent is not in
loganj Oct 28, 2026
f041667
fix(agents): hold a public channel's catch-up until its metadata is read
loganj Oct 28, 2026
17bb299
fix(agents): hold a channel's catch-up before it is attended, too
loganj Oct 28, 2026
b2dabb7
fix(agents): resolve the channels a watch of every channel finds
loganj Oct 28, 2026
7fd1bb4
fix(agents): read again an attended channel found unreadable
loganj Nov 3, 2026
63e1fd4
docs(agents): say an event watch hears public channels it has not joined
loganj Nov 3, 2026
d58f1d5
fix(agents): read again an attended channel that turns private, and b…
loganj Nov 3, 2026
e0d988e
test(agents): pin that a hidden or DM public channel is not heard; fi…
loganj Nov 3, 2026
ceab1be
fix(agents): hear a public channel the agent is not in only once its …
loganj Nov 4, 2026
dfb8dd6
fix(agents): let the live owner decide which attended channels fit
loganj Nov 4, 2026
d0ec42f
fix(agents): keep watched channels live across changes, and only for …
loganj Nov 4, 2026
12871e9
fix(agents): hold a non-member's event while its channel's metadata i…
loganj Nov 4, 2026
762290a
fix(agents): read attended channels' metadata in the background lane
loganj Nov 4, 2026
d3fc416
fix(agents): keep mention turns as without watches' attendance
loganj Nov 4, 2026
5c984e0
fix(agents): keep a mention's provenance from arrival, and the owner'…
loganj Nov 4, 2026
12e7a8f
fix(relay): retire agents-only provenance on any viewer lookup
loganj Nov 4, 2026
3825446
fix(relay): keep agents-only provenance through rechecks
loganj Nov 4, 2026
b9712ee
fix(relay): count a link to an agent-known channel as the viewer's
loganj Nov 4, 2026
63ce7c3
fix(agents): keep scoped searches and re-enabled watch replays as on …
loganj Nov 4, 2026
80cecbb
fix(relay): look up attended channels one batch at a time
loganj Nov 5, 2026
9fa6cf2
fix(relay): read an attended channel once while its read is in flight
loganj Nov 5, 2026
c58cfd4
fix(agents): apply a replay's start floor when it is admitted
loganj Nov 5, 2026
339eda6
test(relay): cover viewer precedence and link-lookup provenance
loganj Nov 5, 2026
e54cc69
Recheck joined metadata after a drop; keep attendance provenance and …
loganj Nov 6, 2026
667e381
fix(agents): refuse watches on unreachable channels
johnmatthewtennant Nov 3, 2026
9e6ac9c
test(agents): describe watch admission policy invariant
johnmatthewtennant Nov 3, 2026
dce81d7
fix(agents): preserve GitHub watches on refused moves
johnmatthewtennant Nov 3, 2026
88a6108
fix(github): respect Attention cancellation during watch moves
johnmatthewtennant Nov 3, 2026
0057ed8
Resolve listening metadata before watch admission
johnmatthewtennant Nov 4, 2026
6778018
fix(agents): preserve watch metadata provenance during admission
johnmatthewtennant Nov 6, 2026
974e2aa
fix(relay): retain viewer provenance through suspended admission
johnmatthewtennant Nov 6, 2026
5232afd
Merge main into watch admission
johnmatthewtennant Nov 6, 2026
704b079
docs: distinguish watch admission from viewer channel lookups
johnmatthewtennant Nov 6, 2026
29363a0
Merge main into watch admission
johnmatthewtennant Nov 6, 2026
3dcff41
Merge main into watch admission
johnmatthewtennant Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -1721,7 +1721,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.

Expand Down
2 changes: 1 addition & 1 deletion docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -1203,7 +1203,7 @@ confirmation. **Delete team** asks first; it doesn't delete the team's agents.
mention can still wake an agent when Attention is off; turning the plugin 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.
Expand Down
6 changes: 3 additions & 3 deletions docs/relay-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,12 +344,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.
Expand Down
27 changes: 27 additions & 0 deletions src/bundled/github/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 31 additions & 27 deletions src/bundled/github/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,33 +512,37 @@ 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.
if (!attention.enabled())
throw new Error("Turn on Attention to watch pull requests");
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
Expand Down
2 changes: 1 addition & 1 deletion src/buzz-mcp/attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,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 <watch-classifier> block.

Expand Down
43 changes: 43 additions & 0 deletions src/features/agents/attention-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions src/features/agents/attention-objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<AgentRecord> {
const parsed = parseSlug(slug);
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions src/features/agents/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
} from "./attention";
import {
checkedWrite,
AttentionError,
interestIds,
show,
watchSummaries,
Expand Down Expand Up @@ -431,6 +432,12 @@ export type RunnerHost = {
* channel is public; undefined as for `member`. Only a member may be
* delivered to. */
reads(agent: Agent, event: Pick<RelayEvent, "tags">): boolean | undefined;
/** Resolves listening metadata before an enabled watch is admitted. */
resolveChannels?(
agent: Agent,
channels: readonly string[],
signal: AbortSignal,
): Promise<void>;
/** 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
Expand Down Expand Up @@ -3297,6 +3304,36 @@ 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") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P3] Resolve channel metadata before entering the record change queue

Adding, updating or enabling an event watch for an unknown explicit channel awaits network resolution inside host.change. AgentsService.change serializes other changes behind this operation (service.ts:5170–5195), and dueTimer returns while changing is set (runner.ts:2371). A slow metadata read therefore stalls owner saves, other Attention writes and timer progress for this agent until it settles. The read deadline is approximately 10 seconds; the delay is source-derived, not measured. Resolve metadata before host.change, keeping the synchronous reads()/member() admission checks inside the queued mutation so authorization still uses current state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Deferred as a separate scheduling follow-up. Source inspection confirms the network await holds the per-agent queue; the practical delay has not been measured. The follow-up will move resolution before the mutation, retain latest-state access and token checks inside it, and cover delayed metadata reads racing owner saves and timer ticks. This is the same non-blocking latency concern already recorded in the review disposition; the current batch preserves the validated admission and atomic-transfer behavior.

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}. Check channel access and wait for confirmed metadata before enabling this watch.`,
);
}
if (
value.channel &&
Comment thread
johnmatthewtennant marked this conversation as resolved.
Resolved
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());
Expand Down
Loading
Loading