fix(seller): state accepting=n while no harness is serving - #303
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
The agents list on the heartbeat is already wired to the live roster — #300 did that.
publish_heartbeatpassesLiveRoster::advertised(), which filters on live serving state and excludes bothDroppedandIncapable. A dropped harness already stops advertising.The flag beside it was not.
acceptingcame from the in-flight slot alone:So a seat that had dropped every harness published
accepting=y,queue_depth=0, and nomobee_agenttag — byte-identical to a healthy seller that simply never named a harness (a_seller_stating_no_harness_emits_a_byte_identical_heartbeatpins that identity deliberately). Both signals a buyer can filter on read open and unconstrained.Reachable in one step: on a non-
acpbuild the first offer marks each harnessIncapable(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 andagent_tagomits rather than emits empty on purpose. There is no spare state there to mean "none" without a protocol change every reader has to learn.acceptinghas 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
servingis not!names.is_empty()The roster advertises names; the unlabelled
--agent-argvhatch has none.AgentRegistry::advertisedalready 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::advertisementreturns both halves under one lock, because both are published in the same event. Read separately, a fault landing in between could emitaccepting=ybeside 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 viaserves(). I am not claiming otherwise.Side effect worth having:
accepting=n, queue_depth=0is now the distinct signature of up-but-dark versusn, 1= busy — two states the pair could not express before.Red legs
① Revert the conjunction to
!job_in_flight⇒accepting_requires_a_free_slot_and_something_servingfails on the idle+dark row,left: ("y", "0")/right: ("n", "0"). One test red, 198 green.② Derive
servingfrom!ad.names.is_empty()⇒the_advertisement_separates_having_no_name_from_having_nothing_to_servefails ("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_advertisementstayed 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 failedcargo test -p mobee-core --features wallet --locked: 661 → 664 = +3, 0 failed, 3 ignoredSame +3 in both aggregations. ⚠
seller_nodeis#[cfg(feature = "wallet")]anddefault = [], so the default job never compiles the call site — the wallet run is what provesrun.rsbuilds. The three new tests are in ungated modules (heartbeat,seller_roster), so they are in both denominators. Keyed on themobee_coreunittests line by name, never the trailingok. 0 passedintegration bins.Not in scope, flagged for slots=2
job_in_flightishealth().jobs > 0, so withslots=2the heartbeat flips toaccepting=non 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