Skip to content

seller: multi-harness registry, wire advertisement, and harness-aware awarding - #201

Merged
orveth merged 6 commits into
devfrom
seller/multi-harness-registry
Jul 27, 2026
Merged

seller: multi-harness registry, wire advertisement, and harness-aware awarding#201
orveth merged 6 commits into
devfrom
seller/multi-harness-registry

Conversation

@orveth

@orveth orveth commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

One seller node can run several agent harnesses at once, and a buyer can ask for the one it wants. Execution stays serial — one job at a time across the whole registry.

What a node does now

Resolves its harnesses once at boot, advertises them, claims only what it can actually run, and dispatches each job to the harness its buyer asked for.

[seller]
agents = ["claude", "codex"]   # order = preference

Live on mobee-relay.orveth.dev, that config boots:

seller node agent PASS claude argv0=/home/gudnuf/.npm-global-buzz/bin/claude-agent-acp
seller node agent PASS codex argv0=/srv/forge/tools/codex-acp-ng/node_modules/.bin/codex-acp
seller node agents ready: ["claude", "codex"] (serial execution — one job at a time)

and publishes a heartbeat carrying both (fetched back off the relay, not from our own logs):

["d","mobee-seller"], ["t","mobee"], ["accepting","y"], ["queue_depth","0"],
["rate","2"], ["protocol_versions","0"], ["mobee_agent","claude","codex"]

Wire

Extends issue #170 rather than inventing a parallel scheme:

  • ["mobee_agent","claude","codex"] on the heartbeat (30340) and the claim (3402) — multi-value and ordered, the same convention protocol_versions already uses in that file. kind-0 persona: machine-filterable capability/rate tags alongside the prose rate card #170's single-harness ["mobee_agent","claude"] is the one-entry case of it.
  • ["param","agent","claude"] on the offer — a sibling of ["param","deadline",…], so no new top-level tag. The value is opaque to the wire: an exact harness name today, with room for a tier vocabulary later without a grammar change.
  • any, blank, and an absent parameter all canonicalise to the same thing — no tag at all — so "no preference" has exactly one representation.

t is deliberately left free for #170's capability tags.

Two rules the design leans on

Advertised is exactly what dispatch can serve. One set answers both "what does the wire say" and "what will this node run", so they cannot drift apart. A raw --agent-argv seller has no preset label, so it advertises nothing — there is no honest harness name to publish.

A named request is exact or nothing. No nearest-match fallback anywhere: not in the registry, not at the claim gate, not at dispatch. Silently running a job on a harness the buyer did not ask for is the failure this exists to prevent.

Compat — stated as two separate claims

A single blanket "byte-identical" claim would be false, so it is split and each half is toothed:

  • A preset-labelled seller's wire gains the additive mobee_agent tag. That is the feature, not a regression.
  • A raw --agent-argv seller advertises nothing and its heartbeat is byte-identical to the pre-registry event (a_seller_stating_no_harness_emits_a_byte_identical_heartbeat asserts equality against the old constructor, not just tag-absence).

A config without an agents list parses unchanged and resolves to the same one-entry registry with the same stored argv — never re-resolved off PATH, so an existing seller launches the binary it has been launching.

Restart survival is load-bearing, not decorative

Execution can be a restart away from the claim. If the requested harness lived only in memory, a resumed job would dispatch whatever the node prefers now — invariant 2 would hold in a single process and quietly break across a crash. So the request is journaled with the other offer facts: additive offers.requested_agent column plus an idempotent ALTER TABLE migration (schema 1→2), guarded by a PRAGMA table_info check. CREATE TABLE IF NOT EXISTS skips a table that already exists, so without the ALTER an upgraded node would fail every offer read on a live store — that path has its own tooth, opening a store built on the old schema and asserting the pre-existing row survives and reads as no-preference.

Which harness ran a job lands in the existing jobs.agent_name column, written before the run so a failed job still names what ran it.

Two registries — deliberate, tracked, not discovered

[seller_roster] / roster.rs::select_agent / store::assign_agent / jobs.agent_name were a verified-dead second agent axis: zero non-test callers (roster.rs has six tests, all exercising only itself), while the run loop dispatched seller.agent_command directly. It is the private worker axis — its names are documented as "never published to buyers" — whereas harness pools are the public runtime axis, so this PR did not fold, rewrite, or delete it. This PR does wire assign_agent and jobs.agent_name for the journal; the roster config and roster.rs remain unwired. Issue #200 carries the consolidate-or-remove decision with the caller census, to be made after this lands.

Sibling surfaces

Rebased onto 2ab64b7 (#196). The only conflict was the SellerNodeRunner struct-tail — my agents field against #198's boot_auth, in the field list and the Ok(Self{…}) constructor. Keep-both, no semantic interaction: boot_auth is populated from the late NIP-42 wait, while the registry resolves pre-wire, before the Client is constructed. That ordering was re-checked on the rebased tree rather than assumed from the pre-rebase read — agents resolves immediately after SellerNode::open, the client is built ~15 statements later, and boot_auth lands after connect(). No recovery/resubscribe/backfill code and no pay-path code is touched. The buyer-side harness filter is a separate predicate called from select_awardable_claim and named_claim_awardable — deliberately not placed inside claim_is_payable, which the crossmint slice is editing.

Not in this slice

Parallel execution. slots parses (agents = ["claude", { name = "codex", slots = 2 }] in the same list, so pool counts arrive later without reshaping a config anyone has written) but any value above 1 is refused at boot rather than silently run serially — a config asking for capacity the engine does not have should fail loudly, not deliver a fraction of what it declared.

Evidence

cargo test --workspace --all-features   →  rc=0, 668 tests, 0 failed
                                            (mobee-core lib: 623)
cargo check -p mobee-core --all-targets, every shipped feature combo:
  (none) · wallet · wallet,acp · all-features             →  rc=0 each
cargo clippy --workspace --all-features --all-targets → rc=0, 0 warnings on changed lines

Red-on-revert for the two strong invariants:

All three re-measured on the rebased tip (2ab64b7), not carried over from the pre-rebase run:

  • Invariant 2 — swap the dispatch lookup to entries.first() (ignore the request, take the preferred harness): 6 red, including the restart tooth and the claim-gate tooth.
  • Invariant 5 — drop the harness arm from both award paths: 1 red, a_job_requesting_a_harness_is_never_awarded_to_a_claim_without_it.
  • The wire-to-row seam — drop the request in offer_row: 1 red. Worth stating why this tooth exists, because it was added after the others and found a real hole. Every other tooth here either builds the store Offer by hand or reads one back, so none of them look at the mapping that produces it: with that mapping broken, the whole suite stayed green while invariant 2 held inside one process and was dead the moment execution happened after a restart. Built, green, dead. The mapping now has a name, one call site, and a test that starts from a wire event.

rustfmt skew — not reformatted, on purpose

rustfmt 1.8.0-stable (this box's rust 1.94 toolchain) wants to reformat files this branch never touched:

$ rustfmt --edition 2024 --check crates/mobee-core/src/budget.rs   → wants changes
$ rustfmt --edition 2024 --check crates/mobee-core/src/receipt.rs  → wants changes

Both are untouched on pristine dev. Running it over the diff would bury the change in unrelated churn, so new code matches the surrounding style instead. Same call and same evidence shape as the crossmint slice.

Live smoke — seller half done, paid trade not run

Proven live on mobee-relay.orveth.dev with both adapters present: per-preset boot verdicts, a two-harness registry, and the heartbeat's mobee_agent tag read back off the relay rather than out of our own logs.

Not proven live, and covered by unit teeth only: the claim's mobee_agent tag, the claim-time harness gate, and dispatch. Those need the end-to-end paid two-job trade (one job per harness, journal rows proving which ran which), which wants a funded testnut buyer and two real agent runs — sequenced by the orchestrator alongside the recoveryfix → crossmint → this merge order.

Closes nothing on its own; #200 tracks the follow-up decision.

mobee-multiharness and others added 6 commits July 27, 2026 16:04
… awarding

A seller node can enable several agent harnesses at once. It resolves them
once at boot, advertises them on its heartbeat and claims, claims only jobs
whose requested harness it can run, and dispatches each job to the harness
its buyer asked for. Execution stays serial: one job at a time.

- seller_agents: the registry — resolve, advertise, dispatch. A named request
  is exact or nothing; advertised is exactly what dispatch can serve.
- config: [seller] agents = ["claude", "codex"], entries bare or {name, slots},
  so pool counts arrive later without reshaping the list. slots > 1 refused.
- wire: mobee_agent multi-value on heartbeat 30340 + claim 3402 (issue #170's
  single-harness tag, generalized); offers request one via param/agent.
- store: offers.requested_agent (additive migration) so a job resumed after a
  restart still dispatches to the harness it was posted for.
- buyer: the award filter holds a claim to the harness the offer asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other tooth either builds the offer row by hand or reads one back, so
all of them stayed green if the claim path dropped the request on its way
into the store — invariant 2 would hold in one process and be dead the
moment execution happened after a restart. Give the mapping a name and a
test that starts from a wire event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@orveth
orveth force-pushed the seller/multi-harness-registry branch from dfc3544 to 0a4061c Compare July 27, 2026 23:17
@orveth
orveth merged commit 8debf43 into dev Jul 27, 2026
2 checks passed
@orveth
orveth deleted the seller/multi-harness-registry branch July 27, 2026 23:47
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