Skip to content

fix(seller): state accepting=n while no harness is serving - #303

Merged
orveth merged 1 commit into
devfrom
seller/heartbeat-accepting-live-roster
Jul 30, 2026
Merged

fix(seller): state accepting=n while no harness is serving#303
orveth merged 1 commit into
devfrom
seller/heartbeat-accepting-live-roster

Conversation

@orveth

@orveth orveth commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What this fixes

The agents list on the heartbeat is already wired to the live roster — #300 did that. publish_heartbeat passes LiveRoster::advertised(), which filters on live serving state and excludes both Dropped and Incapable. A dropped harness already stops advertising.

The flag beside it was not. accepting came from the in-flight slot alone:

HeartbeatDraft::v1(!job_in_flight, u32::from(job_in_flight), rate_sats)

So a seat that had dropped every harness published accepting=y, queue_depth=0, and no mobee_agent tag — byte-identical to a healthy seller that simply never named a harness (a_seller_stating_no_harness_emits_a_byte_identical_heartbeat pins that identity deliberately). Both signals a buyer can filter on read open and unconstrained.

Reachable in one step: on a non-acp build the first offer marks each harness Incapable(AcpFeature), so a one-harness seat is fully dark after a single job.

The design call on emptiness

Routed through accepting, not through the agents tag. An absent tag already means UNSTATED — that absence is load-bearing and agent_tag omits rather than emits empty on purpose. There is no spare state there to mean "none" without a protocol change every reader has to learn. accepting has no unstated value, so the truth fits a field that already exists.

Rejected: stop heartbeating when dark. The #150 relay-stall watchdog uses the own-heartbeat round-trip as its liveness probe (filter is kind+author+d, run.rs:572-574). A dark seat would look relay-stalled and churn reconnects, and would vanish from discovery exactly when an operator needs to see it is up-but-dark. Worse on every axis.

Why serving is not !names.is_empty()

The roster advertises names; the unlabelled --agent-argv hatch has none. AgentRegistry::advertised already documents it: "an empty list means 'this seller states no harness', never 'it has none'." So a seat serving only the hatch advertises nothing while being perfectly able to work, and reading darkness off that list would take it off the market for lacking a label. The signal comes from the roster's own dispatch predicate.

LiveRoster::advertisement returns both halves under one lock, because both are published in the same event. Read separately, a fault landing in between could emit accepting=y beside an empty advertisement — the exact incoherence a live roster exists to make unrepresentable.

Blast radius — measured, and deliberately narrow

Nothing in this tree reads accepting (every other hit of the word is English prose). This is honesty on the wire for external readers — the market site, buyer filters. It does not change our own dispatch, which already declines correctly via serves(). I am not claiming otherwise.

Side effect worth having: accepting=n, queue_depth=0 is now the distinct signature of up-but-dark versus n, 1 = busy — two states the pair could not express before.

Red legs

① Revert the conjunction to !job_in_flightaccepting_requires_a_free_slot_and_something_serving fails on the idle+dark row, left: ("y", "0") / right: ("n", "0"). One test red, 198 green.

② Derive serving from !ad.names.is_empty()the_advertisement_separates_having_no_name_from_having_nothing_to_serve fails ("yet the seat is serving, and must advertise as accepting"). One test red, 198 green. ⚠ Note which test did not redden: a_fully_dropped_named_roster_reports_dark_alongside_an_empty_advertisement stayed green, because for a named roster the simplification gives the right answer. The hatch case is the only positive control for that mistake — that is why it exists as its own test.

Counts — both sides, same tree, same command

  • cargo test -p mobee-core --locked (CI job 1): 196 → 199 = +3, 0 failed
  • cargo test -p mobee-core --features wallet --locked: 661 → 664 = +3, 0 failed, 3 ignored

Same +3 in both aggregations. ⚠ seller_node is #[cfg(feature = "wallet")] and default = [], so the default job never compiles the call site — the wallet run is what proves run.rs builds. The three new tests are in ungated modules (heartbeat, seller_roster), so they are in both denominators. Keyed on the mobee_core unittests line by name, never the trailing ok. 0 passed integration bins.

Not in scope, flagged for slots=2

job_in_flight is health().jobs > 0, so with slots=2 the heartbeat flips to accepting=n on the first job while the node happily claims a second — an existing wire lie that understates capacity. It wants its own reasoning in the slots=2 PR, not smuggled in here.

Refs #254

🤖 Generated with Claude Code

The heartbeat's `accepting` flag came from the in-flight slot alone, so a seat that had
dropped every harness kept publishing `accepting=y` with no `mobee_agent` tag — byte-identical
to a healthy seller that simply never named a harness. Both wire signals a buyer can filter on
read "open and unconstrained", and the seat drew work it could only decline. Reachable in one
step: on a non-`acp` build the first offer marks each harness `Incapable(AcpFeature)`, so a
one-harness seat goes dark after a single job.

`accepting` is now the conjunction of a free slot AND something actually serving. The serving
signal comes from the roster's dispatch predicate, never from the advertised name list: the
unlabelled `--agent-argv` hatch serves without a name to publish, so reading darkness off that
list would take a working seat off the market for lacking a label.

`LiveRoster::advertisement` returns both halves under ONE lock, because both are published in
the same event — read separately, a fault landing in between could emit `accepting=y` beside an
empty advertisement, the exact incoherence a live roster exists to make unrepresentable.

Routed through `accepting` rather than a marker on the agents tag: an ABSENT tag already means
"unstated", so there is no spare state there to mean "none" without a protocol change every
reader must learn. `accepting` has no unstated value. Rejected stopping the heartbeat when
dark — the #150 relay-stall watchdog uses the own-heartbeat round-trip as its liveness probe,
so a dark seat would look relay-stalled and churn reconnects, and would vanish from discovery
just when an operator needs to see it is up-but-dark.

`accepting=n, queue_depth=0` now distinguishes up-but-dark from busy (`n, 1`) — two states the
pair could not express before.

Red-proved both ways: reverting the conjunction fails the truth table on the idle+dark row
(left `("y","0")`, right `("n","0")`); deriving `serving` from `!names.is_empty()` fails the
hatch test only — the named-roster test stays green, so the hatch case is the sole positive
control for that simplification.

Refs #254

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mobee Ready Ready Preview Jul 30, 2026 1:59pm

Request Review

@orveth
orveth merged commit bba2754 into dev Jul 30, 2026
5 of 6 checks passed
@orveth
orveth deleted the seller/heartbeat-accepting-live-roster branch July 30, 2026 14:27
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