buyer: MCP connect-or-spawn — daemon-owned money path + 2-call trade loop - #166
buyer: MCP connect-or-spawn — daemon-owned money path + 2-call trade loop#166orveth wants to merge 3 commits into
Conversation
…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>
Gate evidence (rebased onto main a7a3044)All acceptance gates green on the rebased tree:
#152 verified in a DEBUG build (release masks it — see the PR body caveat)
and is GREEN in debug after routing the verify-fetch through Live testnut dogfoodThrowaway buyer home in-workspace is funded (100 testnut @ testnut.cashudevkit.org), |
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 Buyer The loop (zero human action between post and collect)
The invariants, observed live
Cost: 5 sats testnut (within the approved 5-sat ceiling). Both sides of the trade exercised. |
Rate-gate decline — seller-side line (third-party-provided)The parallel 2-sat offer So the decline is a real machine-readable size/rate skip, not silence — and it's why the
|
|
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 |
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_job→collect) 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 inbuyer.sqlite, and spawns a background task that waits (bounded by the offer deadline) for a payable claim, then reserves-then-awards it.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).award_claimremains 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, spawnmobee buyer servedetached (own process group,MOBEE_HOMEpinned 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.mcp.rs) forwardspost_job/get_job/collect/award_claimover the socket; the in-processBudgetGate/collect/job_lifecyclemoney 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_satson manual award — was dead input; now applied vialifecycle::named_claim_awardable(OverMax / NotFound / NotLive / Unpayable), the manual counterpart ofselect_awardable_claim.reconcile_on_startno longer discards itsReconcileReport; it's kept on the daemon and shown instatus.reconcile{released, converted, kept}. Parked auto-awards show instatus.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.
buyer::collectRPC →lifecycle::settle_after_pay→store.convert_to_spent. Reached ONLY aftercollect::collect_async(budget append viaauthorize_then_attempt+ wallet melt insiderun_verified) returnsClosed. Pay-first, flip-after. (unchanged from buyer daemon: auto-award + live reservation/collect wiring (#126) #136; tooth:settle_flips_only_after_pay_succeeds.)buyer::reconcile_on_start→store.reconcileconvertsPaidjobs.Paidis derived ONLY from a journalClosedstate (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:
awardRPC (manual + auto) →lifecycle::award_with_reservation(reserve FIRST, publish second; refused reserve writes nothing/publishes nothing).drive_auto_award(background) /finalize_auto_award→ the SAMEaward_with_reservation. No award path bypasses the reserve.Background auto-award — the two invariants (both tested)
lifecycle::plan_rearm(award_on_relay, reservation): Skip if a buyer AWARD (kind-3405) is already on the relay OR the reservation is alreadySpent; otherwise Attempt. AReserved-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_awards→spawn_auto_award) re-checks the relay first, so it never double-awards. Tooth:plan_rearm_skips_only_when_already_awarded.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
BudgetGatevs the daemon's reservation ledger — as its top architecture finding. This PR resolves it in the daemon's favor: the MCP holds noBudgetGate; 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--releasetest run. The guard is only live under a debugcargo test.blocking_request_runs_off_the_async_runtimeandcollect_refuses_pay_when_delivered_tip_differs_from_bound_oidcatch it there; a release-only CI green must NOT be read as #152 coverage.Fixes folded in
collectwas 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).reqwest::blocking) runs synchronously insideauthorize_pay_async(a Tokio worker). Fix: the verify-fetch git2 operations (GitDeliveryVerifier::fetch/fetch_base) now run on a dedicated OS thread viagit_transport::off_runtime, so no ambient runtime is present (works under any caller runtime flavor, unlikeblock_in_place). Regression tooth:blocking_request_runs_off_the_async_runtime(drives a real blocking request from inside a#[tokio::test]viaoff_runtime; red-on-revert = call.send()directly → panics in debug). Verified:collect_refuses_pay_when_delivered_tip_differs_from_bound_oidnow passes in a DEBUG build (it panicked before).Gates (rc=0)
cargo test -p mobee-core --no-default-features --features gateway,git-delivery,wallet --releasecargo build -p mobee-core --features acp --releasecargo test -p mobee-core --features acp --releasecargo build -p mobee --releasecargo test -p mobee --release(integration:mcp_daemon— connect-or-spawn + money-op-served-by-daemon)#152verified in DEBUG.🤖 Generated with Claude Code