Skip to content

buzz-acp: wake-ticket persistence for unconsumed mentions (today-slice) - #5940

Open
riddlef0x wants to merge 3 commits into
block:mainfrom
riddlef0x:worktree-wake-ticket-today-slice
Open

buzz-acp: wake-ticket persistence for unconsumed mentions (today-slice)#5940
riddlef0x wants to merge 3 commits into
block:mainfrom
riddlef0x:worktree-wake-ticket-today-slice

Conversation

@riddlef0x

Copy link
Copy Markdown

Summary

Today-slice from PLANS/WAKE_TICKET_SPEC.md, gated by Oksana's architecture review (RESEARCH/WAKE_TICKET_TODAY_SLICE_GATE_20260815.md, 2026-08-15).

buzz-acp's EventQueue is RAM-only: a mention that's accepted into the queue but not yet consumed does not survive process death. A bounce (kickstart, crash, sleep/wake) captures a fresh connect-time watermark, and any unconsumed mention is gone — this is the bug class behind the 01fa1107 incident referenced in the spec.

This PR adds a durable wake-ticket store, default off behind --wake-ticket-dir / BUZZ_ACP_WAKE_TICKET_DIR, for side-binary/canary use only. It does not touch the shipped /Applications/Buzz.app binary, does not point launchd at anything, and does not run a canary — those are explicitly out of scope for this slice per the gate.

  • crates/buzz-acp/src/wake_ticket.rs — new WakeTicketStore. One jsonl line per mention event_id, exclusive flock on the ticket dir for the process lifetime (dual-run is a crash, not a race — lock failure is fatal), atomic tmp+rename writes, chmod 600, last-write-wins compaction on open(). Lifecycle: openclaimeddone (compacted away) | drop (kept for audit, never replayed).
  • lib.rs:
    • open is written after the first queue.push that returns true (not inside EventQueue::push — requeues go through that path too).
    • claimed on successful dispatch (dispatch_pending, right after try_claim succeeds).
    • done only when PromptOutcome::Ok, mark_complete has run, the channel has no pending queued events, and no active retry throttle — mark_complete itself is a lock release, not a completion signal (see gate: hooking done there recreates the 01fa1107 bug).
    • drop on dead-letter (hard-cap timeout with no recent activity, or retry budget exhausted), non-retryable auth error, or channel removal (both the live membership-notification drain path and the in-flight-batch removed-channel path).
    • Boot replay runs before HarnessRelay::connect() / set_startup_watermark(): loads open+claimed tickets, re-validates channel membership and the author gate against a lightweight pre-connect REST client, pushes survivors into the queue, and seeds the relay's seen_ids (new RelayCommand::SeedSeenIds) right after connect so the 5s subscribe window can't double-deliver them.
  • queue.rsEventQueue::is_retry_throttled; queued_event_count is no longer test-only (needed for the done predicate).
  • relay.rsRelayCommand::SeedSeenIds plumbing for the above.

Ticket also carries the prompt_tag that matched at write time (not in the spec's original field table) — boot replay reconstructs a QueuedEvent directly from the ticket without re-running rule matching, since subscription rules aren't available that early in boot (no relay, no channel discovery yet) and may have changed since the ticket was written anyway.

Test plan

  • cargo test -p buzz-acp --lib — 787 passed, 1 pre-existing unrelated failure (acp::tests::keepalive_resets_idle_past_deadline, a timing flake reproducible identically on unmodified main).
  • Unit tests directly on the ticket store per the gate: open / claimed / done-predicate / drop / replay-survives-restart / lock-fail / dual-run-rejected / cap-enforced / corrupt-line-skipped / last-write-wins.
  • cargo clippy -p buzz-acp --all-targets — clean.
  • cargo fmt -p buzz-acp -- --check — clean.
  • cargo build --release -p buzz-acp — builds; smoke-tested --wake-ticket-dir end to end (store opens, lock file + chmod 700 dir + chmod 600 jsonl created, all before the relay connect attempt is logged).

Rollout after this review (not part of this PR): worktree build → side binary at ~/Library/Application Support/xyz.block.buzz.app/harness/bin/buzz-acp-wake, first (and only, until proven) canary unit is Zar, run-zar.sh pointed at the side binary with Desktop start_on_app_launch=false for that key, proof = open ticket on disk survives a bounce with no second @.

🤖 Generated with Claude Code

…y-slice)

buzz-acp's event queue is RAM-only: a mention that's accepted but not yet
consumed does not survive process death. A bounce (kickstart, crash, sleep)
sets a fresh connect-time watermark, and any unconsumed mention is gone —
this is the class of bug behind #01fa1107.

This is the gated today-slice from PLANS/WAKE_TICKET_SPEC.md (Oksana,
2026-08-15): a durable per-agent ticket store, default off behind
--wake-ticket-dir / BUZZ_ACP_WAKE_TICKET_DIR, side-binary/canary use only.

- crates/buzz-acp/src/wake_ticket.rs: WakeTicketStore. One jsonl line per
  mention event_id, exclusive flock on the ticket dir for the process
  lifetime (dual-run is a crash, not a race), atomic tmp+rename writes,
  chmod 600, last-write-wins compaction on open, open -> claimed -> done
  (compacted) | drop (kept for audit) lifecycle.
- lib.rs: ticket `open` written after the first successful queue.push (not
  inside EventQueue::push — requeues go through that too); `claimed` on
  successful dispatch; `done` only on PromptOutcome::Ok with mark_complete
  run, no pending queue depth, and no active retry throttle (mark_complete
  itself is a lock release, not completion); `drop` on dead-letter,
  non-retryable auth error, or channel removal. Boot replay runs before
  HarnessRelay::connect / set_startup_watermark: loads open+claimed
  tickets, re-validates membership + the author gate, pushes survivors into
  the queue, and seeds the relay's seen_ids so the 5s subscribe window
  can't double-deliver them.
- relay.rs: new RelayCommand::SeedSeenIds plumbing for the above.
- queue.rs: EventQueue::is_retry_throttled, and queued_event_count is no
  longer test-only (needed for the done predicate).

Unit tests cover the ticket store directly (open/claimed/done-predicate/
drop/replay-after-restart/lock-fail/cap), per the gate.

Out of scope for this slice, per the gate: nothing points launchd at the
side binary, the shipped /Applications/Buzz.app binary is untouched, and no
canary has run. That's the next step, gated on Oksana's review here plus a
Zar-only bounce proof.

Signed-off-by: Robin Leonard <robinleonardtrust@gmail.com>
@riddlef0x
riddlef0x requested a review from a team as a code owner August 15, 2026 09:27
…-08-15)

Two real bugs from Oksana's review of PR block#5940:

1. Boot replay treated any REST transport failure (query error, unresolved
   channel type) as a confirmed deny and permanently dropped the ticket.
   The bounce this feature exists to survive is often network-adjacent
   (Railway blip, DNS hiccup) — a transient failure at boot must not delete
   work a moment's retry could recover. `is_still_member` now returns
   `Option<bool>` (`None` = query failed, not "not a member"). Replay
   validation is extracted into a testable `validate_ticket_for_replay` ->
   `ReplayDecision::{Push,Drop,SkipThisBoot}`, resolving channel type
   directly (not through the live path's `is_dm_channel`, which
   intentionally fails closed to DM on an unresolved fetch — exactly the
   behavior that was turning a metadata blip into a false author-gate
   rejection for a normal channel). `Drop` now only fires on a confirmed
   deny; `SkipThisBoot` leaves the ticket untouched for the next boot.

2. `done` was gated on the channel's queue being empty. A completed batch
   (A) whose channel had more traffic queued behind it (B, still open) was
   never marked done — it stayed `claimed` indefinitely, so a later bounce
   replayed an already-finished turn a second time. This is the exact
   `01fa1107` double-delivery class the ticket store exists to prevent.
   Only one batch can be claimed/in-flight per channel at a time, so
   `claimed_event_ids_for_channel` already scopes correctly to *this*
   batch regardless of what else is queued — the fix is to stop gating on
   queue depth, not to thread ids through a different path.

Adds `wake_ticket_wiring_tests`: the state-machine-driving tests through
`handle_prompt_result` and the replay-validation helpers that the
store-only unit tests couldn't reach, per Oksana's ask — Ok-with-more-
queued marks exactly this batch done (the regression above), a requeued
failure leaves the ticket claimed (mark_complete alone is not done), a
genuine dead-letter still drops, and both `is_still_member` and
`validate_ticket_for_replay` treat an unreachable relay as "unknown", not
"deny".

Signed-off-by: Robin Leonard <robinleonardtrust@gmail.com>
…ana, 2026-08-15)

Second pass: the two named blockers were fixed, but the residual I flagged
(check_sibling_via_profile fails closed to false on a query timeout/error)
turned out to be blocker 1 on the path this team actually uses — Zar's
traffic is other agents, not Robin, so most replayed tickets need a fresh
sibling lookup, and a Railway blink landing there was deleting them exactly
like the membership/channel-type checks already fixed.

Per instruction: do not fork the live author_allowed. Added a parallel
replay-only decision tree instead:

- extract_verified_sibling: the NIP-OA tag-parsing/verification logic
  pulled out of check_sibling_via_profile as a pure function, shared by
  both the live and replay paths so there's exactly one implementation to
  keep correct.
- check_sibling_via_profile_for_replay / is_owner_or_sibling_for_replay /
  author_allowed_for_replay: tri-state mirrors of the live functions.
  A query timeout/error is None (unknown, not confirmed). A malformed
  pubkey, no owner configured, a completed query with no valid tag, or a
  completed query with a tag that fails signature verification are all
  Some(false) — confirmed, not a transport problem, no reason to wait for
  a retry. The live functions are untouched.
- validate_ticket_for_replay now calls author_allowed_for_replay and maps
  None to SkipThisBoot.

Cached sibling/owner results skip the network entirely on both paths, so
this doesn't cost every replayed ticket a fresh profile query.

Tests: unreachable relay on the sibling-lookup path skips both
is_owner_or_sibling_for_replay and author_allowed_for_replay (not deny);
cached results resolve without touching the network.

Signed-off-by: Robin Leonard <robinleonardtrust@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant