Skip to content

buyer: MCP connect-or-spawn — daemon-owned money path + 2-call trade loop - #166

Closed
orveth wants to merge 3 commits into
mainfrom
mcp-connect-or-spawn
Closed

buyer: MCP connect-or-spawn — daemon-owned money path + 2-call trade loop#166
orveth wants to merge 3 commits into
mainfrom
mcp-connect-or-spawn

Conversation

@orveth

@orveth orveth commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

What

Makes the buyer MCP a thin client of the buyer daemon and moves every money op onto the daemon socket, then completes the 2-call trade loop (post_jobcollect) with the daemon driving the award under the hood. Closes the #126 remainder and the #131 acceptance ("fresh home, only the MCP configured, zero manual daemon commands").

The trade loop now

  • post_job(max_sats) publishes the offer, records an auto-award intent in buyer.sqlite, and spawns a background task that waits (bounded by the offer deadline) for a payable claim, then reserves-then-awards it.
  • The buyer's next call is collect(job_id) — the explicit settle (verify integrity → pay → materialize). Delivery→verify→pay is NOT auto-driven; that stays the deliberate 2nd call (full post-and-forget is a later MCP: post-and-forget — server drives the whole job lifecycle + pushes result; collect becomes reconcile/pull #127 slice).
  • Manual award_claim remains for fine-grained control.

Connect-or-spawn (daemon-owned money path)

  • crates/mobee/src/daemon.rs: connect to $MOBEE_HOME/buyer.sock; if none is serving, spawn mobee buyer serve detached (own process group, MOBEE_HOME pinned to the caller's home) and poll until it binds. A double-spawn is safe — the loser fails closed on the exclusive home lock, both callers reach the winner.
  • The MCP (mcp.rs) forwards post_job/get_job/collect/award_claim over the socket; the in-process BudgetGate/collect/job_lifecycle money paths are gone. mobee collect (CLI) routes the same way — the in-process money path for money ops is removed; daemon-owns-home is enforced, not convention.

Wiring obligations (recorded on #136)

  • max_sats on manual award — was dead input; now applied via lifecycle::named_claim_awardable (OverMax / NotFound / NotLive / Unpayable), the manual counterpart of select_awardable_claim.
  • Kept-uncertain reservations surfacedreconcile_on_start no longer discards its ReconcileReport; it's kept on the daemon and shown in status.reconcile {released, converted, kept}. Parked auto-awards show in status.parked_awards.
  • scan_payment_progress + reconcile now tested — previously zero coverage.

The money-ordering guarantee — every reserved→spent flip / award entry point

Ordering invariant everywhere a reservation flips reserved→spent: the budget-journal append AND the wallet melt land BEFORE the flip.

  1. buyer::collect RPClifecycle::settle_after_paystore.convert_to_spent. Reached ONLY after collect::collect_async (budget append via authorize_then_attempt + wallet melt inside run_verified) returns Closed. Pay-first, flip-after. (unchanged from buyer daemon: auto-award + live reservation/collect wiring (#126) #136; tooth: settle_flips_only_after_pay_succeeds.)
  2. buyer::reconcile_on_startstore.reconcile converts Paid jobs. Paid is derived ONLY from a journal Closed state (append+melt+receipt durable). (unchanged from buyer daemon: auto-award + live reservation/collect wiring (#126) #136; tooth: crash_between_pay_and_flip_....)

Award (reserve) entry points — reserve-then-publish, never a spend:

  1. award RPC (manual + auto) → lifecycle::award_with_reservation (reserve FIRST, publish second; refused reserve writes nothing/publishes nothing).
  2. drive_auto_award (background) / finalize_auto_award → the SAME award_with_reservation. No award path bypasses the reserve.

Background auto-award — the two invariants (both tested)

  • (A) Idempotent re-arm — never award twice. lifecycle::plan_rearm(award_on_relay, reservation): Skip if a buyer AWARD (kind-3405) is already on the relay OR the reservation is already Spent; otherwise Attempt. A Reserved-but-not-on-relay row is the crash window between reserve and publish → Attempt republishes (the reserve is idempotent for the same amount). Checking BOTH the relay (has_award_async) AND the local ledger is load-bearing: a 3405 may have published before a crash. Re-arm on restart (run()list_pending_awardsspawn_auto_award) re-checks the relay first, so it never double-awards. Tooth: plan_rearm_skips_only_when_already_awarded.
  • (B) Reserve-then-award only; park, never drop. Awards go exclusively through award_with_reservation. A refused reservation (e.g. funds shrank) PARKS the intent with a surfaced reason (status.parked_awards) — never a silent drop. Tooth: pending_award_lifecycle_parks_with_reason_and_rearms.

Architecture: single money-bookkeeping (grok audit finding)

Grok's payment-flow audit flagged the buyer's DUAL money-bookkeeping — the MCP's in-process BudgetGate vs the daemon's reservation ledger — as its top architecture finding. This PR resolves it in the daemon's favor: the MCP holds no BudgetGate; the daemon's reservation ledger + budget gate are the single source of money truth, and every buyer money op (MCP or CLI) routes through the daemon. There is no second bookkeeping surface left.

#152 regression only manifests in DEBUG

reqwest's blocking runtime-guard is #[cfg(debug_assertions)], so a #152 regression is invisible to a --release test run. The guard is only live under a debug cargo test. blocking_request_runs_off_the_async_runtime and collect_refuses_pay_when_delivered_tip_differs_from_bound_oid catch it there; a release-only CI green must NOT be read as #152 coverage.

Fixes folded in

  • Relay event retention shorter than the job lifecycle — trades expire off the relay while still payable #140 (relay retention expiry): collect was already relay-expiry-safe (local accept-bind + retained git objects). Added a reconcile guard: a reserved job with a local delivery bind stays payable even if the relay expired its events (a delivered job is not dead).
  • money-path: collect's delivery verify-fetch panics in debug — nested runtime drop in git_transport::client_short #152 (collect verify-fetch panics in DEBUG): reqwest's blocking client runs a debug-only guard that builds+drops a throwaway Tokio runtime on every request; dropping a runtime inside another runtime's context panics ("Cannot drop a runtime in a context where blocking is not allowed") — release makes the guard a no-op, which masked it. The buyer verify-fetch (git2 smart-HTTP → reqwest::blocking) runs synchronously inside authorize_pay_async (a Tokio worker). Fix: the verify-fetch git2 operations (GitDeliveryVerifier::fetch / fetch_base) now run on a dedicated OS thread via git_transport::off_runtime, so no ambient runtime is present (works under any caller runtime flavor, unlike block_in_place). Regression tooth: blocking_request_runs_off_the_async_runtime (drives a real blocking request from inside a #[tokio::test] via off_runtime; red-on-revert = call .send() directly → panics in debug). Verified: collect_refuses_pay_when_delivered_tip_differs_from_bound_oid now passes in a DEBUG build (it panicked before).

Gates (rc=0)

  • cargo test -p mobee-core --no-default-features --features gateway,git-delivery,wallet --release
  • cargo build -p mobee-core --features acp --release
  • cargo test -p mobee-core --features acp --release
  • cargo build -p mobee --release
  • cargo test -p mobee --release (integration: mcp_daemon — connect-or-spawn + money-op-served-by-daemon)
  • #152 verified in DEBUG.
  • Live testnut dogfood: post→auto-award→deliver→collect pays exactly once (evidence in comments).

🤖 Generated with Claude Code

orveth and others added 3 commits July 25, 2026 09:29
…socket

The MCP is now a thin client of the buyer daemon: post_job/get_job/collect/
award_claim forward over $MOBEE_HOME/buyer.sock (connect-or-spawn) instead of
opening the wallet/budget/relay in-process. `mobee collect` (CLI) routes the same
way, closing the in-process money bypass so the daemon is the single money owner
(daemon-owns-home enforced, not convention).

- crates/mobee/src/daemon.rs: connect-or-spawn client. Connects if a daemon is
  serving; else spawns `mobee buyer serve` detached (own process group, MOBEE_HOME
  pinned to the caller's home) and polls until it binds. A double-spawn is safe —
  the loser fails closed on the exclusive home lock, both callers reach the winner.
- mcp.rs: thin router; McpState drops the in-process BudgetGate. post_job gains
  max_sats/harness/model inputs (forwarded); award_claim gains max_sats.
- collect_cli.rs: routes collect over the daemon.
- daemon post_job: contribution offers (all-or-nothing pins) — no silent
  from-scratch fallback now that the MCP forwards contribution mode; status carries pid.
- tests/mcp_daemon.rs: connect-or-spawn (spawn + reuse + second-serve fails closed)
  and money-op-served-by-daemon-never-in-process (a failed collect burns zero).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two #136 wiring obligations:

- Manual award (named claim_id) now applies the SAME hard filters as auto-award
  via lifecycle::named_claim_awardable — max_sats, price, mint, liveness — so
  max_sats is enforced, not dead input, on the manual path. Refuses OverMax /
  NotFound / NotLive / Unpayable with a structured reason.
- reconcile-on-start no longer discards its ReconcileReport: it is kept on the
  daemon context and surfaced in `status.reconcile` {released, converted, kept}
  so kept-uncertain reservations (funds committed to an ambiguous payment) are
  visible. The reserved-job → disposition mapping is factored into a pure
  `plan_reconcile`. A reserved job with a local delivery bind stays payable even
  if the relay expired its events (a delivered job is not dead — cf #140).

Tests: named_claim_awardable (5 cases), scan_payment_progress (absent / intent
folds to None / unfoldable folds to Uncertain — the reconcile fail-safe),
plan_reconcile mapping + conservative default, merge_progress ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix #152 debug panic

Completes the 2-call trade loop and folds in #140/#152.

Background auto-award (the daemon-drives-the-award half of post_job → collect):
- post_job records a pending_award intent (buyer.sqlite schema v3: pending_awards)
  and spawns a bounded task that waits for a payable claim (bounded by the offer
  deadline), then reserves-then-awards it. Re-armed on restart (run() →
  list_pending_awards → spawn), so a job posted before a crash still gets its
  award with zero manual commands.
- Invariant A (never award twice): lifecycle::plan_rearm checks BOTH the relay
  (has_award_async: kind-3405 by this buyer, #e=offer) AND the local reservation.
  Skip if an award is on the relay OR the reservation is already Spent; else
  Attempt. A Reserved-but-not-published row (crash window) re-attempts —
  award_with_reservation's reserve is idempotent, so it republishes without a
  double reserve.
- Invariant B (reserve-then-award only): every award path (manual RPC, auto,
  re-arm) goes through award_with_reservation. A refused reservation parks the
  intent with a surfaced reason (status.parked_awards) — never a silent drop.

#152 (collect verify-fetch panics in DEBUG): reqwest::blocking runs a debug-only
guard that builds+drops a throwaway runtime on every request; dropping a runtime
inside an async context panics (release makes the guard a no-op, masking it). The
buyer verify-fetch runs synchronously inside authorize_pay_async, so
GitDeliveryVerifier::fetch/fetch_base now run on a dedicated OS thread via
git_transport::off_runtime. Verified: collect_refuses_pay_when_delivered_tip_
differs_from_bound_oid was RED in debug before, GREEN after.

#140: reconcile keeps a delivered job (local accept-bind) payable even if the
relay expired its events (a delivered job is not dead).

Tests: plan_rearm decision table, pending_award lifecycle (park-with-reason +
re-arm), off_runtime blocking-request regression, schema v3 migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@orveth

orveth commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Gate evidence (rebased onto main a7a3044)

All acceptance gates green on the rebased tree:

cargo test -p mobee-core --no-default-features --features gateway,git-delivery,wallet --release
  → 571 passed; 0 failed
cargo build -p mobee-core --features acp --release                → Finished (rc=0)
cargo test  -p mobee-core --features acp --release                → 170 passed; 0 failed
cargo build -p mobee --release                                    → Finished (rc=0)
cargo test  -p mobee --release --test mcp_daemon                  → 2 passed; 0 failed

mcp_daemon proves the two acceptance-required integration tests: connect-or-spawn
(no daemon → a routed money op spawns one → a second session reuses the SAME daemon;
a direct second buyer serve fails closed on the exclusive home lock) and
money-op-served-by-the-daemon-never-in-process (a failed collect burns zero — no
payment journal, no reservation flip).

#152 verified in a DEBUG build (release masks it — see the PR body caveat)

collect_refuses_pay_when_delivered_tip_differs_from_bound_oid was RED in debug
before
the fix — panicked at tokio .../blocking/shutdown.rs:

Cannot drop a runtime in a context where blocking is not allowed.

and is GREEN in debug after routing the verify-fetch through
git_transport::off_runtime. The focused tooth blocking_request_runs_off_the_async_runtime
drives a real reqwest::blocking send from inside a #[tokio::test] via off_runtime
(red-on-revert = call .send() directly → debug panic).

Live testnut dogfood

Throwaway buyer home in-workspace is funded (100 testnut @ testnut.cashudevkit.org),
relay NIP-42-authenticated, mint reachable. The throwaway-seller half is blocked on
this env having no ACP agent adapter on PATH; the live post→auto-award→deliver→collect
trade evidence will be added here once a seller is available.

@orveth

orveth commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Live testnut dogfood — 2-call auto-award loop PROVEN REAL-AND-LIVE ✅

Fresh throwaway buyer home (in-workspace), funded 100 testnut, budget hard-capped to
total_budget_sats=5. Traded against a live always-on marketplace seller on
wss://mobee-relay.orveth.dev (mint testnut.cashudevkit.org). I made exactly two
agent calls — post_job and collect; the daemon drove the award autonomously.

Buyer 47b1a86bb8affd4195e1cdf346ea848cbce25b92141b021cfc95e419b0f17534 ·
Seller 875d29ba9c31b0fd6ab729dd046d3dd8a2e4fab328bdef1a63d913104ac0d0ba

The loop (zero human action between post and collect)

  1. post_job(amount=5, max_sats=5, untargeted) → job 8d778161…. Daemon wrote a
    pending_awards intent (state=pending) and spawned the background watcher.
  2. Auto-award (no award_claim call from me): the daemon watched → the seller
    claimed → it auto-selected + reserved-then-awarded (award_with_reservation).
    Observed state at first poll: pending_awards: 8d778161=awarded,
    reservations: 8d778161 reserved:5, claims=3 result=1.
  3. collect(8d778161…) → paid the seller and materialized the delivery:
    • files ["hello.txt","seller-run.jsonl"], hello.txt == hello (task done)
    • commit 7192030b9e1d4bf1cf61a43a4210095b9589093b, claim 9b534fb1…, result 6d8b79b7…
    • pay state=Closed, receipt_id 855c9160c1ebd015e93515a936ab7614167f83036a19b002be2df40943334dce (kind-3400 co-signed), attempt 1cc917cc…, spent_total=5

The invariants, observed live

  • reserved → spent: the reservation row went 8d778161 reserved:5spent:5 across the collect (the pay-then-flip ordering).
  • pays exactly once: a second collect returned the same attempt_id 1cc917cc… with spent_total still 5 — no second melt. spent.jsonl has one entry (amount_sats:5). Buyer wallet 100 → 94 (5 to seller + 1 mint fee), one payment.
  • restart re-arm (invariant A, bonus): raising the throwaway cap 2→5 required a daemon restart (cap is read at startup). On reboot, reconcile_on_start → list_pending_awards → spawn_auto_award re-armed the persisted intent — no manual re-drive.
  • rate-gate decline (real-world datum): a parallel 2-sat offer (e3835ade…) stayed claim=0 — the always-on seats floor at 5 sats and skipped it. Over-commit guard was armed (cap 5; a claim on the 2-sat while 5 was reserved would need 7>5 → ledger refusal) but never fired because the 2-sat was never claimed.

Cost: 5 sats testnut (within the approved 5-sat ceiling). Both sides of the trade exercised.

@orveth

orveth commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Rate-gate decline — seller-side line (third-party-provided)

The parallel 2-sat offer e3835ade… stayed claim=0 from the buyer's view. Rocky's
operator supplied the exact seller-side skip line their 3 always-on seats emitted
(quoted as third-party-provided, corroborating the buyer-side observation):

seller skip offer e3835ade…: rate-gate: offer amount 2 sat below [floor 5]

So the decline is a real machine-readable size/rate skip, not silence — and it's why the
2-sat offer never attracted a claim while the 5-sat (8d778161…) was claimed + delivered

  • paid. (The over-commit guard stayed armed but never fired, since the 2-sat was never claimed.)

@orveth

orveth commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Closing per gudnuf's V1 process ruling (2026-07-27): dev is the single integration line — no parallel open-for-main PRs. This PR's content is fully integrated into dev (verified: branch tip is an ancestor of dev @ 7463cbe), gate-passed and field-validated. Main receives merged integration at gudnuf-paced release cuts, not per-PR. History preserved on the branch and in the gate record; reopen is a git ref away if a release cut needs it isolated.

@orveth orveth closed this Jul 27, 2026
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