Skip to content

mobee-node: persistent daemon shell — exclusive lock, unix-socket RPC, wallet/identity behind the daemon (step 1 of #127) - #131

Merged
orveth merged 2 commits into
mainfrom
feat/mobee-node-daemon-step1
Jul 23, 2026
Merged

mobee-node: persistent daemon shell — exclusive lock, unix-socket RPC, wallet/identity behind the daemon (step 1 of #127)#131
orveth merged 2 commits into
mainfrom
feat/mobee-node-daemon-step1

Conversation

@orveth

@orveth orveth commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Step 1 of the stateful-buyer "mobee node" (#127). Both design plans in the issue converge on one architecture: one persistent per-home daemon owns the wallet, identity, and state; every session is a thin client over a local socket. This PR builds the shell — the boundary — and nothing more.

What step 1 delivers

  1. Exclusive owner (the money-safety keystone). mobee node takes an OS advisory lock on $MOBEE_HOME/node.lock (flock(LOCK_EX | LOCK_NB)). A second daemon on the same home fails closed before it ever opens the wallet — proven live and by test. The lock releases on process exit/crash (no stale-lock reaping).
  2. Local unix socket + JSON-RPC surface. $MOBEE_HOME/node.sock (user-only, 0600), newline-delimited JSON, one request/response per connection. status/health is live. The buyer trade methods (post_job, get_job, accept_claim, authorize_pay) are recognized but return a structured not-implemented error (-32001) rather than silently succeeding — honest step-1 stubs.
  3. Wallet + identity behind the daemon. The daemon exclusively opens the CDK wallet (buyer_fund::open_wallet_async) and holds the Nostr key. Both sit behind serialized in-process actors (one mpsc queue each, concurrency 1) — the queue, not SQLite locking, is the in-process concurrency boundary. The secret key never crosses the socket.
  4. Durable state DB. $MOBEE_HOME/node.sqlite in WAL mode with foreign_keys=ON, synchronous=FULL, bounded busy timeout, and a minimal node_meta/jobs schema. This is the state home the reservation ledger / payment saga / lifecycle build on. rusqlite uses the bundled sqlite so the node needs no system libsqlite3.
  5. Thin-client demo. mobee node status connects to the running daemon and prints its status, holding no wallet/key/state.

Thin-client demo (live, release binary)

$ mobee node            # daemon: creates node.lock (0600), node.sock (srw-------), node.sqlite (+WAL)
mobee node online (home=…/demo-home, socket=…/demo-home/node.sock)

$ mobee node status     # thin client over the socket
{"id":1,"result":{"ok":true,"version":"0.1.0","home":"…/demo-home",
 "pubkey":"e7a8c192…38f3","started_at_unix":1784819762,
 "wallet":{"balance_sats":0,"mint":"https://testnut.cashudevkit.org"},
 "store":{"schema_version":1,"jobs":0}}}

$ mobee node            # second daemon, same home
mobee node: another mobee node already owns this home (lock held: …/node.lock); refusing to start a second owner

$ mobee node status     # after the daemon is killed
no mobee node is listening (Connection refused (os error 111)); start it with `mobee node`

Deliberately deferred to later phases (per the #127 build order)

  • Phase 2 — reservation ledger: jobs/reservations/payment_attempts/outbox tables, BEGIN IMMEDIATE award, uniqueness invariants, transactional outbox.
  • Phase 3 — crash-safe payment saga: single wallet-op slot for proof-changing ops, CDK proof-reservation mapping, exact-token replay, mint reconciliation, PAYMENT_UNCERTAIN.
  • Phase 4 — persistent relay lifecycle: subscription manager, durable cursors, event dedup, inbox/outbox.
  • Phase 5 — auto-award + deterministic verifier + co-signed receipts.
  • Migration of callers — the existing mobee mcp in-process wallet paths are untouched; later phases migrate MCP sessions to be thin clients over this socket. This PR is purely additive.

Design forks resolved (flagged per instructions)

  • Naming = node, not buyer. The plans say buyer.sqlite / mobee-buyerd; I used mobee node, node.lock, node.sock, node.sqlite, and put the core in mobee-core::node — per the issue's symmetry note so a seller service can share the same node core (identity/wallet/relay/lifecycle) later. The daemon file leaves are node.*.
  • Signer actor exposes pubkey only in step 1 (marketplace-event signing is deferred to the phase that publishes awards/receipts); the key is held in-actor and never returned over the socket.
  • Wallet actor step-1 command = balance (a read) to prove exclusive ownership + serialization; proof-changing ops are the phase-3 single-slot work.
  • One request/response per connection (simplest framing that proves the boundary); a multiplexed/notification protocol is a later concern.

Tests

New (crates/mobee-core/src/node/):

  • lock: second acquire fails closed while first is held; releases cleanly.
  • store: opens in WAL, carries schema version + recorded start.
  • wallet_actor: 32 concurrent callers observe peak in-flight = 1 (serialization regression guard) + balance read through the queue.
  • signer: serves pubkey (cached + via actor); never equals the secret.
  • node (integration): status round-trips over the real socket (wallet + store + signer all answer; secret not echoed; socket is 0600); a second node on the same home fails closed at the lock.

Acceptance (all green, exit 0)

cargo build --release                                                                              # GATE1_EXIT=0
cargo test -p mobee-core --no-default-features --features gateway,git-delivery,wallet --release    # GATE2_EXIT=0  (482 passed, 0 failed)
cargo build -p mobee-core --features acp,gateway,git-delivery,wallet --release                     # GATE3_EXIT=0
cargo build -p mobee --features acp,wallet --release                                               # GATE4_EXIT=0

Do not merge — gudnuf reviews.

🤖 Generated with Claude Code

@orveth

orveth commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Feedback — daemon lifecycle UX (for the migration phase)

Step-1 shell looks right, and the flock exclusive-owner keystone is the correct money-safety foundation. One acceptance criterion to lock in before MCP sessions migrate to the socket (Phase 5 / caller migration):

The human must not have to run anything. The MCP client should lazy-start-or-adopt the daemon transparently.

Rationale: today the buyer's agent just has the MCP and gets going — the human runs nothing. Introducing a separate long-lived mobee node process must not regress that into "start the daemon first." The flock already makes this safe: on first tool call the MCP client should attempt to connect to node.sock, and if nothing is listening, spawn mobee node (or adopt an existing owner). A concurrent double-start is a no-op — the second loser fails closed at the lock, exactly as step 1 proves. So the daemon becomes invisible plumbing, not a chore.

Concretely, for the migration PR:

  1. MCP client: connect-or-spawn against node.sock (Connection-refused → launch daemon, retry with bounded backoff).
  2. Daemon: survives the spawning session exiting (detached), so the wallet/identity/state outlive any one agent session.
  3. Acceptance: a fresh home with only the MCP configured (no manual mobee node) completes a full post→award→collect loop, human having run zero daemon commands.

No change requested to this PR's scope — this is the boundary condition for when the shell gets wired to the MCP. Flagging now so the design carries it forward.

Comment thread crates/mobee-core/src/node/mod.rs Outdated
@@ -0,0 +1,357 @@
//! The persistent per-home **mobee node** (step 1 of the stateful-buyer design, #127).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we should just call it "mobee node" especially looking at the file structure. It's not totally clear that this is a buyer kind of thing.

pub mod buyer_fund;
/// Persistent per-home node daemon (exclusive lock, unix-socket RPC, wallet/identity
/// behind serialized actors, durable state DB). See [`node`].
#[cfg(feature = "wallet")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kind of weird IMO that this is beyond the wallet feature. Also I kind of think that the wallet shouldn't even be feature flagged as a buyer. So can you file this as an issue to just reassess our future flags and usage and like I'm not really sure all these feature flags we have are actually doing as much good or maybe we need to rethink the structure between between buyer and seller, but I think buyers should always have a wallet

orveth and others added 2 commits July 23, 2026 09:56
Add `mobee node`: a persistent per-home daemon that is the single owner of
the wallet, Nostr identity, and durable state for a home. Every other process
is a thin, stateless client over its local unix socket.

- Exclusive home lock ($MOBEE_HOME/node.lock) via flock(LOCK_EX|LOCK_NB); a
  second daemon on the same home fails closed before it opens the wallet.
- Local unix socket ($MOBEE_HOME/node.sock, 0600) with a newline-delimited
  JSON-RPC surface: live `status`/`health`; the buyer trade methods are
  recognized but return a structured not-implemented error (deferred).
- Wallet + identity behind serialized in-process actors (single queue each,
  concurrency 1): the daemon exclusively opens the CDK wallet and holds the
  buyer key; the secret never crosses the socket.
- Durable state DB ($MOBEE_HOME/node.sqlite) in WAL mode with FK + FULL sync
  and a minimal node_meta/jobs schema — the home later phases build on.
- Thin client: `mobee node status` routes over the socket and prints daemon
  status, holding no wallet/key/state.

Additive: existing `mobee mcp` buyer/seller flows and their in-process wallet
paths are untouched. The node core lives in mobee-core so a seller service can
share it in a later phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eassessment #133

The per-home daemon added in this PR was named generically "node". Rename it
comprehensively to "buyer" (its actual role, restoring #127's own naming):
- crates/mobee-core/src/node -> crates/mobee-core/src/buyer (mod path + all types:
  NodeContext/NodeError/NodeStore -> Buyer*, node_meta table -> buyer_meta)
- crates/mobee/src/node.rs -> crates/mobee/src/buyer.rs
- runtime leaves node.lock/node.sqlite/node.sock -> buyer.{lock,sqlite,sock}
- CLI subcommand `mobee node[/status]` -> `mobee buyer[/status]` + all help/user strings
- doc comments/test names: node daemon -> buyer daemon

A shared buyer/seller core can be extracted once a seller daemon actually exists;
not generalized preemptively. The wallet/buyer feature-flag structure is under
review in issue #133 (not restructured here).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@orveth
orveth force-pushed the feat/mobee-node-daemon-step1 branch from 9000c02 to 306b62b Compare July 23, 2026 17:04
@orveth
orveth merged commit 9478677 into main Jul 23, 2026
@orveth
orveth deleted the feat/mobee-node-daemon-step1 branch July 23, 2026 17:05
orveth added a commit that referenced this pull request Jul 23, 2026
…rt on pay (#123) (#134)

* buyer: reservation ledger — reserve on award, release on death, convert on pay (#123)

Phase 2 of the buyer daemon (#127), built on the daemon shell's durable store
(#131). Adds a reservation ledger to buyer.sqlite enforcing the symmetric money
invariant:

    available = balance − reserved − spent

- Never over-commit: an award that would push `reserved` past `available` is
  refused atomically (BEGIN IMMEDIATE) with ZERO reserve written.
- Never lock up: a reservation for a job that is no longer payable is released
  so the funds become available again; reconcile-on-restart reclaims stale
  reservations after a crash.

`reserved` sums ONLY `reserved`-state rows, so a collect converting
reserve→spent moves the amount out of the reserved term exactly as the budget
ledger's `spent` term takes it up — never subtracted twice. The budget ledger
(crate::budget) remains the sole spend authority; a `spent`-state row is a label.

Schema bumped to v2 (forward-only, additive `reservations` table). Guard lives
in the store (core), so future auto-award (#126) inherits it.

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

* buyer: fix double-counted spend in reservation available — two-ceiling model (#134)

`compute_available` computed `available = balance − reserved − spent` where
`balance` is the LIVE wallet balance. A completed payment already melted that
ecash, so subtracting cumulative `spent` again double-counted every completed
spend and progressively refused awards the buyer could actually afford as spend
history grew (gudnuf's catch).

Replace with the correct two-ceiling model — the codebase has two independent
ceilings (see budget.rs):

  available = min( wallet_balance − reserved , total_cap − spent − reserved )

- wallet ceiling: physical ecash on hand. `spent` is NOT subtracted (the live
  balance already netted completed melts).
- budget ceiling: policy cap `total_cap` consumed by cumulative `spent` +
  in-flight `reserved`; budget.rs remains the sole spend authority.

In-flight `reserved` consumes BOTH ceilings. i128 intermediates, each ceiling
saturates at 0 (never wraps). `total_cap` threaded through `compute_available`,
`reserve`, and `available`. Refusal now names the binding ceiling (wallet vs
budget) via a new `Ceiling` field on `InsufficientAvailable`.

Docs: module `//!` rewritten to the two-ceiling model; the reserved→spent
ordering obligation on `convert_to_spent` (#126 wiring) now covers BOTH
ceilings (budget spent-append AND wallet melt must land before the flip).

Tests: existing 5 money teeth kept green with corrected expectations; the
available test rewritten to exercise both ceilings; added a red-on-revert
regression for the exact double-count bug (affordable award wrongly refused by
the old formula) and a test that the budget ceiling still bites when the wallet
could cover.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants