Skip to content

seller: boot the buzz persona from the production daemon (#199) - #207

Open
orveth wants to merge 4 commits into
devfrom
buzz/wire-persona-boot
Open

seller: boot the buzz persona from the production daemon (#199)#207
orveth wants to merge 4 commits into
devfrom
buzz/wire-persona-boot

Conversation

@orveth

@orveth orveth commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

seller: boot the buzz persona from the production daemon (#199)

SellerNode::start_buzz was merged, fenced and live-verified (#158) — and nothing in the shipping daemon ever called it. "This seller has a persona on buzz" was true of the capability and false of the path mobee sell runs. This wires it, and nothing else.

What changed

  1. SellerNodeRunner::boot brings the persona up when [buzz] is configured, as the last step of boot — after the marketplace relay is connected and authenticated, so buzz never sits in front of the money path's connect. The handle is held for the node's lifetime.

  2. The bring-up is bounded and degrades. BUZZ_START_TIMEOUT (25s) caps it — the legs inside buzz::start are individually bounded but the publish is not — and no buzz outcome can stop the node from selling (sell: startup doctor readiness gate — refuse to boot a box that can't sell (closes #107) #149's brick class): a relay it cannot reach logs BUZZ DEGRADE and boot continues with no persona. With no [buzz] section the call is inert: no connection, no publish, no log line.

  3. A clean stop clears presence instead of leaving the persona advertised as online until the relay's ~60s TTL expires it. Once the run loop is entered its only exits are the two breaks, and both fall through to the clear. A crash still falls back to TTL expiry — that is the crash path, unchanged.

  4. Process semantics change, for buzz-configured daemons only: mobee sell now exits cleanly on SIGTERM/SIGINT. This is the point pr-feedback should look at hardest, so it is stated rather than buried. Registering a tokio signal receiver REPLACES the process default for that signal — installing one unconditionally would silently stop every seller daemon from dying on kill -TERM. So the receivers are installed only when a persona is live, the loop's arm is guarded the way it already guards heartbeat_enabled, and a buzz-inert daemon installs nothing and dies exactly as it does today. Both halves are toothed, and both are proven on the real binary below. Blast radius at merge: zero — no production seller runs a [buzz] config today.

  5. "tokio/signal" joins the wallet feature on mobee-core (the feature that gates seller_node). Unix only; other platforms keep the platform default.

What it does NOT do

No mention handling, no bridge, no new capability. Zero changes inside seller_node/buzz.rs or the signer actor — the signing allowlist {0, 20001, 22242} is untouched. This slice adds a CALLER. No wallet, budget or payment code is touched.

Live proof — the real mobee sell release binary, deployed buzzrelay

Re-run after the rebase onto 8debf43, because #201 changed the boot path this slice wires into (it resolves an AgentRegistry at boot). Binary sha256 6ee9c8e1a47fb91712d83f2e5f77f3651b6969317407a29847b5c59cfa52bc71, built from this branch's tip. Marketplace leg pointed at a local in-memory relay, fresh throwaway key admitted for the run and revoked after; no production infrastructure, no money.

Boot ⇒ persona live (2026-07-28T00:03:26Z)

seller node WARN: no NIP-42 challenge within 20s; proceeding …
seller node buzz persona live: pubkey=d062f0a6… kind0=55b5bdfc054c8cbf1024e0fdfb905cfd2262345cd2e5679ae40be70168c06431
seller node live: pubkey=d062f0a6… relay=ws://127.0.0.1:10547

The marketplace line precedes the persona line — the ordering claim in item 1, read off the log rather than argued.

The persona kind-0 is on the relay, with its rate card — fetched back afterwards through the same /query, filter [{"kinds":[0],"authors":["d062f0a6…"]}]:

id 55b5bdfc054c8cbf1024e0fdfb905cfd2262345cd2e5679ae40be70168c06431   ← the id the daemon logged
content {"name":"buzzwire-live","about":"wiring proof for #199 · rate 2 sat/job · does: code ·
         pays via testnut · hire me on mobee"}
tags [["mobee_persona","seller"]]   ← the clobber marker
signed by the persona key (d062f0a6…), not the relay

Online in the relay's snapshot (00:03:48Z) — POST /query, NIP-98, filter array [{"kinds":[20001],"authors":["d062f0a6…"]}]:

HTTP 200 · one event · content "online" · pubkey 811e497f…6668d (relay-signed) · tags [["p","d062f0a6…"]]

SIGTERM ⇒ clean exit, presence gone (00:03:48Z), pid identity-checked through /proc/<pid>/exe:

seller node: stop requested; clearing the buzz persona and ending the loop
seller node buzz persona cleared (clean shutdown)
exit rc=0 (under 1s) · survivors: none

/query at 00:03:55Z → HTTP 200 []. That is 7s after the kill and ~29s after the last beat, so the 60s TTL cannot explain the absence: the clear is attributable to the shutdown path. The same query returned [] before boot, so both transitions are witnessed by the same instrument.

Control — the inert half, same binary, no [buzz] (00:04:29Z):

kill -TERM ⇒ exit rc=143 (killed by the signal, today's behaviour) · no shutdown lines · survivors: none

That is item 4's "unchanged" claim proven on a live process. (One line of that log matches "buzz" case-insensitively: the test's own git_remote=https://example.invalid/buzzwire-live.git. Zero buzz-path lines.)

Teeth — seller_node::buzz_wire_it, in-process relay, 4 tests, 4 red-on-revert bites at rc=101

They live in a NEW file so a four-deep merge train has one line of shared surface (the mod declaration).

1. Configured vs absent, as one differential. Two nodes boot concurrently against one relay, one observer watches both keys: wired kind0=1 presence=1, inert 0/0. The configured node is the positive control, so the inert node's silence is evidence rather than an untested path. Bite — boot no longer calls the bring-up:

booting with [buzz] configured must publish the persona kind-0 and a presence beat
(kind0=0, presence=0) — the production boot path is the thing under test

2. A dead buzz relay never keeps the node from booting (invariant 2). A bound-then-released port: boot returns Ok bounded, with the DEGRADE line. Bite — propagate instead of degrade:

a dead buzz relay must NOT fail the boot: Relay("buzz: buzz relay: no relay accepted kind-0
(ws://127.0.0.1:34625: relay not connected)")

3. The signal receivers stay uninstalled without a live persona. Bite — drop the persona_live gate:

a buzz-inert daemon must install NO signal handler — installing one silently changes how
every seller daemon dies

4. A relay that hangs is caught by the bring-up's INNER bounds, not by BUZZ_START_TIMEOUT. A local relay whose query and write policies never resolve — handshake complete, then silence, which is the failure the bound exists for. It asserts the returned variant (Failed, not TimedOut), never elapsed time: the call has returned, so there is no window to be wrong about and nothing that rots when a machine gets slower. The arithmetic is in the test's docs — kind-0 fetch 8s + the SDK's WAIT_FOR_OK_TIMEOUT 10s = 18s against a 25s bound, and measured at 18.005s against an 18s prediction read off the constants. A red here means a dependency bump pushed that sum past the bound and the backstop has started bearing load. Bite — shrink the window to 15s:

arm=TimedOut elapsed=15.0005s → a hung relay must be refused by the bring-up's own bounds,
leaving BUZZ_START_TIMEOUT a backstop … the inner bounds now sum past 15s and the backstop
is bearing load

Gates

Branch is rebased onto 8debf43 (#201). Every figure below was re-measured on that base, on both sides — nothing is carried from the pre-rebase run, because the base moved and a comparison is only valid against the tree it was taken on.

  • cargo test --workspacerc=0, 665 passed, 0 failed on this branch; pristine 8debf43rc=0, 661 passed. Delta = exactly the 4 teeth.
  • All-combos compile, every one rc=0: core --no-default-features · gateway · git-delivery · wallet · acp · all four together · bin default · bin+acp · bin+stub-pay · cargo check --workspace --all-targets. Release binary builds.
  • clippy (--features gateway,git-delivery,wallet --all-targets -D warnings): 47 errors on this branch, 47 on pristine 8debf43, per-file counts identical. Zero in the new test file; run.rs carries one on both sides. (The old base yielded 57 — seller recovery: order the resubscribe after NIP-42 (#189 client half), and give the open-pool re-arm an owned schedule (#190) #198/seller: multi-harness registry, wire advertisement, and harness-aware awarding #201 removed ten, which is exactly why this was re-measured rather than restated.)
  • rustfmt: the new file is clean outright under the workspace edition. run.rs is NOT whole-tree formatted (pristine 8debf43 already yields 90 hunks under this toolchain, this branch also 90); the normalized hunk set-difference pristine-vs-branch is a single use super::{…} line that is already a pristine hunk — this diff adds one name to it.
  • #[allow(clippy::large_enum_variant)] on BuzzStartOutcome carries its reason: exactly one exists per process boot and it is destructured immediately, so boxing would trade a one-off few hundred stack bytes for a per-boot allocation. Four other allows in this crate, one already in run.rs.
  • One combo is red on BOTH sides and is filed as mobee bin does not build with --no-default-features (sell.rs imports the feature-gated mobee_core::profile) #203: cargo check -p mobee --no-default-features (sell.rs imports the feature-gated mobee_core::profile). mobee ships default = ["wallet"], so no shipped configuration is affected — it is outside the all-combos gate, found while widening the matrix.
  • Not verified here: the non-unix branch of ShutdownSignals (no Windows target installed on this box). It is written to keep the platform default — install answers None — matching the crate's existing posture, where wallet already carries #[cfg(unix)] paths.

Found while wiring — filed, not fixed here

#202: the kind-0 clobber guard fail-closes on Err but reads Ok(empty) as "no persona on this key". fetch_events returns Ok(<empty>) when a relay never answers, so a dead socket — or auth-pending, or #189's restricted: path — makes the node clobber a foreign kind-0 it merely could not see. The dead-relay tooth's failure lands at the kind-0 publish, which is only reachable past the guard. That is persona logic inside buzz.rs, which this slice does not touch.

worker:mobee-buzzwire added 4 commits July 27, 2026 16:56
The seller daemon's boot now brings up the buzz persona when `[buzz]` is
configured and clears its presence when the node stops cleanly, so "this
seller has a persona on buzz" is true of the path `mobee sell` runs — not
only of the capability behind it.

Bounded and degrading by construction: the bring-up cannot outrun
BUZZ_START_TIMEOUT, and no buzz outcome can keep the node from selling —
a relay it cannot reach logs BUZZ DEGRADE and boot continues. With no
`[buzz]` section nothing is opened, published, or logged.

A stop request (SIGTERM/SIGINT) ends the loop so presence clears at exit
rather than expiring on the relay's TTL. The receivers are installed ONLY
for a daemon carrying a live persona: registering one replaces the process
default, so a buzz-inert daemon installs nothing and dies exactly as it
does today.

Teeth (seller_node::buzz_wire_it, in-process relay):
- configured vs absent as one differential, one observer, both keys: the
  configured node is the positive control for the other's silence
- a dead buzz relay boots anyway, bounded
- the signal receivers stay uninstalled without a live persona
The new test file is clean outright under the workspace edition; run.rs
keeps its prevailing style, with only the two new constructs formatted so
the diff adds no formatting divergence of its own.
The bound was a backstop asserted only by construction, and a relay that
hangs mid-conversation — handshake complete, then silence — is the failure
it exists for. That case is now driven by a local relay whose query and
write policies never resolve.

What it asserts is the arm that ran, not a duration: the bring-up's own
bounds refuse the hang (kind-0 fetch 8s + the SDK's 10s OK-wait = 18s)
while the 25s bound stays a backstop. The call has returned, so there is
no window to be wrong about and nothing that rots when a machine gets
slower. A dependency bump that pushes that sum past the bound flips this
to TimedOut and turns it red — which is the day the backstop starts
bearing load and someone needs to know.

The arms had to become nameable for that, so the bring-up now reports a
BuzzStartOutcome and the boot path keeps the logging.
clippy's large_enum_variant fires on `Live`, and boxing it would trade a
one-off few hundred stack bytes for an allocation on every boot. The
exemption carries its reason, matching the four other allows in this crate.
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