block / block/buzz

RFC: an agent-writable lifecycle event kind — agent start/stop/restart/crash is invisible in the channel

Open
#5,396 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

**Motivation**

An agent can start, stop, be restarted, or die, and **nobody in the channel learns any of it**. Every lifecycle transition is local desktop state plus owner-private encrypted telemetry.

| Transition | Where it happens | Reaches other channel members? |
|---|---|---|
| spawn / start | `commands/agents.rs` `start_managed_agent`, `runtime_commands.rs` `start_pair` | no — local, plus a Tauri event |
| `Listening` / `Waking` / `Ready` / `Failed` | `buzz-acp` runtime lifecycle frames | no — kind:24200, NIP-44 encrypted, p-tagged to the owner only |
| stop | `commands/agents.rs` `stop_managed_agent` | no |
| restart / respawn | `runtime_commands.rs` `restart_managed_agent_runtime` | no |
| crash / process exit | `managed_agents/runtime/lifecycle.rs` `sync_managed_agent_processes` records `last_exit_code` | no — pure local bookkeeping |
| stale-PID reap, orphan sweep | `runtime/lifecycle.rs`, `runtime/orphan_sweep.rs` | no |

What *does* reach the channel today is thin: kind:40099 `member_joined` / `member_removed` when an agent is attached or detached, transient kind:20002 typing, and kind:20001 presence as a sidebar dot. Presence is the closest thing to a status, and its offline edge is lossy — on a hard crash the relay clears the Redis key on disconnect (`connection.rs:291`) and publishes nothing, so other clients only find out if they happen to query.

The user-visible consequence is already documented in #1743 (open since July, ~8 independent reproductions): you @mention an agent whose runtime is down, the message is accepted by the relay, and nothing ever happens. No warning, no catch-up, no signal that the target was not there. Several reporters describe waiting 30+ minutes before a human noticed.

The same gap is why #3711 exists: `!cancel` / `!rotate` / `!shutdown` are consumed by the harness and their outcome is only written to the harness log.

**Proposed solution**

A dedicated event kind for agent lifecycle, writable by the agent (or its registered owner), channel-scoped, rendered as a system row.

```
KIND_AGENT_LIFECYCLE = 44201 // free today; 44200 = NIP-AM turn metrics
tags: ["h", ], ["p", ], ["agent", ]
content: {"type": …, "agent": , "actor": }
```

`actor` / `agent` mirror the field names every existing system row uses, so both renderers already resolve them to profile names and prefetch the profiles.

`type` from a closed set:

- lifecycle — `started`, `stopped`, `restarted`, `crashed`
- owner control command outcomes — the six defined in #3711 (`turn_cancelled`, `turn_cancel_noop`, `session_rotated`, `session_rotated_in_flight`, `session_rotate_noop`, `shutdown`)

## Authorization — the part that matters

This follows `KIND_AGENT_TURN_METRIC` (44200) exactly, which is the closest existing precedent:

1. `required_scope_for_kind` (`ingest.rs:327`) → `Scope::MessagesWrite`. Scope is not the real boundary — every NIP-42 connection holds the full set (`buzz-auth/src/scope.rs:3-5`); the table is a default-deny kind allowlist and nothing more.
2. An envelope validator, mirroring `validate_agent_turn_metric_envelope` (`ingest.rs:1647`): exactly one `h`, one `p`, one `agent` tag, `agent == event.pubkey`, `type` in the allowlist.
3. The actual gate: `db.is_agent_owner(...)` (`buzz-db/src/user.rs:354`), the same call the 44200 block makes at `ingest.rs:2505`.

**Forgery is structurally impossible**, and that is the point. `is_agent_owner` is

```sql
SELECT agent_owner_pubkey = $3 FROM users
WHERE community_id = $1 AND pubkey = $2 AND agent_owner_pubkey IS NOT NULL
```

`agent_owner_pubkey` is only ever written at the auth seam, after the NIP-OA auth tag verifies cryptographically. An ordinary member's column is `NULL`, so the row never matches and the check returns `false`. A human cannot publish one of these events about anything, including themselves.

**Two accepted author classes**, same helper with the arguments swapped:

- the agent speaking about itself → `is_agent_owner(event.pubkey, actor)`
- the owner speaking about their agent → `is_agent_owner(agent_tag, event.pubkey)`

The second is not cosmetic: it is the only way `crashed` can ever be reported. A dead agent cannot announce its own death, and Desktop signs with the human's key, not the agent's.

## Why not kind:40099

The obvious alternative is to reuse the existing system-message kind. It does not work, and the one-line "fix" is a security regression — worth stating plainly so nobody lands it by accident.

40099 has no arm in `required_scope_for_kind`, so an agent-signed one falls through to `_ => Err("restricted: unknown event kind")` (`ingest.rs:435`). Today's system rows never meet that gate: `emit_system_message` writes straight to `insert_event`.

Adding 40099 to the scope table would make it writable by **every member**, because scope is not a restriction. And every 40099 today is an authoritative statement about channel state — `member_removed`, `channel_archived`, and the `message_deleted` tombstone that renders as *"Removed by community moderators"*. Any member could forge those. That is why a new kind with an author-class check is the right shape, rather than widening an existing one.

(I hit this the hard way in #3916 — thanks to @Chessing234 for catching it in review.)

## Surface

- `buzz-core/src/kind.rs` — const, `ALL_KINDS`, the compile-time assertions
- `crates/buzz-relay/src/handlers/ingest.rs` — scope arm, envelope validator, authz block
- `buzz-sdk` — a builder, so the NIP-10/tag construction is shared rather than duplicated
- `buzz-acp` — emitters; the seams already exist where the harness publishes presence `online` / `offline`
- `desktop/.../SystemMessageRow.tsx` (`describeSystemEvent`) and `mobile/.../timeline_message.dart` (`SystemEvent.fromContent`) — one case each

**Alternatives considered**

- *Reuse kind:40099* — rejected above on security grounds.
- *Relay-emitted rows instead of agent-emitted.* This sidesteps authorization entirely, since `emit_system_message` bypasses ingest, and @Bartok9 sketched exactly this in #1743 (an `agent_mention_undelivered` notice emitted when a mention targets an agent with no presence key). It never landed — no occurrence in the tree today. It is a good fit for facts the relay can observe, and a poor one for the rest: the relay cannot know whether a turn was in flight or whether a session was cached, which is precisely what the control-command outcomes report. **If maintainers prefer the relay-emitted route for the lifecycle half, I'd happily take that instead** — the two are complementary rather than exclusive.
- *Keep everything on kind:9 agent messages* (what #3916 does today) — works everywhere and degrades gracefully in third-party clients, but puts lifecycle events permanently in the conversation, and cannot express "this agent crashed" at all.
- *Presence only* — already the de-facto status, but it is a sidebar dot rather than a timeline fact, it carries no cause, and its offline edge is lost on a hard crash.

**Additional context**

Open questions I'd rather settle here than in a PR:

1. **`crashed` on a hard kill.** Nobody is alive to emit it at the moment it happens. Desktop can detect it later via `sync_managed_agent_processes` and report it as the owner, but the row would be delayed to the next Desktop wake. Acceptable, or worse than silence?
2. **Noise.** An agent in a restart loop would flood the channel. Group consecutive rows the way membership rows are grouped, only emit human-caused transitions, or rate-limit?
3. **Old clients show nothing** — both renderers drop an unknown `type` silently. That is the standing cost of the system-row shape.
4. **Kind number** — 44201 is free; happy to take whatever fits your numbering plan.
5. Should this be written up as a NIP (it is close in spirit to NIP-AO, which covers the owner-private side of the same story)?

Searched open issues and PRs — nothing proposes this kind. Closest neighbours: #1743 (offline agents, the user-facing symptom), #3711 (control command outcomes, the half this would legitimise), #3916 (my PR, now back to kind:9).

I'm happy to implement whichever shape you land on, but this adds an event kind and a relay authorization path, so I'd rather have a direction agreed here first than send code on spec.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.