seller: multi-harness registry, wire advertisement, and harness-aware awarding - #201
Merged
Conversation
… 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
force-pushed
the
seller/multi-harness-registry
branch
from
July 27, 2026 23:17
dfc3544 to
0a4061c
Compare
This was referenced Jul 28, 2026
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.
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.
Live on
mobee-relay.orveth.dev, that config boots:and publishes a heartbeat carrying both (fetched back off the relay, not from our own logs):
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 conventionprotocol_versionsalready 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.tis 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-argvseller 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:
mobee_agenttag. That is the feature, not a regression.--agent-argvseller advertises nothing and its heartbeat is byte-identical to the pre-registry event (a_seller_stating_no_harness_emits_a_byte_identical_heartbeatasserts equality against the old constructor, not just tag-absence).A config without an
agentslist parses unchanged and resolves to the same one-entry registry with the same stored argv — never re-resolved offPATH, 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_agentcolumn plus an idempotentALTER TABLEmigration (schema 1→2), guarded by aPRAGMA table_infocheck.CREATE TABLE IF NOT EXISTSskips 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_namecolumn, 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_namewere a verified-dead second agent axis: zero non-test callers (roster.rshas six tests, all exercising only itself), while the run loop dispatchedseller.agent_commanddirectly. 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 wireassign_agentandjobs.agent_namefor the journal; the roster config androster.rsremain 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 theSellerNodeRunnerstruct-tail — myagentsfield against #198'sboot_auth, in the field list and theOk(Self{…})constructor. Keep-both, no semantic interaction:boot_authis populated from the late NIP-42 wait, while the registry resolves pre-wire, before theClientis constructed. That ordering was re-checked on the rebased tree rather than assumed from the pre-rebase read —agentsresolves immediately afterSellerNode::open, the client is built ~15 statements later, andboot_authlands afterconnect(). No recovery/resubscribe/backfill code and no pay-path code is touched. The buyer-side harness filter is a separate predicate called fromselect_awardable_claimandnamed_claim_awardable— deliberately not placed insideclaim_is_payable, which the crossmint slice is editing.Not in this slice
Parallel execution.
slotsparses (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
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:entries.first()(ignore the request, take the preferred harness): 6 red, including the restart tooth and the claim-gate tooth.a_job_requesting_a_harness_is_never_awarded_to_a_claim_without_it.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 storeOfferby 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: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.devwith both adapters present: per-preset boot verdicts, a two-harness registry, and the heartbeat'smobee_agenttag 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_agenttag, 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.