Files
buzz/docs/welcome-kickoff-silent-failures.md
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

503 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Welcome Kickoff — Failure Paths
Context: the Welcome-channel kickoff choreography
(`desktop/src/features/onboarding/welcomeKickoff.ts`) where Fizz posts an
opener, teammates introduce themselves in-thread, and Fizz posts a closer.
The file name says "silent-failures" for link stability (referenced from
[PR #2066](https://github.com/block/buzz/pull/2066) and
`useWelcomeKickoffStage.ts`); the scope is all kickoff failure paths.
## The one bug behind all of it
The kickoff fails in three directions, and they look unrelated until you notice
what they share:
| Class | Failure | Status |
|---|---|---|
| **Wrong story** | The team is announced as late/broken while it is working fine | **Open** — [§1](#1-wrong-story-the-closer-speaks-on-a-timer) |
| **Too loud** | Agents reply to each other indefinitely | **Fixed 2026-07-18** — [§2](#2-too-loud-runaway-reply-loop-fixed) |
| **Too quiet** | Nobody speaks; the user stares at an empty channel | **Open** — [§3](#3-too-quiet-silent-paths) |
**The shared root cause: the kickoff decides what to say from a timer and the
absence of evidence, then writes that guess in permanent ink.**
It has exactly one fact-based health check — `failedAfterKickoff`
(`welcomeKickoff.ts:282`), which reads real agent state (`status === "stopped"`
+ `lastError` + `lastStoppedAt` after the opener). That is a genuine fact: the
process died. Everything else that drives a user-visible decision is a stopwatch:
| Timer | Value | Decides |
|---|---|---|
| `TEAMMATE_READY_WAIT_MS` | 60s | whether to post the degraded opener |
| `TEAMMATE_INTRO_WAIT_MS` | **15s** (now `TEAMMATE_INTRO_BACKSTOP_MS`, 120s — [§1](#1-wrong-story-the-closer-speaks-on-a-timer)) | whether to announce teammates as slow |
| `WELCOME_KICKOFF_STAGE_TIMEOUT_MS` | 90s | whether to retire the kickoff stage |
**The facts decorate; the timers decide.** `failedAfterKickoff` only chooses
*wording* inside a message the 15s stopwatch already decided to send. Invert
that and most of this doc collapses: **facts decide, timers are a last-resort
backstop.**
The distinction the code is missing is between two things it treats as one:
- **"The agent crashed"** — a fact. We have it. Worth announcing.
- **"No intro yet"** — *not* a fact. That is ignorance. It is not news.
Announcing ignorance on a deadline is what produces the wrong story. Being
unable to announce anything is what produces the silent paths. And the loop was
the same disease one layer up: agents were *required to speak every turn*
regardless of whether they had anything true to add, so they said "got it"
forever.
**The principle, at both layers: don't mandate speech — mandate honesty.** The
prompt fix in §2 and the closer fix in §1 are the same change in two places.
## Plan
| # | Work | Where | PR |
|---|---|---|---|
| 1 | Stop "no intro yet" from writing the permanent closer | `welcomeKickoff.ts` | **✅ landed, this branch** |
| 2 | Loop hardening | `base_prompt.md` | **✅ landed, this branch** |
| 3 | Thread replies don't render live | `hooks.ts` / thread cache | **separate PR** — app-wide, not kickoff ([§4](#4-thread-replies-dont-render-live-separate-pr)) |
| 4 | Silent paths — surface a cause in the UI | `useWelcomeKickoff` + stage | later ([§3](#3-too-quiet-silent-paths)) |
| 5 | Loop circuit breaker | `buzz-acp` | backlog ([§2](#2-too-loud-runaway-reply-loop-fixed)) |
| 6 | `!cancel` unreachable from any surface | `buzz-acp` + CLI | backlog ([§5](#5-backlog)) |
---
## 1. Wrong story: the closer speaks on a timer
**Status: fixed on this branch. Observed 2026-07-18, 14:26.** Opener at 2:26. At 2:26+15s Fizz
posted *"Honey and Bumble are taking longer than expected. I'm still here to
help."* Honey and Bumble posted good intros at 2:27. The false story was never
corrected, because it was already stamped final.
### Mechanism
1. Opener posts. A timer is set for `15s (now opener.created_at)`
(`welcomeKickoff.ts:716`).
2. It fires. `classifyWelcomeKickoffResolution` (`:292`) splits teammates into
`failed` (fact-based, via `failedAfterKickoff`) and `unresolved` (**merely
no intro seen yet**).
3. `unresolved.length > 0``buildWelcomeKickoffCloser([], ["Honey","Bumble"])`
→ the "taking longer" text + the CTA (`:253`).
4. It posts **with `closerMarker`** (`sendWelcomeKickoffCloser`, `:443`). That
marker is **terminal**: every later pass early-returns on it (`:703`) and the
`kickoffResolved` latch (`:513`) makes it permanent by design.
5. Intros arrive. Nothing re-runs. **There is no path that ever posts a real
closer.**
### Why 15s is the wrong number *and* the wrong question
- Its neighbour allows **60s for a process to boot** (`TEAMMATE_READY_WAIT_MS`)
but **15s for two cold agents to receive a dispatched event, run a full LLM
turn, and publish** — 4× less budget for a far harder job.
- The clock starts at `opener.created_at`, so harness dispatch latency spends it
before the agents hold the event.
- `Math.max(0, …)` (`:716`) means on a revisit the wait clamps to zero and the
message fires **instantly**.
- Observed reality: intros took **~60s**. A 30s timer would also have misfired.
But the deeper problem is structural: **the closer welds a terminal fact to a
provisional guess.**
| Part of the closer | Nature | Wants |
|---|---|---|
| The CTA — *"What can we help you build?"* | terminal, exactly once | ✅ a one-shot marker |
| Teammate status — *"X is taking longer"* | **provisional, corrigible** | ❌ currently welded to that marker |
### The fix
Let facts decide; keep the stopwatch as a backstop only. The closer should fire
when one of these is true:
- **intros land** → clean closer + CTA (the ~95% path — needs no timer at all)
- **`failed` is non-empty** → the "couldn't start / check Agents" closer,
immediately (fact-based, so it can be fast and still honest)
- **a long backstop elapses with teammates alive but silent** → the "taking
longer" text, which by then is *true*
**The code already has this structure. Only the backstop's value was wrong.**
`classifyWelcomeKickoffResolution` (`:292`) already excludes `failed` from
`unresolved`, so once every teammate is intro'd-or-failed, `unresolved` is empty
and the closer fires on the 3s beat with the correct fact-based wording — the
timer is cleared and never speaks. The timer callback also re-classifies against
the latest events before posting, so it self-corrects if intros land between
timer-set and timer-fire.
So the whole fix is the constant: `TEAMMATE_INTRO_WAIT_MS = 15_000`
`TEAMMATE_INTRO_BACKSTOP_MS = 120_000`. Renamed because the old name described it
as an expectation of how fast an intro arrives, which is what invited tuning it
like one. It is a give-up backstop. Because it doesn't gate the happy path,
raising it costs the normal case nothing — it only delays the moment we give up
on a teammate that is alive but silent. A real failure never waits for it;
`failedAfterKickoff` resolves crashed teammates immediately.
### Decided: keep the CTA bundled in the closer
The CTA only exists *inside* the closer, so waiting for intros delays the "you
can talk to us now" handoff from ~15s to ~60s. **Accepted** (Morgan, 2026-07-18):
the room is not dead while we wait — the opener is up and the stage shows
*"Fizz: Working"*. The alternative (post the CTA early on its own, and post
status only when there is status worth reporting) is honest but adds a second
message, with its own marker and idempotency, to solve a problem the stage
already solves.
**Note:** this failure is a *shrunken* §3. The channel is not dead — a CTA
arrives — but the story is false. Any fix here must not reopen §3: if we wait
longer and the wait ends in nothing, we are back to unexplained silence.
---
## 2. Too loud: runaway reply loop (fixed)
**Status: prompt hardening landed 2026-07-18. Verified once manually** (14:26
run: 3 replies, intros, stop). One good observation, not proof. Re-verify on
Codex specifically.
Observed on the Codex runtime (`codex-acp`), never reproduced on Claude Code.
21+ replies deep, each an acknowledgement of the previous acknowledgement:
> **Bumble:** `@Fizz` parked; no further replies from me until there's work.
> **Honey:** `@Fizz` understood. I won't reply again unless there's a task for me.
> **Fizz:** `@Honey` `@Bumble` acknowledged — stay parked until `@morgan` brings a real task.
**The content was the tell: every agent was trying to end the conversation, and
announcing it is what kept it alive.** The agents were not malfunctioning — they
were complying exactly. The loop was *correct* behavior given the prompt.
### Root cause
Two rules in `crates/buzz-acp/src/base_prompt.md` composed into a perpetual
motion machine:
1. *"**Every turn that processes a user message MUST publish a reply.** […] A
turn that ends without a published message is a silent failure."*
2. *"When you finish delegated work, you MUST `@mention` the delegator […]. This
is the #1 cause of stalled collaboration."*
Rule 1: *always speak*. Rule 2: *when you speak, tag whoever tagged you*. On a
mutual mention the circuit closes and never opens. Rule 1 said "user message"
but was phrased as an absolute with no exception for an agent-authored trigger.
Rule 2 was written to fix the *opposite* failure — the two hardenings worked
against each other and nothing reconciled them.
The Welcome kickoff was the worst case: the opener says *"Don't start any work
yet"* (`:162`), so teammates were told they **must** reply and that there is
**nothing to report** — stripping away every substantive thing a reply could
contain. The only output satisfying rule 1 was a content-free acknowledgement.
The kickoff didn't just permit the loop; its instructions selected for it.
### What shipped
Scoped both rules by **what the turn has to say**, not who triggered it:
- Rule 1 → publish if the turn produced something worth knowing (a result,
answer, deliverable, decision, blocker, or needed question; asked-for work
always counts).
- A human who asked you something must always get a reply — even "nothing to
add". This preserves the anti-silent-failure floor rule 1 existed for.
- Otherwise publishing is optional and **silence is explicitly a success**.
- **No bare acknowledgements**, with the observed offenders named ("Got it",
"Confirmed", "Standing by", "Parked", "I won't reply again") and the kicker:
*if you are tempted to announce you are done replying, that is the message not
to send.*
- Rule 2 scoped to **completed work only** — not assignment acks, not
conversational loop-closing.
- Mentions rule hardened: naming someone while talking *about* them ("waiting on
@morgan") is narrative — drop the `@`. The loop spammed Morgan with 4 such
false notifications.
### Why it had to be a local test, not "don't loop"
**"Don't get into a loop" is not a rule an agent can follow.** A loop is a
global property of a conversation; each agent sees only its own turn, and every
individual reply looks locally reasonable — which is why the sign-offs read as
polite rather than broken. The rule had to become a **local, per-turn test**:
*does this add information the thread doesn't have?* An acknowledgement is
definitionally not new information, which makes "no bare acknowledgements" the
checkable form of the intent.
A soft caveat would also have failed: *"you may end the turn"* sitting next to
*"**MUST** publish a reply"* leaves a literal model correctly following the
stronger instruction. The mandate had to be **narrowed**, not exception-ed.
### Still open: the circuit breaker
Prompt-only means prose-compliance-only, and Codex is proof models don't
reliably comply. There is still **no reply-depth counter, hop limit, cooldown,
or agent-to-agent budget anywhere in the path.** Existing guards that don't help:
| Guard | Why not |
|---|---|
| `ignore_self` (`lib.rs:1864`) | Blocks self-replies only. The *only* loop guard, and A→B→A is exactly what it misses. |
| Author gate (`respond_to`) | **Admits siblings by design**`is_owner_or_sibling` (`lib.rs:166`) verifies same-owner agents via NIP-OA. It's an *admission* mechanism; a loop needs *termination*. No setting stops this. |
| `max_turns_per_session` (`config.rs:372`) | Defaults 0 = disabled; it's session rotation for context hygiene, not a reply brake. |
| Queue caps (`queue.rs:24`) | Backpressure on *pending* events. A ping-pong is never backed up. |
| `closerMarker` | Idempotency for the client-authored closer only; never observes agent replies. |
Candidate: **consecutive agent-to-agent reply budget** — count unbroken
agent-authored turns in a thread; past N, drop the trigger. A human message
resets it. Set N high (~610) so it never fires on healthy work — a circuit
breaker, not a policy. `resolve_reply_anchor` deliberately allows deep
agent-only nesting, so a low cap would truncate legitimate coordination.
**A too-aggressive breaker manufactures §3.** A depth counter cannot tell a loop
from a productive chain; dropping a good reply produces exactly the unexplained
silence this doc is otherwise about. Hence: prompt primary, breaker high.
Add a `tracing` line when it fires — cheap and currently we'd be blind. A
user-facing surface is likely out of scope: when the breaker works, the desired
outcome is just that agents stop talking. The case for surfacing it is the
false-positive, not the success.
---
## 3. Too quiet: silent paths
**Status: open.** The *perception* gap is handled; the paths are not.
Every fallback message assumes Fizz — the lead and sender — is alive and able to
post. **When Fizz is the thing that failed, nobody speaks.**
The client-side kickoff stage (characters on the Welcome composer banner) covers
perception and has landed: after 90s with no message, they exit and the banner
drops to its normal mention hint. A failed kickoff degrades to an ordinary,
usable empty channel rather than claiming a team is still being set up.
What it does **not** do is explain anything:
- The stage reads only "is the timeline empty" + that timer
(`useWelcomeKickoffStage.ts`). It never reads real kickoff state, so it cannot
tell "Fizz crashed" from "the relay is slow" — **another stopwatch standing in
for a fact.**
- The empty channel it degrades to invites the user to `@`-mention Fizz — who,
in exactly these cases, is what isn't working. Honest, but a dead end.
### What the user CANNOT be told today
1. **Fizz fails to start.** `startManagedAgent` rejects (harness binary missing,
spawn error). The effect logs `Failed to start Welcome agent…` and returns —
by design only Fizz sends the opener, so nobody speaks.
2. **Any step throws.** The whole kickoff is one `try/catch` that logs
`Failed to start the Welcome team kickoff.` and gives up. Seen in practice:
relay unreachable / websocket down; `ensureWelcomeTeam` failure; the send
itself rejected (relay rate-limiting — see [Related](#related)).
3. **Closer-path failures.** A failing closer send is caught-and-logged only;
the thread ends without the CTA. Lower stakes (opener + intros already
happened) but still dangling.
Navigating away mid-kickoff also cancels silently — intentional (it resumes on
next visit), not a failure.
### Messages the user CAN receive today
All hard-coded client-side; only teammate intro replies are LLM-generated.
| # | Message | Trigger | Sender |
|---|---|---|---|
| 1 | Provider fallback ("connect to an AI provider in Settings…") | Readiness check fails before kickoff | Fizz (`provider-required.v1`) |
| 2 | Happy-path opener | Team online | Fizz (`opener.v1`) |
| 3 | Degraded opener ("I'm here with Honey and Bumble…") | Fizz online, zero teammates online within 60s | Fizz (opener + closer markers) |
| 4 | Closer variants (clean / failed / slow) | 3s beat after intros resolve, **or the 120s intro backstop** — see [§1](#1-wrong-story-the-closer-speaks-on-a-timer) | Fizz (`closer.v1`) |
| 5 | Setup-mode nudge ("here's what you still need to configure") | Agent spawns but requirements check fails (e.g. missing API key) | The agent process itself (buzz-acp setup-listener mode) |
### Constraints for the fix
- **Fizz cannot be the messenger** — she is what failed. Any fallback must come
from the client UI (banner, intro-block state, stage `timed-out` phase), not a
channel message impersonating an agent.
- A relay-side/system-authored message is possible (kind-scoped system event)
but heavier. The client already knows locally that the kickoff threw, so local
UI state is the cheap, honest option.
- Must be **idempotent across revisits** — same rule as the opener markers.
Don't re-alarm the user every time they click Welcome.
- Distinguish *retryable* (relay hiccup, rate-limit) from *actionable* (harness
missing → point at Agents/Settings). `Requirement` in
`desktop/src-tauri/src/managed_agents/readiness.rs` already classifies the
actionable ones.
### Sketch (to validate later)
1. Surface a `kickoffError` phase from `useWelcomeKickoff` when the catch block
fires or the lead's start rejects, with a coarse cause
(`lead-start-failed` | `relay` | `unknown`).
2. The stage's `timed-out` phase renders that cause: quiet copy + a pointer to
Agents (start failures) or a retry affordance (relay failures). Retry =
re-run the effect (the coordinator already dedupes). The phase currently
exits immediately on timeout, so giving it copy means holding it on screen —
and it is `aria-hidden` decoration today, so anything it says must reach
screen readers.
3. Consider a bounded auto-retry (once, short delay) for the relay class before
showing anything.
4. Closer-path failure: retry the send once; otherwise leave the thread as-is
(intros already delivered the core experience).
---
## 4. Thread replies don't render live (separate PR)
**Status: open, root cause unknown. Not a kickoff bug** — surfaced here, tracked
here only until it gets its own home. **App-wide; likely predates this work and
outranks everything else in this doc by blast radius.**
### Symptom (observed repeatedly, 2026-07-18 among others)
With a thread open, new replies from someone else **do not appear**. The
channel's reply count **does** increment. Closing and reopening the thread shows
every missing reply.
### Why it hides
One fact — "there are new replies" — travels three independent roads:
| What the user sees | Source |
|---|---|
| **Reply count** | Relay pushes a **kind 39005 thread-summary recount** → merged into the window-store overlay (`hooks.ts:266-277`). **Does not come from the replies themselves.** |
| **Thread pane rows** | A separate React Query cache `["thread-replies", channelId, rootId]` (`useThreadReplies.ts`), filled on open; live replies must be *filed into it* by `appendMessage` (`hooks.ts:282-291`) |
| **Channel timeline** | Window store — thread replies deliberately early-return before reaching it (`hooks.ts:292`) |
So the badge is **correct** and the pane is wrong — the count is the relay's own
tally, arriving whether or not the client ever received the reply. **The badge
moving is not evidence the message arrived**, which is why this is
undiagnosable from the UI and has gone unexplained across multiple sightings.
Close/reopen refetches from scratch (`staleTime: 0`) → everything appears.
Likely invisible in human-to-human threads because your own sends render
optimistically without waiting for the relay. The broken road is **replies
arriving from someone else while the thread is open** — in practice, mostly
agents.
### Cleared so far
- **Not `getThreadReference` normalization.** It returns `rootId: rootTag?.[1] ?? parentId`
(`threading.ts:50`), so a direct reply to the opener *does* get a `rootId`
the thread-cache write condition should pass.
- **Not the live filter.** `buildChannelFilter` is `#h`-scoped across the broad
`CHANNEL_EVENT_KINDS` set — thread replies carry the same `h` tag.
- **Not the initial-fetch race.** `useThreadReplies`' `queryFn` snapshots ids at
start and re-merges anything received in-flight (`:108-113`).
### Suspect worth checking (unproven)
`welcomeKickoff.ts:504` calls `useThreadReplies` on the **same cache key** the
open thread pane uses (`ChannelScreen.tsx:186`) — in the Welcome kickoff the
open thread *is* the opener thread, so two features with different lifecycles
share one cache entry at `staleTime: 0`. When `kickoffResolved` latched, the
kickoff's observer passed `null`, flipping its key to
`["thread-replies","none",openerId]` and detaching — **~30s before the intros
failed to render.** The comment at `:492-499` documents this coupling as having
bitten once already.
**But Morgan reports this outside the Welcome flow, which argues against the
coupling being the cause.** Correlation only.
### Next step (do this before theorizing further)
Temporary log in `appendMessage` printing `event.kind`, `event.id`, and
`getThreadReference(event.tags)` for the channel; re-run with the thread open.
That splits the problem in half in one run:
- **Reply never arrives** → a delivery problem (subscription/relay fan-out).
- **Arrives but isn't filed** → a bookkeeping problem (the write condition).
---
## 5. Backlog
**`!cancel` / `!shutdown` / `!rotate` are unreachable from every product
surface.** `is_owner_control_command` (`lib.rs:2476`) requires *all* of: kind:9,
`content.trim() == "!cancel"` (**exact**), and a `p` tag naming the agent. But
every surface derives the `p` tag *from `@Name` text in the content* (Desktop:
`hasMention.ts:143`; CLI: `resolve_content_mentions`, `messages.rs:128`
`SendMessageParams` has no mention flag). So `@Fizz !cancel` fails the exact
match, and bare `!cancel` produces no `p` tag. **Mutually exclusive on every
real surface.** Only a hand-crafted signed event via `POST /events` fires them.
The unit test passes only because it attaches the `p` tag independently of
content — a shape no product path can produce.
Options: relax the matcher to accept a leading `@Name` before the command, or
add a mention flag to `buzz messages send`. First confirm these were ever
intended for anything but hand-crafted/test use.
Even fixed, `!cancel` cancels **one turn, one agent, one channel**, and the
agent resumes on the next mention — not a loop breaker. Note stop/cancel
controls were explicitly descoped from the loop work (2026-07-18); this is
tracked as its own bug.
**What works today for a runaway team:** steering (just send a message —
`multiple_event_handling` defaults to `steer`, `config.rs:357`) redirects an
agent that is *working*, but a loop is many short *completed* turns, so steering
can't break it. The only real tool is **Stop in the Agents UI**
(`useManagedAgentActions.ts:245`), which also kills legitimate in-flight work
and requires the user to recognize the loop and know where the kill switch is.
## Reference: rejected approaches (do not retry)
**Scoping the reply mandate by sender identity (human- vs agent-triggered
turns).** Rejected 2026-07-18. The obvious fix is to key rule 1 on
`turn_is_human_facing` (`queue.rs:1150`). It does not work, and the reason is
invisible until you trace real transcript `p` tags:
`parse_thread_tags` (`queue.rs:835-837`) collects **every `p` tag with no notion
of who is being addressed**, and `turn_is_human_facing` returns `true` if *any*
mentioned pubkey is human (`:1167`). The loop's signature content is agents
narrating *"stay parked until `@morgan` brings a real task"* — which `p`-tags
the human:
| Turn | Trigger | `p` tags | Classified |
|---|---|---|---|
| Honey/Bumble | Fizz: *"…until `@morgan` brings a real task"* | Honey, Bumble, **morgan** | **human → MUST reply** |
| Fizz | Honey: *"@Fizz understood"* | Fizz | agent → optional |
It exempts only the leg that happens not to name the human — cutting 1 of 3 legs
**by luck**. Had Honey written *"@Fizz understood, waiting on @morgan"*
entirely in character — the loop survives the fix intact. **The loop's own
content re-arms the rule meant to stop it.** A guard the symptom disables is not
a guard. Worse, those narrative `@morgan` mentions already violated the Mentions
rule, so the guard would have taken an existing prose non-compliance as input.
Root insight: `turn_is_human_facing` answers *"is a human named?"*, not *"is a
human asking?"* — and those diverge exactly where it matters. It is a fine
reply-anchor heuristic and a wrong safety signal. This also killed the planned
`[Context]` `Triggered by: human|agent` plumbing: correct signal delivery, wrong
signal.
**Fixing the loop in the personas** (`personas.rs`). Rejected: they are
*character* prompts (tone, wordplay), so a conversation-protocol rule is a
layering violation; it would need duplicating across all three and every future
persona; and stored copies are user-editable with modification tracking
(`migrate_retired_personas`, `was_unmodified`) — a user rewording Fizz must not
be able to delete a loop guard.
**Fixing the loop in `welcomeKickoff.ts` copy.** Rejected: treats the trigger,
not the cause. The same rule collision fires for any two agents that mention
each other with nothing to report.
**Available but not chosen: team instructions.** `TeamRecord.instructions` is
plumbed end-to-end (`teams.rs:61``PromptContext.team_instructions`
`[Team Instructions]`, `pool.rs:1114-1127`) and is `None` for the Welcome Team.
It is the natural home for kickoff-specific etiquette and the right place if the
base-prompt fix proves too weak for the intro case specifically — but it only
covers this one team, so it complements rather than replaces §2.
## Related
- **Rate-limiting incident.** One Welcome agent produced a 42KB log of
"rate-limited: quota exceeded" retries within seconds (2026-07-17, remote
relay `onboarding.communities.buzz.xyz`). A tight retry loop against a quota
makes every other send in the session fail too — including the kickoff's, one
of the §3 silent paths. Worth a separate look at buzz-acp publish backoff.
Originally suspected to be the §2 loop burning quota; with §2 fixed, if this
recurs it is an independent retry bug.
- **Why Codex and not Claude Code.** Ruled out: prompt content (identical across
runtimes — `[Workspace]`+`[Base]`+`[System]`+`[Team Instructions]`+
`[Agent Memory]`+`[Channel Canvas]`, `pool.rs:742-797`; only *delivery*
differs and no path omitted rule 1), per-runtime config (args, env, permission
handling — none adds or removes a loop guard), and persona content. Remaining
hypothesis: **literal compliance** — Codex read "MUST publish a reply" as
absolute; Claude Code applied judgment and quietly *violated* rule 1, and that
violation was the only thing preventing the loop. If so the loop was latent on
every runtime and Claude Code's good behavior was luck. This is why §2 keeps a
structural breaker on the backlog rather than trusting prose.