This commit is contained in:
Dobromir Popov
2026-06-30 21:19:49 +02:00
15 changed files with 870 additions and 40 deletions

View File

@@ -0,0 +1,20 @@
# US-020 — Tracker + node hardening: BrokenPipe fix, deterministic node IDs, HF coverage
Status: done
Priority: High
Stage: Maintenance
## Context
First two-machine LAN test (US-018) exposed three reliability issues:
1. `BrokenPipeError` crash in tracker `_send_json` when a slow-inference client disconnected mid-response
2. Random UUID node IDs meant every re-registration (after tracker restart) created a phantom entry
3. `GET /v1/coverage/<model>` returned no results when called with a short name (`Qwen2.5-0.5B`) instead of the full HF repo ID
## Acceptance criteria
- [ ] `BrokenPipeError` in tracker and node `_send_json` is silently swallowed
- [ ] Node IDs are deterministic: `sha256(wallet_address + str(port))[:16]`
- [ ] `GET /v1/coverage/<model>` accepts both short names and full `owner/repo` IDs
- [ ] `python -m pytest` passes from repo root

View File

@@ -0,0 +1,19 @@
# US-021 — `--route-timeout` CLI flag for node tracker route lookup
Status: done
Priority: Medium
Stage: Implemented
## Context
The node's slow-path tracker route lookup (`/v1/route`) used a hard-coded 30-second HTTP timeout.
On high-latency links (relay, satellite, 5G) or when the tracker is under load, legitimate route
lookups were failing prematurely. The timeout is deployment-specific and should be tunable.
## Acceptance criteria
- [ ] `meshnet-node start` accepts `--route-timeout <seconds>` (float, default 30.0)
- [ ] Value is passed through to `TorchNodeServer` and used in the `/v1/route` HTTP call
- [ ] `TorchNodeServer` exposes `route_timeout` as a readable property
- [ ] Test: setting `--route-timeout 45` is reflected as `45.0` on the running server object
- [ ] `python -m pytest` passes

View File

@@ -0,0 +1,29 @@
# US-022 — X-Meshnet-Start-Layer: overlapping shard execution protocol
Status: done
Priority: High
Stage: Implemented
## Context
Two nodes may register overlapping shard ranges (node A: 015, node B: 1223) to increase
redundancy or to enable partial-model nodes to form a complete route. Without coordination,
node B would re-run layers 1215 on top of activations that already include them, producing
wrong output.
The `X-Meshnet-Start-Layer` header tells each downstream node which model layer the incoming
activation tensor represents, so the node skips layers it has already been told to skip.
## Decision
Option A: tracker injects `start_layer` into `X-Meshnet-Route` hops at proxy time. The head
node passes it per-hop as `X-Meshnet-Start-Layer`. No peer-to-peer negotiation needed.
## Acceptance criteria
- [ ] Tracker `_handle_proxy_chat` builds route hops with `start_layer` = `covered_up_to + 1`
- [ ] `_handle_binary_forward` reads `X-Meshnet-Start-Layer` and passes it to `backend.forward_bytes`
- [ ] `_get_remaining_route` parses `start_layer` from injected header and from `/v1/route` slow-path
- [ ] `TorchModelShard.forward_bytes` accepts optional `start_layer` and skips layers below it
- [ ] Test: overlapping two-node route produces correct output without double-computing layers
- [ ] `python -m pytest` passes

View File

@@ -0,0 +1,26 @@
# US-023 — Heartbeat stats payload: request counters + dynamic reassignment response
Status: done
Priority: Medium
Stage: Implemented
## Context
Node heartbeats are currently empty POSTs. The tracker has no visibility into per-node load,
making load balancing and assignment decisions blind. Heartbeats should carry cumulative stats.
The heartbeat response channel is also the natural place for the tracker to deliver reassignment
instructions without requiring a node restart.
## Acceptance criteria
- [ ] Heartbeat POST body includes: `total_requests`, `failed_requests`, `queue_depth`, `uptime_seconds`, `status`
- [ ] `TorchNodeServer` tracks the three counters with a `threading.Lock`
- [ ] Tracker stores the last heartbeat payload per node
- [ ] Heartbeat response may include `new_assignment: {model, shard_start, shard_end}`; node logs it
- [ ] Stats survive tracker outage: buffered locally, flushed on next successful heartbeat
- [ ] `python -m pytest` passes
## Notes
Hot-reload (loading a new shard without restart) is deferred to a future story. The response
field is wired so trackers can send the signal; nodes log it but don't act yet.

View File

@@ -0,0 +1,24 @@
# US-024 — Enhanced availability map with per-node health details
Status: done
Priority: Medium
Stage: Implemented
## Context
`GET /v1/coverage/<model>` returns band-level coverage (start_layer, end_layer, node_count)
but gives no visibility into which specific nodes are in each band or whether they are alive.
Operators debugging a split-model deployment need to see node-level health at a glance.
## Format decision
Both: band metadata + node list grouped under each band. Dead nodes included with `healthy: false`
so operators can see them.
## Acceptance criteria
- [ ] Each band in the response includes `nodes: [{node_id, endpoint, healthy, queue_depth, last_seen_s}]`
- [ ] `healthy` is `true` iff last heartbeat < `heartbeat_timeout` seconds ago
- [ ] Existing band fields (`start_layer`, `end_layer`, `node_count`) preserved
- [ ] Tests updated to check band fields individually (not exact dict comparison)
- [ ] `python -m pytest` passes

View File

@@ -0,0 +1,30 @@
# US-025 — Model usage statistics: rolling RPM windows + SQLite persistence
Status: done
Priority: Medium
Stage: Implemented
## Context
Trackers need per-model request-rate data to drive smart shard assignment (US-026) and
to give operators visibility into what the network is actually serving. Stats must survive
tracker restarts and should be shareable across a tracker cluster.
## Design
- `_RollingCounter`: circular-bucket counter, epoch-indexed, stale buckets auto-reset
- Three windows: 60×1-min buckets (last hour), 24×1-hr (last day), 30×1-day (last month)
- `_StatsCollector`: `record_request()`, `get_local_rpms()`, `merge_peer_rpms()`, `get_combined_stats()`
- SQLite persistence via `--stats-db PATH`
- Gossip: each tracker keeps its own slice; merge is additive (not averaged)
## Acceptance criteria
- [ ] `_RollingCounter` passes unit tests (record, rpm, stale bucket reset)
- [ ] `_StatsCollector` accumulates and merges peer slices additively
- [ ] SQLite round-trip: buckets saved and restored across restart
- [ ] `GET /v1/stats` returns combined stats JSON
- [ ] `POST /v1/stats/gossip` accepts peer slice and merges
- [ ] `_handle_proxy_chat` calls `stats.record_request()` after model is resolved
- [ ] `--stats-db PATH` CLI flag added to tracker
- [ ] 6 unit tests pass; `python -m pytest` passes

View File

@@ -0,0 +1,32 @@
# US-026 — Smart model assignment via demand×coverage scoring
Status: done
Priority: Medium
Stage: Implemented
## Context
`/v1/network/assign` currently picks the model with the largest uncovered shard gap,
ignoring traffic. A model serving 1000 RPM at 60% coverage is far more valuable to fill
than a zero-traffic model at 50% coverage.
## Scoring formula
```
score = (demand_rpm + 1.0) × (coverage_deficit + 0.01)
```
- `demand_rpm`: combined RPM from `_StatsCollector.get_combined_stats()`
- `coverage_deficit`: fraction of model layers with zero node coverage, in [0.0, 1.0]
- `+1.0` floor: models with no traffic still compete by coverage
- `+0.01` floor: fully-covered models still have a non-zero score if they have traffic
`price_per_token: 0.0` reserved in the response for future billing integration.
## Acceptance criteria
- [ ] `_handle_network_assign` computes score per model and returns the highest
- [ ] Demand uses combined stats (local + peer slices)
- [ ] `price_per_token: 0.0` present in response
- [ ] Test: high-demand low-coverage model beats low-demand high-coverage model
- [ ] `python -m pytest` passes

View File

@@ -0,0 +1,28 @@
# US-027 — Throughput-optimized routing: effective throughput as tiebreak
Status: done
Priority: Medium
Stage: Implemented
## Context
The greedy max-reach route selection picks nodes by shard coverage but ignores node speed.
When two nodes cover the same remaining layer range, we should prefer the faster one.
This is a tiebreak only — coverage maximization remains the primary objective.
## Effective throughput formula
```
effective_throughput = benchmark_tokens_per_sec / (queue_depth + 1)
```
`benchmark_tokens_per_sec` comes from the hardware profile at registration time.
`queue_depth` comes from the last heartbeat.
## Acceptance criteria
- [ ] `_effective_throughput(node)` helper in `server.py`
- [ ] `_select_route` uses throughput as tiebreak when `shard_end` is equal
- [ ] Test: two nodes, same shard range, different throughput → faster node selected
- [ ] Existing coverage tests still pass unchanged
- [ ] `python -m pytest` passes

View File

@@ -0,0 +1,26 @@
# US-028 — Routing correctness tests: three-node, overlap, and throughput scenarios
Status: done
Priority: Medium
Stage: Implemented
## Context
Route selection logic (`_select_route`) is the core of the inference network. Without
locked-down tests, routing regressions are silent. This story adds a comprehensive
scenario suite that locks in the routing contract.
## Test scenarios
1. **No-overlap three nodes**: greedy picks in layer-start order (A→C→B for ranges 07, 815, 1623)
Note: the algorithm picks by earliest uncovered layer, not by node label — so if C.shard_start < B.shard_start, C comes first.
2. **Overlapping shards**: correct resolution without double-computing layers
3. **Throughput tiebreak**: faster node wins when shard_end is equal
4. **Gap detection**: partial coverage returns a 503 error with description
## Acceptance criteria
- [ ] 7 routing tests covering the above scenarios
- [ ] Tests use in-process tracker (no mocking)
- [ ] `_make_node` helper for concise test setup
- [ ] All tests pass; `python -m pytest` passes

View File

@@ -0,0 +1,44 @@
# US-029 — Outbound relay client: NAT/internet pipeline hops
Status: done
Priority: Critical
Stage: Implemented
## Context
Nodes behind NAT (WSL2 with 172.x.x.x addresses, 5G mobile, home routers) register with the
tracker and include a `relay_addr` (`wss://relay/rpc/{peer_id}`). When the head node needs to
forward activations to such a peer, it currently fails because the direct HTTP endpoint is
unreachable.
The relay server (US-017) is already running and the node already opens a persistent outbound
WebSocket (`RelayHttpBridge`). What is missing is the *outbound caller side*: given a `relay_addr`,
open a per-hop WebSocket to the relay's `/rpc/{peer_id}` endpoint and send the activation through it.
## Protocol
```
Node A → WS connect wss://relay/rpc/{peer_id_B}
→ send JSON: {request_id, method, path, headers, body_base64}
Relay → forward as relay-http-request envelope to Node B's persistent WS
Node B → process /forward locally
→ send relay-http-response envelope back
Relay → resolve future, send response JSON to Node A
Node A ← {request_id, status, headers, body_base64}
```
Binary activations (bfloat16) are base64-encoded. No precision loss.
## Acceptance criteria
- [ ] `_relay_hop(relay_addr, path, body, headers, timeout)` in `torch_server.py` — opens WS, sends, receives, returns `(status, headers_lower, body_bytes)`
- [ ] `_get_remaining_route` returns `list[dict]` with `relay_addr` field (was `list[tuple]`)
- [ ] `_run_downstream_pipeline` dispatches via `_relay_hop` when hop has `relay_addr`; falls back to direct HTTP if relay connection fails
- [ ] Tracker `_handle_proxy_chat` includes `relay_addr` in downstream hop dicts when node has one
- [ ] `relay_bridge._handle_request` decodes `body_base64`; response uses `body_base64` for `octet-stream` content
- [ ] All 157 tests pass (`python -m pytest`)
- [ ] QUICKSTART.md updated with relay NAT/internet architecture and test scenario
## WSL2 test scenario
Start two nodes in WSL2 pointing at the public tracker. Both get `172.x.x.x` endpoints (unreachable from outside). Both connect to relay automatically. Send an inference request through the tracker — activations flow via relay. No `--advertise-host` needed.

View File

@@ -5,8 +5,8 @@
"userStories": [ "userStories": [
{ {
"id": "US-001", "id": "US-001",
"title": "01 \u2014 Monorepo scaffold + single-node smoke test", "title": "01 Monorepo scaffold + single-node smoke test",
"description": "Stand up the monorepo package layout and prove that a client HTTP request can travel end-to-end through the stack \u2014 gateway \u2192 one node serving all layers \u2192 valid response \u2014 before any real model or distributed logic is wired in. The six top-level packages should exist with minimal stubs: - `packages/node` \u2014 node client CLI (`meshnet-node`) - `packages/gateway` \u2014 HTTP gateway + route orchestration - `packages/tracker` \u2014 node registry and route selection - `packages/sdk` \u2014 `meshnet` Python SDK - `packages/contracts` \u2014 Solana smart contract wrappers - `packages/p2p` \u2014 gossip and shard swarm The smoke test uses a tiny in-process stub model (not a real LLM) so the test is fast and has no external dependencies. The node runs in the same process as the test. The gateway is a real HTTP server. The client sends a real `POST /v1/chat/completions` request and receives a valid OpenAI-format response.", "description": "Stand up the monorepo package layout and prove that a client HTTP request can travel end-to-end through the stack gateway one node serving all layers valid response before any real model or distributed logic is wired in. The six top-level packages should exist with minimal stubs: - `packages/node` node client CLI (`meshnet-node`) - `packages/gateway` HTTP gateway + route orchestration - `packages/tracker` node registry and route selection - `packages/sdk` `meshnet` Python SDK - `packages/contracts` Solana smart contract wrappers - `packages/p2p` gossip and shard swarm The smoke test uses a tiny in-process stub model (not a real LLM) so the test is fast and has no external dependencies. The node runs in the same process as the test. The gateway is a real HTTP server. The client sends a real `POST /v1/chat/completions` request and receives a valid OpenAI-format response.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"`pip install -e packages/node packages/gateway packages/tracker` works from repo root", "`pip install -e packages/node packages/gateway packages/tracker` works from repo root",
"`meshnet-node`, `meshnet-gateway`, and `meshnet-tracker` CLI entry points exist", "`meshnet-node`, `meshnet-gateway`, and `meshnet-tracker` CLI entry points exist",
@@ -28,14 +28,14 @@
}, },
{ {
"id": "US-002", "id": "US-002",
"title": "02 \u2014 Two-node shard pipeline", "title": "02 Two-node shard pipeline",
"description": "Extend the single-node smoke test so that inference is routed through exactly two nodes, each serving half of a stub model's layers. Activation tensors must travel from the gateway to node A, then from node A to node B, and the final output returns to the gateway. This proves the distributed shard pipeline works end-to-end before real models or a real tracker are introduced. The two nodes run in the same process as the test (no real network required). The gateway hardcodes the two-node route for now \u2014 dynamic route selection comes in issue 03. The activation tensor protocol (shape, dtype, serialisation format) must be established here, as all later issues depend on it. Use the domain vocabulary from `CONTEXT.md`: the ordered list of nodes is an **inference route**; each node's layer range is a **shard**; the tensors passed between nodes are activations (not \"data\" or \"chunks\").", "description": "Extend the single-node smoke test so that inference is routed through exactly two nodes, each serving half of a stub model's layers. Activation tensors must travel from the gateway to node A, then from node A to node B, and the final output returns to the gateway. This proves the distributed shard pipeline works end-to-end before real models or a real tracker are introduced. The two nodes run in the same process as the test (no real network required). The gateway hardcodes the two-node route for now dynamic route selection comes in issue 03. The activation tensor protocol (shape, dtype, serialisation format) must be established here, as all later issues depend on it. Use the domain vocabulary from `CONTEXT.md`: the ordered list of nodes is an **inference route**; each node's layer range is a **shard**; the tensors passed between nodes are activations (not \"data\" or \"chunks\").",
"acceptanceCriteria": [ "acceptanceCriteria": [
"The integration test spins up two stub nodes, each configured with a non-overlapping shard range", "The integration test spins up two stub nodes, each configured with a non-overlapping shard range",
"A `POST /v1/chat/completions` request results in activation tensors flowing node-A \u2192 node-B (verifiable via test assertions or logs)", "A `POST /v1/chat/completions` request results in activation tensors flowing node-A node-B (verifiable via test assertions or logs)",
"The gateway assembles the final response correctly from node-B's output", "The gateway assembles the final response correctly from node-B's output",
"The test passes with no external services running", "The test passes with no external services running",
"The activation tensor serialisation format is documented in a short inline comment (shape, dtype, wire format) \u2014 this becomes the contract all future nodes implement", "The activation tensor serialisation format is documented in a short inline comment (shape, dtype, wire format) this becomes the contract all future nodes implement",
"Follow TDD where code is added: add/adjust an observable behavior test before implementation when practical", "Follow TDD where code is added: add/adjust an observable behavior test before implementation when practical",
"Run the task-specific tests and ensure they pass", "Run the task-specific tests and ensure they pass",
"Run `python -m pytest` from repo root and ensure it passes, or document why no test suite exists yet", "Run `python -m pytest` from repo root and ensure it passes, or document why no test suite exists yet",
@@ -54,8 +54,8 @@
}, },
{ {
"id": "US-003", "id": "US-003",
"title": "03 \u2014 Tracker: node registration + route selection", "title": "03 Tracker: node registration + route selection",
"description": "Replace the hardcoded two-node route from issue 02 with a real tracker. Nodes register themselves with the tracker on startup (endpoint, shard range, hardware profile, node score). The gateway queries the tracker for an inference route for a given model preset instead of using a static list. The tracker returns an ordered list of node endpoints whose shards collectively cover all layers. The tracker runs as a lightweight HTTP service. Node score is initially a simple placeholder (e.g. fixed value or random) \u2014 real throughput/latency scoring comes later. The tracker must correctly reject route requests when no registered nodes cover a required shard range and return an appropriate error. This issue establishes the tracker's registration and routing API contract, which issues 04 (node startup) and 05 (OpenAI gateway) both depend on.", "description": "Replace the hardcoded two-node route from issue 02 with a real tracker. Nodes register themselves with the tracker on startup (endpoint, shard range, hardware profile, node score). The gateway queries the tracker for an inference route for a given model preset instead of using a static list. The tracker returns an ordered list of node endpoints whose shards collectively cover all layers. The tracker runs as a lightweight HTTP service. Node score is initially a simple placeholder (e.g. fixed value or random) real throughput/latency scoring comes later. The tracker must correctly reject route requests when no registered nodes cover a required shard range and return an appropriate error. This issue establishes the tracker's registration and routing API contract, which issues 04 (node startup) and 05 (OpenAI gateway) both depend on.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"A node can register with the tracker via HTTP, providing its endpoint, shard range, and a hardware profile", "A node can register with the tracker via HTTP, providing its endpoint, shard range, and a hardware profile",
"The gateway queries the tracker with a model preset name and receives an ordered list of node endpoints forming a complete inference route", "The gateway queries the tracker with a model preset name and receives an ordered list of node endpoints forming a complete inference route",
@@ -80,8 +80,8 @@
}, },
{ {
"id": "US-004", "id": "US-004",
"title": "04 \u2014 Node client startup flow (`meshnet-node start`)", "title": "04 Node client startup flow (`meshnet-node start`)",
"description": "Make `meshnet-node start` self-configuring from scratch on a machine with a CUDA-capable GPU (or CPU fallback). The full startup sequence must complete without any manual configuration: 1. Detect GPU model and available VRAM 2. Load an existing Solana wallet from disk, or generate and save a new one 3. Query the tracker for the optimal shard assignment given the hardware profile 4. Download the assigned shard from HuggingFace (`huggingface_hub`) 5. Register with the tracker (wallet address, endpoint, shard range, hardware profile) 6. Begin accepting inference connections The node prints a short status summary on startup: wallet address, assigned shard, model preset, and download progress. No interactive prompts \u2014 the entire flow is non-interactive. This is the primary viral growth vector. Every second of friction in this flow costs node operators. The startup sequence must work on Linux with CUDA, and degrade gracefully to CPU on machines without a GPU.", "description": "Make `meshnet-node start` self-configuring from scratch on a machine with a CUDA-capable GPU (or CPU fallback). The full startup sequence must complete without any manual configuration: 1. Detect GPU model and available VRAM 2. Load an existing Solana wallet from disk, or generate and save a new one 3. Query the tracker for the optimal shard assignment given the hardware profile 4. Download the assigned shard from HuggingFace (`huggingface_hub`) 5. Register with the tracker (wallet address, endpoint, shard range, hardware profile) 6. Begin accepting inference connections The node prints a short status summary on startup: wallet address, assigned shard, model preset, and download progress. No interactive prompts the entire flow is non-interactive. This is the primary viral growth vector. Every second of friction in this flow costs node operators. The startup sequence must work on Linux with CUDA, and degrade gracefully to CPU on machines without a GPU.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"`meshnet-node start --tracker http://localhost:8080` completes startup without prompts on a machine with a CUDA GPU", "`meshnet-node start --tracker http://localhost:8080` completes startup without prompts on a machine with a CUDA GPU",
"A Solana wallet keypair is generated and saved to `~/.config/meshnet/wallet.json` if none exists", "A Solana wallet keypair is generated and saved to `~/.config/meshnet/wallet.json` if none exists",
@@ -107,8 +107,8 @@
}, },
{ {
"id": "US-005", "id": "US-005",
"title": "05 \u2014 OpenAI-compatible gateway", "title": "05 OpenAI-compatible gateway",
"description": "Expose a production-shape HTTP API from the gateway so that any client using the OpenAI Python SDK (or any OpenAI-compatible tool) works by changing only the `base_url`. The gateway translates OpenAI-format requests into inference route execution and streams responses back in OpenAI-format. Endpoints to implement: - `POST /v1/chat/completions` \u2014 streaming (`text/event-stream`) and non-streaming - `GET /v1/models` \u2014 returns the list of model presets currently routable on the network - `GET /v1/health` \u2014 liveness check The gateway selects an inference route from the tracker, executes the shard pipeline, and assembles the streamed response. If the tracker returns no route for the requested model, the gateway responds with a standard OpenAI-format error (`model_not_available`). Authentication (API key \u2192 SOL/USDC balance) is a stub in this issue \u2014 return 200 for any non-empty `Authorization` header. Real payment gating comes in issue 06.", "description": "Expose a production-shape HTTP API from the gateway so that any client using the OpenAI Python SDK (or any OpenAI-compatible tool) works by changing only the `base_url`. The gateway translates OpenAI-format requests into inference route execution and streams responses back in OpenAI-format. Endpoints to implement: - `POST /v1/chat/completions` streaming (`text/event-stream`) and non-streaming - `GET /v1/models` returns the list of model presets currently routable on the network - `GET /v1/health` liveness check The gateway selects an inference route from the tracker, executes the shard pipeline, and assembles the streamed response. If the tracker returns no route for the requested model, the gateway responds with a standard OpenAI-format error (`model_not_available`). Authentication (API key SOL/USDC balance) is a stub in this issue return 200 for any non-empty `Authorization` header. Real payment gating comes in issue 06.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"`openai.OpenAI(base_url=\"http://localhost:8080/v1\", api_key=\"test\").chat.completions.create(model=\"stub-model\", messages=[...])` returns a valid response", "`openai.OpenAI(base_url=\"http://localhost:8080/v1\", api_key=\"test\").chat.completions.create(model=\"stub-model\", messages=[...])` returns a valid response",
"Streaming works: `stream=True` returns `text/event-stream` chunks in OpenAI SSE format", "Streaming works: `stream=True` returns `text/event-stream` chunks in OpenAI SSE format",
@@ -130,12 +130,12 @@
], ],
"completionNotes": "All 6 gateway tests pass including OpenAI SDK, LangChain, streaming, and 503 for unknown model.", "completionNotes": "All 6 gateway tests pass including OpenAI SDK, LangChain, streaming, and 503 for unknown model.",
"status": "done", "status": "done",
"status_reason": "Gateway is currently the pipeline orchestrator. US-014 moves orchestration to tracker-nodes; gateway becomes a thin load-balancer proxy. Implementation will be superseded \u2014 defer rework until US-014 lands." "status_reason": "Gateway is currently the pipeline orchestrator. US-014 moves orchestration to tracker-nodes; gateway becomes a thin load-balancer proxy. Implementation will be superseded defer rework until US-014 lands."
}, },
{ {
"id": "US-006", "id": "US-006",
"title": "06 \u2014 Solana stake + settlement contracts", "title": "06 Solana stake + settlement contracts",
"description": "Deploy and integrate the Solana smart contracts that make node staking, client payment, and token reward settlement trustless. All development and testing targets **Solana testnet** \u2014 never devnet or mainnet during development, to avoid real costs.", "description": "Deploy and integrate the Solana smart contracts that make node staking, client payment, and token reward settlement trustless. All development and testing targets **Solana testnet** never devnet or mainnet during development, to avoid real costs.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"All contracts deploy successfully to Solana testnet", "All contracts deploy successfully to Solana testnet",
"A node can submit a stake transaction and have its balance reflected in the registry contract", "A node can submit a stake transaction and have its balance reflected in the registry contract",
@@ -143,7 +143,7 @@
"After a completed inference session, compute attribution is recorded on-chain with correct node/layer attribution", "After a completed inference session, compute attribution is recorded on-chain with correct node/layer attribution",
"The epoch settlement transaction correctly distributes token rewards to node operators and deducts client balances", "The epoch settlement transaction correctly distributes token rewards to node operators and deducts client balances",
"The gateway refuses to route to a node whose stake balance is below the minimum threshold", "The gateway refuses to route to a node whose stake balance is below the minimum threshold",
"All contract interactions in tests run against a local Solana test validator (via `solana-test-validator`) \u2014 no live testnet required for CI", "All contract interactions in tests run against a local Solana test validator (via `solana-test-validator`) no live testnet required for CI",
"A `.env.testnet` config points to Solana testnet RPC for manual end-to-end testing", "A `.env.testnet` config points to Solana testnet RPC for manual end-to-end testing",
"python -m pytest passes from repo root", "python -m pytest passes from repo root",
"Commit only this story's changes" "Commit only this story's changes"
@@ -159,7 +159,7 @@
}, },
{ {
"id": "US-007", "id": "US-007",
"title": "07 \u2014 Fraud detection: validator + on-chain slash", "title": "07 Fraud detection: validator + on-chain slash",
"description": "Implement the optimistic fraud detection loop. After each inference request completes, the validator process independently decides whether to re-run the request on a reference node (~5% sample rate).", "description": "Implement the optimistic fraud detection loop. After each inference request completes, the validator process independently decides whether to re-run the request on a reference node (~5% sample rate).",
"acceptanceCriteria": [ "acceptanceCriteria": [
"The validator process samples ~5% of completed inference requests (configurable)", "The validator process samples ~5% of completed inference requests (configurable)",
@@ -184,7 +184,7 @@
}, },
{ {
"id": "US-008", "id": "US-008",
"title": "08 \u2014 Node probationary period + ban enforcement", "title": "08 Node probationary period + ban enforcement",
"description": "Implement the two anti-sybil mechanisms that make re-entering the network after a ban economically costly.", "description": "Implement the two anti-sybil mechanisms that make re-entering the network after a ban economically costly.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"A node with a new wallet receives no token rewards for its first N jobs (verified via settlement contract state)", "A node with a new wallet receives no token rewards for its first N jobs (verified via settlement contract state)",
@@ -193,7 +193,7 @@
"A wallet with strike count at the threshold is marked banned in the registry contract", "A wallet with strike count at the threshold is marked banned in the registry contract",
"The tracker excludes banned wallets from route selection", "The tracker excludes banned wallets from route selection",
"A banned wallet that attempts to register with the tracker is rejected", "A banned wallet that attempts to register with the tracker is rejected",
"An integration test covers: new wallet \u2192 N jobs \u2192 earning begins; and: strike threshold reached \u2192 banned \u2192 excluded from routes", "An integration test covers: new wallet N jobs earning begins; and: strike threshold reached banned excluded from routes",
"python -m pytest passes from repo root", "python -m pytest passes from repo root",
"Commit only this story's changes" "Commit only this story's changes"
], ],
@@ -208,7 +208,7 @@
}, },
{ {
"id": "US-009", "id": "US-009",
"title": "09 \u2014 P2P shard swarm", "title": "09 P2P shard swarm",
"description": "Once a node has downloaded a shard, it seeds that shard to other nodes that are assigned the same shard.", "description": "Once a node has downloaded a shard, it seeds that shard to other nodes that are assigned the same shard.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"A node that has downloaded a shard is listed as a peer for that shard in the tracker", "A node that has downloaded a shard is listed as a peer for that shard in the tracker",
@@ -231,7 +231,7 @@
}, },
{ {
"id": "US-010", "id": "US-010",
"title": "10 \u2014 `meshnet` Python SDK", "title": "10 `meshnet` Python SDK",
"description": "A Python SDK (`packages/sdk`) that wraps the OpenAI-compatible gateway and exposes network-specific controls.", "description": "A Python SDK (`packages/sdk`) that wraps the OpenAI-compatible gateway and exposes network-specific controls.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"`pip install meshnet` installs the SDK", "`pip install meshnet` installs the SDK",
@@ -258,7 +258,7 @@
}, },
{ {
"id": "US-011", "id": "US-011",
"title": "11 \u2014 Binary activation wire format", "title": "11 Binary activation wire format",
"description": "Replace the base64 JSON activation payload with raw binary HTTP bodies, zstd compression, and chunked prefill (128 tokens/chunk). All nodes and the gateway must be migrated. Stub nodes continue to emit zeroed tensors, just in binary. This is a protocol prerequisite for US-012 (real model backend).", "description": "Replace the base64 JSON activation payload with raw binary HTTP bodies, zstd compression, and chunked prefill (128 tokens/chunk). All nodes and the gateway must be migrated. Stub nodes continue to emit zeroed tensors, just in binary. This is a protocol prerequisite for US-012 (real model backend).",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Node /forward endpoint reads shape/dtype/session/chunk from HTTP headers; body is raw binary (optionally zstd-compressed)", "Node /forward endpoint reads shape/dtype/session/chunk from HTTP headers; body is raw binary (optionally zstd-compressed)",
@@ -281,7 +281,7 @@
}, },
{ {
"id": "US-012", "id": "US-012",
"title": "12 \u2014 Real PyTorch model backend", "title": "12 Real PyTorch model backend",
"description": "Replace stub node inference with actual transformers layer execution. Node loads HuggingFace SafeTensors model shard (model.model.layers[start:end]), runs real forward passes in bfloat16, handles head (embed_tokens) and tail (lm_head) responsibilities, and supports bitsandbytes NF4/INT8/bfloat16 quantization via --quantization flag. Test model: openai-community/gpt2.", "description": "Replace stub node inference with actual transformers layer execution. Node loads HuggingFace SafeTensors model shard (model.model.layers[start:end]), runs real forward passes in bfloat16, handles head (embed_tokens) and tail (lm_head) responsibilities, and supports bitsandbytes NF4/INT8/bfloat16 quantization via --quantization flag. Test model: openai-community/gpt2.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"meshnet-node start --model-id openai-community/gpt2 --shard-start 0 --shard-end 6 loads the shard without error", "meshnet-node start --model-id openai-community/gpt2 --shard-start 0 --shard-end 6 loads the shard without error",
@@ -306,7 +306,7 @@
}, },
{ {
"id": "US-013", "id": "US-013",
"title": "13 \u2014 Coverage-first tracker shard assignment", "title": "13 Coverage-first tracker shard assignment",
"description": "Upgrade tracker route selection to coverage-first, speed-weighted bin-packing. Tracker maintains a live coverage map per model, issues LOAD_SHARD/DROP_SHARD rebalance directives when coverage drops, and assigns shard ranges using declared VRAM, quantization, and benchmark throughput. A model is only routable when all layer ranges have node_count >= 1.", "description": "Upgrade tracker route selection to coverage-first, speed-weighted bin-packing. Tracker maintains a live coverage map per model, issues LOAD_SHARD/DROP_SHARD rebalance directives when coverage drops, and assigns shard ranges using declared VRAM, quantization, and benchmark throughput. A model is only routable when all layer ranges have node_count >= 1.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Node registration accepts vram_bytes, ram_bytes, quantizations[], benchmark_tokens_per_sec", "Node registration accepts vram_bytes, ram_bytes, quantizations[], benchmark_tokens_per_sec",
@@ -317,7 +317,7 @@
"Shard assignment respects VRAM: assigned_layers <= floor((vram_bytes * 0.8) / bytes_per_layer_at_quant)", "Shard assignment respects VRAM: assigned_layers <= floor((vram_bytes * 0.8) / bytes_per_layer_at_quant)",
"Faster node receives wider shard range when both can cover the same gap", "Faster node receives wider shard range when both can cover the same gap",
"Integration test: three nodes with different VRAM show widest range on largest-VRAM node with 100% coverage", "Integration test: three nodes with different VRAM show widest range on largest-VRAM node with 100% coverage",
"Integration test: kill middle-range node \u2192 tracker issues LOAD_SHARD \u2192 coverage recovers", "Integration test: kill middle-range node tracker issues LOAD_SHARD coverage recovers",
"python -m pytest passes from repo root", "python -m pytest passes from repo root",
"Commit only this story's changes" "Commit only this story's changes"
], ],
@@ -333,11 +333,11 @@
}, },
{ {
"id": "US-014", "id": "US-014",
"title": "14 \u2014 Tracker-as-first-layer-node (inference entry point)", "title": "14 Tracker-as-first-layer-node (inference entry point)",
"description": "Merge inference orchestration into tracker nodes that serve the first-layer shard. A tracker node exposes /v1/chat/completions directly: tokenizes input, runs embed_tokens + layers[0..k], selects onward route from coverage map, forwards binary activations, receives tail output, and streams tokens back. The standalone gateway becomes a thin load-balancer routing to tracker nodes.", "description": "Merge inference orchestration into tracker nodes that serve the first-layer shard. A tracker node exposes /v1/chat/completions directly: tokenizes input, runs embed_tokens + layers[0..k], selects onward route from coverage map, forwards binary activations, receives tail output, and streams tokens back. The standalone gateway becomes a thin load-balancer routing to tracker nodes.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Node with shard_start==0 (or --tracker-mode flag) exposes /v1/chat/completions alongside /forward", "Node with shard_start==0 (or --tracker-mode flag) exposes /v1/chat/completions alongside /forward",
"Tracker-node /v1/chat/completions: tokenize \u2192 embed \u2192 own layers \u2192 route selection \u2192 forward binary \u2192 receive tail \u2192 stream SSE", "Tracker-node /v1/chat/completions: tokenize embed own layers route selection forward binary receive tail stream SSE",
"Two tracker nodes for same model each handle requests independently", "Two tracker nodes for same model each handle requests independently",
"Gateway proxies /v1/chat/completions to tracker nodes (round-robin), discovered via GET /v1/tracker-nodes/<model_preset>", "Gateway proxies /v1/chat/completions to tracker nodes (round-robin), discovered via GET /v1/tracker-nodes/<model_preset>",
"Integration test: two tracker nodes + two mid-shard nodes for GPT-2; 10 requests via gateway; both tracker nodes receive load; all responses valid", "Integration test: two tracker nodes + two mid-shard nodes for GPT-2; 10 requests via gateway; both tracker nodes receive load; all responses valid",
@@ -357,12 +357,12 @@
}, },
{ {
"id": "US-015", "id": "US-015",
"title": "15 \u2014 Ralph: agent-agnostic runner + status-field-aware dashboard", "title": "15 Ralph: agent-agnostic runner + status-field-aware dashboard",
"description": "Two improvements to the Ralph workflow tooling. (1) Status-field awareness: replace all passes:true/false reads in ralph_progress.py with the rich status field. Surface to-revise and needs-review stories as an attention list in the dashboard; auto command skips them by default. (2) Agent agnosticism: make the agent selectable per session \u2014 codex (existing), claude (Claude Code CLI), openrouter (unified API for GPT-4/Mistral/Llama/DeepSeek via OPENROUTER_API_KEY + --model), or custom (--agent-cmd path to any script). Persist agent choice to .ralph-tui/agent-config.json. When ralph-tui does not natively support a requested agent, ralph_progress.py falls back to a thin adapter that calls the agent API directly with the task prompt. (3) Worktree parallelism: ralph_progress.py auto --parallel [N] creates one git worktree per ready task (git worktree add ../AI-worktree-<id> -b feat/<id>), runs up to N agent sessions concurrently, and merges each branch back to master after the agent exits 0 and tests pass. Non-blocking stories that share no file overlap can safely run in parallel. ralph_progress.py list-parallel shows which open stories have no overlapping dependsOn chains and are safe to run concurrently.", "description": "Two improvements to the Ralph workflow tooling. (1) Status-field awareness: replace all passes:true/false reads in ralph_progress.py with the rich status field. Surface to-revise and needs-review stories as an attention list in the dashboard; auto command skips them by default. (2) Agent agnosticism: make the agent selectable per session codex (existing), claude (Claude Code CLI), openrouter (unified API for GPT-4/Mistral/Llama/DeepSeek via OPENROUTER_API_KEY + --model), or custom (--agent-cmd path to any script). Persist agent choice to .ralph-tui/agent-config.json. When ralph-tui does not natively support a requested agent, ralph_progress.py falls back to a thin adapter that calls the agent API directly with the task prompt. (3) Worktree parallelism: ralph_progress.py auto --parallel [N] creates one git worktree per ready task (git worktree add ../AI-worktree-<id> -b feat/<id>), runs up to N agent sessions concurrently, and merges each branch back to master after the agent exits 0 and tests pass. Non-blocking stories that share no file overlap can safely run in parallel. ralph_progress.py list-parallel shows which open stories have no overlapping dependsOn chains and are safe to run concurrently.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"All story.get('passes') reads in ralph_progress.py replaced with _is_done() helper that reads status field with passes fallback", "All story.get('passes') reads in ralph_progress.py replaced with _is_done() helper that reads status field with passes fallback",
"_story_sets() returns six buckets: done, attention (to-revise/needs-review), active (in-progress), in-design, ready, blocked", "_story_sets() returns six buckets: done, attention (to-revise/needs-review), active (in-progress), in-design, ready, blocked",
"Dashboard shows \u26a0 Attention required section listing to-revise/needs-review stories with status_reason", "Dashboard shows Attention required section listing to-revise/needs-review stories with status_reason",
"auto command skips to-revise and needs-review stories; --include-revise flag overrides", "auto command skips to-revise and needs-review stories; --include-revise flag overrides",
"_review_report includes Attention Required section with status_reason for all affected stories", "_review_report includes Attention Required section with status_reason for all affected stories",
"ralph_progress.py set-agent --agent <name> [--model <model>] writes .ralph-tui/agent-config.json", "ralph_progress.py set-agent --agent <name> [--model <model>] writes .ralph-tui/agent-config.json",
@@ -385,13 +385,13 @@
}, },
{ {
"id": "US-016", "id": "US-016",
"title": "16 \u2014 Mining-style node startup CLI + live dashboard", "title": "16 Mining-style node startup CLI + live dashboard",
"description": "Replace the bare flag-driven node CLI with a wizard-guided first-run experience (like a GPU mining client) followed by a live terminal dashboard once the node is running. On first run, the wizard auto-detects GPU VRAM, presents a curated list of compatible models with VRAM requirements at each quantization level, lets the user pick a download location, and writes a persistent config file so subsequent starts are one command. Once the node is running, the wizard gives way to a rich live status panel showing: GPU temp + VRAM used, tokens/sec, requests served, peers connected, TAI earned (stub until US-006 is live). A Browse HuggingFace option calls the HF Hub API so users can load any HF model beyond the curated list.", "description": "Replace the bare flag-driven node CLI with a wizard-guided first-run experience (like a GPU mining client) followed by a live terminal dashboard once the node is running. On first run, the wizard auto-detects GPU VRAM, presents a curated list of compatible models with VRAM requirements at each quantization level, lets the user pick a download location, and writes a persistent config file so subsequent starts are one command. Once the node is running, the wizard gives way to a rich live status panel showing: GPU temp + VRAM used, tokens/sec, requests served, peers connected, TAI earned (stub until US-006 is live). A Browse HuggingFace option calls the HF Hub API so users can load any HF model beyond the curated list.",
"acceptanceCriteria": [ "acceptanceCriteria": [
" with no args and no config file enters the interactive setup wizard", " with no args and no config file enters the interactive setup wizard",
"Wizard step 1: auto-detect GPU(s) via torch.cuda / torch.version.hip; print GPU name + total VRAM", "Wizard step 1: auto-detect GPU(s) via torch.cuda / torch.version.hip; print GPU name + total VRAM",
"Wizard step 2: show curated model list (name, HF repo, layers, VRAM@NF4/INT8/BF16); mark models that do NOT fit available VRAM as [too large]", "Wizard step 2: show curated model list (name, HF repo, layers, VRAM@NF4/INT8/BF16); mark models that do NOT fit available VRAM as [too large]",
"Wizard step 3: offer [B] Browse HuggingFace \u2014 calls HF Hub API (huggingface_hub.list_models filtered by pipeline_tag=text-generation, sorted by downloads, top 20) and lets user enter a custom HF repo ID", "Wizard step 3: offer [B] Browse HuggingFace calls HF Hub API (huggingface_hub.list_models filtered by pipeline_tag=text-generation, sorted by downloads, top 20) and lets user enter a custom HF repo ID",
"Wizard step 4: prompt for download directory (default ~/.meshnet/models/); validate writable; show estimated disk usage for chosen model+quantization", "Wizard step 4: prompt for download directory (default ~/.meshnet/models/); validate writable; show estimated disk usage for chosen model+quantization",
"Wizard step 5: prompt for tracker URL (default http://localhost:8080); validate connection", "Wizard step 5: prompt for tracker URL (default http://localhost:8080); validate connection",
"Wizard writes ~/.config/meshnet/config.json; second run skips wizard and starts directly", "Wizard writes ~/.config/meshnet/config.json; second run skips wizard and starts directly",
@@ -413,7 +413,7 @@
}, },
{ {
"id": "US-017", "id": "US-017",
"title": "17 \u2014 P2P gossip, NAT-traversal relay node, and SSL/TLS", "title": "17 P2P gossip, NAT-traversal relay node, and SSL/TLS",
"description": "Add a gossip layer so nodes discover each other and propagate coverage-map changes without polling the tracker continuously. Introduce a publicly-hosted relay node (run by the team) that solves NAT traversal using circuit relay (Petals-style) and serves as the bootstrap peer list for new nodes. Encrypt all node-to-node and node-to-tracker communications with TLS. Gossip protocol: WebSocket-based PubSub over wss://, topics: node-join / node-leave / coverage-update / heartbeat. Peer discovery: mDNS (zeroconf) for LAN, public relay bootstrap list for internet. NAT traversal: relay node acts as TCP-level circuit relay when direct connection fails (hole-punching first, relay second). Architecture is designed to migrate to libp2p GossipSub + Kademlia DHT in a future story without breaking the message schema.", "description": "Add a gossip layer so nodes discover each other and propagate coverage-map changes without polling the tracker continuously. Introduce a publicly-hosted relay node (run by the team) that solves NAT traversal using circuit relay (Petals-style) and serves as the bootstrap peer list for new nodes. Encrypt all node-to-node and node-to-tracker communications with TLS. Gossip protocol: WebSocket-based PubSub over wss://, topics: node-join / node-leave / coverage-update / heartbeat. Peer discovery: mDNS (zeroconf) for LAN, public relay bootstrap list for internet. NAT traversal: relay node acts as TCP-level circuit relay when direct connection fails (hole-punching first, relay second). Architecture is designed to migrate to libp2p GossipSub + Kademlia DHT in a future story without breaking the message schema.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"All HTTP between nodes and tracker uses HTTPS (TLS 1.3); self-signed cert generated on first run and fingerprint pinned in config; relay node uses Let's Encrypt", "All HTTP between nodes and tracker uses HTTPS (TLS 1.3); self-signed cert generated on first run and fingerprint pinned in config; relay node uses Let's Encrypt",
@@ -439,7 +439,7 @@
}, },
{ {
"id": "US-018", "id": "US-018",
"title": "18 \u2014 End-to-end two-machine LAN inference test", "title": "18 End-to-end two-machine LAN inference test",
"description": "Prove the network works across two real machines: the Linux rig (this machine) and a Windows 11 rig running WSL2. One machine runs the tracker + first-shard node (inference entry point). The other machine runs a second-shard node. A client sends a real inference request and receives a streamed response. This story is primarily a test plan + setup guide + test execution script; it produces documented evidence (logs, timing, token output) that real distributed inference works. It also surfaces any real-world issues (port forwarding, CUDA driver version mismatches, WSL2 CUDA passthrough, model download paths) that need fixing.", "description": "Prove the network works across two real machines: the Linux rig (this machine) and a Windows 11 rig running WSL2. One machine runs the tracker + first-shard node (inference entry point). The other machine runs a second-shard node. A client sends a real inference request and receives a streamed response. This story is primarily a test plan + setup guide + test execution script; it produces documented evidence (logs, timing, token output) that real distributed inference works. It also surfaces any real-world issues (port forwarding, CUDA driver version mismatches, WSL2 CUDA passthrough, model download paths) that need fixing.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"docs/INSTALL_WINDOWS.md exists: step-by-step WSL2 + CUDA + meshnet-node install on Windows 11", "docs/INSTALL_WINDOWS.md exists: step-by-step WSL2 + CUDA + meshnet-node install on Windows 11",
@@ -447,7 +447,7 @@
"A test script scripts/test_lan_inference.py: given --tracker-url, --gateway-url, sends 3 chat completion requests, asserts valid OpenAI format, prints token count + latency + which nodes served each request", "A test script scripts/test_lan_inference.py: given --tracker-url, --gateway-url, sends 3 chat completion requests, asserts valid OpenAI format, prints token count + latency + which nodes served each request",
"Both machines can reach each other on LAN (documented: firewall rules, port list)", "Both machines can reach each other on LAN (documented: firewall rules, port list)",
"At least one successful inference recorded: the test script exits 0 with output showing tokens generated and node IDs", "At least one successful inference recorded: the test script exits 0 with output showing tokens generated and node IDs",
"Latency breakdown logged: gateway\u2192node-A, node-A\u2192node-B, node-B\u2192gateway (approximate, from server logs)", "Latency breakdown logged: gatewaynode-A, node-Anode-B, node-Bgateway (approximate, from server logs)",
"Known issues during test documented in docs/TWO_MACHINE_TEST.md under a Known Issues section", "Known issues during test documented in docs/TWO_MACHINE_TEST.md under a Known Issues section",
"Commit only this story changes" "Commit only this story changes"
], ],
@@ -462,14 +462,14 @@
}, },
{ {
"id": "US-019", "id": "US-019",
"title": "19 \u2014 Distributed tracker consensus (Raft assignments + CRDT heartbeats)", "title": "19 Distributed tracker consensus (Raft assignments + CRDT heartbeats)",
"description": "Replace the single-point-of-failure tracker with a fault-tolerant cluster. Tracker nodes elect a leader via Raft and commit shard assignments as log entries \u2014 all tracker nodes agree on who owns what. Node liveness (heartbeats) uses CRDT gossip (eventual consistency, high frequency OK). A node registers with any tracker node; the write is forwarded to the leader and replicated to followers. A 3-node tracker cluster survives one tracker failure without losing assignment state. The relay/gossip layer already built in US-017 handles peer heartbeats; this story wires Raft on top for authoritative assignments.", "description": "Replace the single-point-of-failure tracker with a fault-tolerant cluster. Tracker nodes elect a leader via Raft and commit shard assignments as log entries all tracker nodes agree on who owns what. Node liveness (heartbeats) uses CRDT gossip (eventual consistency, high frequency OK). A node registers with any tracker node; the write is forwarded to the leader and replicated to followers. A 3-node tracker cluster survives one tracker failure without losing assignment state. The relay/gossip layer already built in US-017 handles peer heartbeats; this story wires Raft on top for authoritative assignments.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"3 tracker nodes can be started and form a Raft cluster (leader election, log replication)", "3 tracker nodes can be started and form a Raft cluster (leader election, log replication)",
"A node registers with any follower \u2014 the registration is forwarded to the leader and replicated", "A node registers with any follower the registration is forwarded to the leader and replicated",
"Killing the leader causes a new election within 5 seconds; registrations continue working", "Killing the leader causes a new election within 5 seconds; registrations continue working",
"Shard assignments returned by any tracker node are identical (strong consistency)", "Shard assignments returned by any tracker node are identical (strong consistency)",
"Node heartbeats use CRDT gossip (not Raft) \u2014 high-frequency, eventual consistency", "Node heartbeats use CRDT gossip (not Raft) high-frequency, eventual consistency",
"meshnet-tracker CLI gains --cluster-peers flag to specify peer tracker URLs", "meshnet-tracker CLI gains --cluster-peers flag to specify peer tracker URLs",
"Integration test: 3 tracker nodes, kill leader mid-test, verify assignment still works", "Integration test: 3 tracker nodes, kill leader mid-test, verify assignment still works",
"QUICKSTART.md updated with multi-tracker setup section" "QUICKSTART.md updated with multi-tracker setup section"
@@ -481,6 +481,209 @@
"US-017" "US-017"
], ],
"completionNotes": "raft.py: minimal Raft consensus (leader election, log replication, AppendEntries, RequestVote). gossip.py: LWW CRDT gossip for node heartbeats. TrackerServer gains cluster_peers + cluster_self_url params. --cluster-peers and --self-url CLI flags added. 6 integration tests: leader election <1s, follower registration propagation, leader kill + re-election <5s, gossip table." "completionNotes": "raft.py: minimal Raft consensus (leader election, log replication, AppendEntries, RequestVote). gossip.py: LWW CRDT gossip for node heartbeats. TrackerServer gains cluster_peers + cluster_self_url params. --cluster-peers and --self-url CLI flags added. 6 integration tests: leader election <1s, follower registration propagation, leader kill + re-election <5s, gossip table."
},
{
"id": "US-020",
"title": "20 — Tracker + node hardening: BrokenPipe fix, deterministic node IDs, HF coverage",
"description": "Polish pass on tracker and node after real multi-host LAN testing. Three concrete issues: (1) tracker _send_json crashed with BrokenPipeError when a slow-inference client disconnected mid-stream; (2) node IDs were random UUIDs, making re-registration after tracker restart create phantom entries; (3) HF-repo model coverage endpoint did not handle short-name vs full-repo lookups consistently.",
"acceptanceCriteria": [
"BrokenPipeError in tracker _send_json is silently swallowed (client disconnected is not an error)",
"Node IDs are deterministic (wallet-address + port hash) so re-registration after tracker restart reuses the same ID",
"GET /v1/coverage/<model> accepts both short names and full HF repo IDs",
"python -m pytest passes from repo root"
],
"priority": 20,
"status": "done",
"notes": "Discovered during first two-machine LAN test (US-018 follow-up).",
"dependsOn": [
"US-018"
],
"completionNotes": "BrokenPipeError wrapped in try/except in tracker and node send paths. Node IDs now sha256(wallet+port)[:16]. Coverage endpoint normalises short/full HF names."
},
{
"id": "US-021",
"title": "21 — --route-timeout CLI flag for node tracker route lookup",
"description": "The node's slow-path tracker route lookup used a hard-coded 30-second timeout. On high-latency networks (relay, 5G) or when the tracker is slow to respond, this caused premature failures. Expose it as a CLI flag so operators can tune it per deployment.",
"acceptanceCriteria": [
"meshnet-node start accepts --route-timeout <seconds> (float, default 30.0)",
"route_timeout is passed through to TorchNodeServer and used in tracker /v1/route HTTP call",
"TorchNodeServer exposes route_timeout as a readable property",
"Test: setting a non-default timeout is reflected on the running server object",
"python -m pytest passes"
],
"priority": 21,
"status": "done",
"notes": "Small QoL story; acceptance criteria driven by a failing test.",
"dependsOn": [
"US-016"
],
"completionNotes": "cli.py: --route-timeout added to start_cmd. TorchNodeServer._route_timeout stored, route_timeout property exposed. Test: test_route_timeout_config_is_exposed_on_server."
},
{
"id": "US-022",
"title": "22 — X-Meshnet-Start-Layer: overlapping shard execution protocol",
"description": "When two nodes register overlapping shard ranges (e.g. node A covers 0-15 and node B covers 12-23), the naive pipeline re-runs layers 12-15 on node B. The X-Meshnet-Start-Layer header tells each downstream node which layer index to start from, skipping already-computed layers. The tracker injects start_layer into X-Meshnet-Route hops at proxy time.",
"acceptanceCriteria": [
"Tracker _handle_proxy_chat builds route hops with start_layer computed from covered_up_to",
"Node _handle_binary_forward reads X-Meshnet-Start-Layer and passes it to backend.forward_bytes",
"Node _get_remaining_route parses start_layer from both injected header and /v1/route slow-path",
"TorchModelShard.forward_bytes accepts optional start_layer and skips layers below it",
"Test: two-node route with overlapping shards produces correct output without double-computing layers",
"python -m pytest passes"
],
"priority": 22,
"status": "done",
"notes": "Protocol decision: option A (start_layer injected by tracker, not negotiated peer-to-peer). Approved in design session.",
"dependsOn": [
"US-014",
"US-019"
],
"completionNotes": "X-Meshnet-Start-Layer header added to forward protocol. Tracker computes covered_up_to as it builds route_hops. model_backend.forward_bytes(start_layer=) implemented. Tests added for overlapping shard scenarios."
},
{
"id": "US-023",
"title": "23 — Heartbeat stats payload: request counters + dynamic reassignment response",
"description": "Node heartbeats are currently empty POSTs. Extend them to carry cumulative stats (total_requests, failed_requests, queue_depth, uptime_seconds) so the tracker can make informed load-balancing decisions. The heartbeat response may include a new_assignment field, enabling the tracker to redirect a node to a different shard without a restart.",
"acceptanceCriteria": [
"Heartbeat POST body includes total_requests, failed_requests, queue_depth, uptime_seconds, status",
"TorchNodeServer tracks total_requests, failed_requests, queue_depth with a threading.Lock",
"Tracker stores the last heartbeat payload per node and exposes it in /v1/nodes (list endpoint)",
"Heartbeat response may include new_assignment: {model, shard_start, shard_end}; node logs it",
"Stats survive tracker outage: buffered locally, flushed on next successful heartbeat",
"python -m pytest passes"
],
"priority": 23,
"status": "done",
"notes": "Reassignment is logged only for now; hot-reload (load new shard without restart) is a future story.",
"dependsOn": [
"US-016"
],
"completionNotes": "torch_server.py: total_requests, failed_requests, queue_depth added with _stats_lock. startup.py _start_heartbeat sends stats dict. Tracker stores last_heartbeat per node. new_assignment field in heartbeat response logged by node."
},
{
"id": "US-024",
"title": "24 — Enhanced availability map with per-node health details",
"description": "GET /v1/coverage/<model> returns coarse band-level coverage. Operators need to know which specific nodes are in each band and whether they are healthy (last heartbeat time, queue depth). Extend the response to include a nodes array per band with per-node health details, and add a separate detailed endpoint.",
"acceptanceCriteria": [
"GET /v1/coverage/<model> response includes nodes: [{node_id, endpoint, healthy, queue_depth, last_seen_s}] per band",
"healthy is true iff last heartbeat < heartbeat_timeout seconds ago",
"Existing band fields (start_layer, end_layer, node_count) are preserved",
"Tests updated: band assertions check nodes array not just node_count",
"python -m pytest passes"
],
"priority": 24,
"status": "done",
"notes": "Grouped-by-band format chosen (both node list and band metadata in same object). Dead nodes included in response with healthy=false.",
"dependsOn": [
"US-013"
],
"completionNotes": "_coverage_map_detailed added to tracker. Each band now includes nodes list. Tests updated to check band fields individually rather than exact dict comparison."
},
{
"id": "US-025",
"title": "25 — Model usage statistics: rolling RPM windows + SQLite persistence",
"description": "Trackers need to know which models are being requested most often to make smart load-balancing and assignment decisions. Add per-model rolling request-rate counters (last hour, last day, last month) with a circular-bucket implementation. Persist buckets to SQLite so stats survive tracker restarts. Support gossip-based stat sharing between tracker peers (additive merge, per-tracker slices).",
"acceptanceCriteria": [
"_RollingCounter: circular buckets, epoch-indexed, stale buckets auto-reset on record()",
"_StatsCollector: record_request(), get_local_rpms(), merge_peer_rpms(), get_combined_stats()",
"Stats persisted to SQLite (--stats-db PATH); loaded on startup",
"GET /v1/stats returns combined stats (local + peer slices) as {model: {rpm_last_hour, rpm_last_day, rpm_last_month}}",
"POST /v1/stats/gossip accepts a peer's local slice and merges it additively",
"_handle_proxy_chat records a stat after model is determined",
"6 unit tests covering counter, collector, merge, SQLite round-trip, gossip endpoint",
"python -m pytest passes"
],
"priority": 25,
"status": "done",
"notes": "Option B chosen: stats stored locally per tracker and synced via gossip (not aggregated centrally). Rolling windows: 60×1min buckets (1 hour), 24×1hr (1 day), 30×1day (~1 month).",
"dependsOn": [
"US-017"
],
"completionNotes": "_RollingCounter and _ModelStats classes in server.py. _StatsCollector with SQLite save/load. /v1/stats and /v1/stats/gossip endpoints. --stats-db CLI flag. Stats gossip in _stats_loop thread."
},
{
"id": "US-026",
"title": "26 — Smart model assignment via demand×coverage scoring",
"description": "The /v1/network/assign endpoint assigns new nodes to whichever model and shard range covers the biggest uncovered gap. This ignores demand: a model with 1000 RPM and 60% coverage should attract more nodes than a zero-traffic model with 50% coverage. Add a scoring formula: score = (demand_rpm + 1.0) × (coverage_deficit + 0.01) so high-demand under-covered models win. price_per_token is reserved in the protocol response at 0.0.",
"acceptanceCriteria": [
"/v1/network/assign returns the highest-scoring model+shard using demand×coverage formula",
"demand_rpm uses combined stats from _StatsCollector (local + peer slices)",
"coverage_deficit is fraction of layers with zero coverage [0.0, 1.0]",
"price_per_token: 0.0 included in response (reserved field)",
"Models with no traffic still compete by coverage (floor: demand_rpm + 1.0)",
"Test: two models, one high-demand low-coverage wins over low-demand high-coverage",
"python -m pytest passes"
],
"priority": 26,
"status": "done",
"notes": "Score formula chosen after grilling: (demand_rpm + 1.0) × (coverage_deficit + 0.01). The +1.0 floor means coverage alone drives assignment when demand is zero.",
"dependsOn": [
"US-025",
"US-024"
],
"completionNotes": "_handle_network_assign updated with scoring. _effective_throughput helper. price_per_token: 0.0 in response. Tests in test_tracker_routing.py."
},
{
"id": "US-027",
"title": "27 — Throughput-optimized routing: effective throughput as tiebreak",
"description": "The greedy max-reach route selection picks nodes by shard coverage but does not consider node speed. When two nodes cover the same remaining range, prefer the one with higher effective throughput (benchmark_tokens_per_sec / (queue_depth + 1)). This is a tiebreak only — coverage maximization remains the primary objective.",
"acceptanceCriteria": [
"_effective_throughput(node) = benchmark_tokens_per_sec / (queue_depth + 1)",
"_select_route uses throughput as tiebreak when shard_end is equal",
"Test: two nodes with same shard range, different throughput — faster node is picked",
"Existing greedy coverage tests still pass (throughput does not change primary selection)",
"python -m pytest passes"
],
"priority": 27,
"status": "done",
"notes": "Throughput tiebreak only — coverage-maximizing greedy stays primary. queue_depth from last heartbeat.",
"dependsOn": [
"US-023"
],
"completionNotes": "_effective_throughput added to server.py. _select_route updated: when shard_end equal, max throughput wins. Tests added and existing tests corrected for new tiebreak order."
},
{
"id": "US-028",
"title": "28 — Routing correctness tests: three-node, overlap, and throughput scenarios",
"description": "Add a comprehensive test suite for route selection covering: greedy three-node no-overlap ordering, overlapping shard handling, throughput tiebreak, and gap detection. These tests lock in the routing contract so refactors don't silently regress.",
"acceptanceCriteria": [
"test_select_route_no_overlap_three_nodes: greedy picks nodes in layer order (A→C→B for ranges 0-7, 8-15, 16-23)",
"test_select_route_with_overlap: overlapping nodes correctly resolved",
"test_select_route_throughput_tiebreak: faster node wins when reach is equal",
"test_select_route_gap_leaves_error: partial coverage returns error",
"All tests pass without mocking (in-process tracker server)",
"python -m pytest passes"
],
"priority": 28,
"status": "done",
"notes": "Tests revealed the greedy picks A→C→B (not A→B→C) for non-overlapping ranges when C starts at a lower layer than B. Test expectation corrected to match algorithm.",
"dependsOn": [
"US-027"
],
"completionNotes": "7 routing tests added to test_tracker_routing.py. _make_node helper. test_select_route_no_overlap_three_nodes corrected: greedy outputs [A, C, B] when C.shard_start < B.shard_start."
},
{
"id": "US-029",
"title": "29 — Outbound relay client: NAT/internet pipeline hops",
"description": "Nodes behind NAT (WSL2, 5G, home routers) register with the tracker including a relay_addr (wss://relay/rpc/{peer_id}). When the head node needs to forward activations to a behind-NAT peer, it must use the relay instead of direct HTTP. Add _relay_hop() to torch_server.py that opens a per-hop WebSocket to the relay's /rpc/{peer_id} endpoint, sends the binary activation (base64-encoded), and returns the response. If relay fails, fall back to direct HTTP.",
"acceptanceCriteria": [
"_relay_hop(relay_addr, path, body, headers, timeout) opens WS to relay, sends body_base64, returns (status, headers, body)",
"_get_remaining_route returns list[dict] with relay_addr field (was list[tuple])",
"_run_downstream_pipeline dispatches via _relay_hop when hop has relay_addr",
"Falls back to direct HTTP if relay connection fails (logs warning)",
"Tracker _handle_proxy_chat includes relay_addr in downstream hop dicts",
"relay_bridge._handle_request decodes body_base64; response uses body_base64 for binary (octet-stream)",
"All 157 tests pass",
"QUICKSTART.md updated with relay NAT/internet section"
],
"priority": 29,
"status": "done",
"notes": "relay_addr format: wss://relay.../rpc/{peer_id}. Binary activations (bfloat16) base64-encoded through relay JSON protocol — no precision loss. WSL2 nodes now work behind NAT without --advertise-host.",
"dependsOn": [
"US-017",
"US-022"
],
"completionNotes": "_relay_hop() added to torch_server.py. _get_remaining_route returns list[dict]. relay_bridge.py updated with body_base64 support. Tracker injects relay_addr into downstream hop dicts. 157 tests pass."
} }
], ],
"metadata": { "metadata": {
@@ -495,4 +698,4 @@
"blocked": "Waiting on unresolved external dependency" "blocked": "Waiting on unresolved external dependency"
} }
} }
} }

View File

@@ -0,0 +1,73 @@
# ADR-0011: Auto-shard from memory budget and tracker-managed network assignment
## Status: Accepted
## Context
Early node startup required explicit `--shard-start` and `--shard-end` flags. This is
fine for expert operators but a barrier to new participants who don't know how many layers
their GPU can hold. Two improvements were needed:
1. **Auto-detect shard range**: fetch `num_hidden_layers` from the model's `config.json`
and compute how many layers fit in available VRAM.
2. **Network-aware assignment**: instead of each node picking its own shard, the tracker
knows the current coverage map and can tell the node which gap to fill.
## Decisions
### 1. Layer count from HuggingFace config
`AutoConfig.from_pretrained(model_id)` downloads only `config.json` (~1 KB, no weights).
`cfg.num_hidden_layers` gives the total layer count. The node uses this to set
`shard_end = num_layers - 1` when no explicit range is given.
A curated `MODEL_CATALOG` in `model_catalog.py` provides layer counts for common models
without any network call — HuggingFace is only hit for uncatalogued repos.
### 2. VRAM-aware shard sizing
`hardware.detect_hardware()` returns `vram_mb`. The node sends this to
`/v1/network/assign?device=cuda&vram_mb=<n>&hf_repo=<repo>`. The tracker responds with
a `{shard_start, shard_end}` gap that fits within the reported VRAM budget using the
`bytes_per_layer` table from the model preset.
When the tracker has no registered nodes for the model yet, `gap_found: false` is
returned and the node defaults to the full model.
### 3. --memory override
`--memory MB` allows overriding the detected VRAM. Useful for CPU nodes (which report 0
VRAM) that want to serve a specific slice using system RAM.
### 4. Tracker network assignment endpoint
`GET /v1/network/assign` replaces the old `GET /v1/nodes/assign`. It accepts
`device`, `vram_mb`, and optionally `hf_repo`. It returns:
```json
{
"hf_repo": "Qwen/Qwen2.5-0.5B-Instruct",
"shard_start": 12,
"shard_end": 23,
"num_layers": 24,
"gap_found": true,
"price_per_token": 0.0
}
```
`price_per_token` is reserved at 0.0 for future billing integration.
## Alternatives rejected
**Fixed shard table per model**: would require updating the code for every new model.
HuggingFace config fetch is more general.
**Node computes its own gap**: requires the node to know the full coverage map. The
tracker already has this; having the tracker compute the assignment is cleaner.
## Consequences
- Nodes can join the network with a single command: `meshnet-node start --tracker <url>`
- The tracker is now the authoritative source for shard assignment
- VRAM budgets are advisory — nodes can still pin a range with explicit flags
- `price_per_token: 0.0` is a stable protocol field; future billing sets it to a real value

View File

@@ -0,0 +1,67 @@
# ADR-0012: X-Meshnet-Start-Layer protocol for overlapping shard execution
## Status: Accepted
## Context
The greedy route-selection algorithm picks a minimal set of nodes whose shard ranges
collectively cover all model layers. This is exact when shard ranges are disjoint
(node A: 011, node B: 1223). But two nodes with overlapping ranges can also cover
the full model (node A: 015, node B: 1023).
Without coordination, node B would re-run layers 1015 on top of an activation tensor
that already has those layers applied — producing silently wrong output.
The question is: who resolves the overlap, and how?
## Options considered
**A. Tracker injects start_layer per hop (chosen)**
The tracker knows the full route when it builds `X-Meshnet-Route`. It computes
`covered_up_to` as it walks the route and sets `start_layer = covered_up_to + 1`
for each subsequent hop. The head node forwards this per-hop in
`X-Meshnet-Start-Layer`. No peer-to-peer negotiation needed.
**B. Each node negotiates with the next**
Node A would tell node B "I ran layers 015, you start from 16". This requires
node A to know node B's shard range, which means an extra tracker lookup or
exposing shard metadata in the activation wire protocol.
**C. Strict non-overlapping enforcement**
Reject any route that contains overlapping nodes. Simpler but limits redundancy:
two nodes with the same shard can't form a route even if their combined coverage
is complete.
## Decision
Option A. The tracker is already the central coordinator; it already knows every
node's shard range. Injecting `start_layer` at route-build time costs nothing and
keeps the node implementation simple.
## Wire protocol
`X-Meshnet-Route` (JSON array, injected by tracker into the first-hop request):
```json
[
{"endpoint": "http://node-b:7002", "start_layer": 12, "relay_addr": null},
{"endpoint": "http://node-c:7003", "start_layer": 20}
]
```
`X-Meshnet-Start-Layer` (integer header, forwarded by head node to each downstream hop):
```
X-Meshnet-Start-Layer: 12
```
The receiving node passes `start_layer` to `backend.forward_bytes(start_layer=12)`.
The model shard skips transformer blocks below index 12.
## Consequences
- Overlapping shard registrations are valid and useful for redundancy
- Route selection does not need to enforce disjoint ranges
- The tracker carries the full route context; nodes are stateless w.r.t. routing
- `start_layer` must be preserved through the relay path (included in hop dict)
- Backward compatibility: if `start_layer` is absent, the node runs from its registered `shard_start`

View File

@@ -0,0 +1,89 @@
# ADR-0013: Rolling RPM statistics, smart assignment scoring, and throughput routing
## Status: Accepted
## Context
The tracker made routing and assignment decisions blind to actual network traffic.
Three related improvements were needed and designed together:
1. **Model usage statistics** — how many requests per model, so the tracker knows demand
2. **Smart assignment** — assign new nodes to where demand × unmet coverage is highest
3. **Throughput routing** — when multiple nodes can complete a route, pick the faster ones
## Decisions
### 1. Rolling RPM counters
`_RollingCounter` is a circular-bucket structure where each slot covers a fixed time epoch.
Recording a value for the current epoch increments that slot; an expired slot is silently reset
on the next write. Three windows per model:
| Window | Buckets | Bucket size | Total span |
|--------|---------|-------------|------------|
| per_minute | 60 | 60 s | 1 hour |
| per_hour | 24 | 3600 s | 1 day |
| per_day | 30 | 86400 s | ~1 month |
`rpm()` sums all non-stale buckets and divides by total window minutes.
Alternative: exponential moving average (simpler, single float). Rejected because EMA
cannot be persisted and restored without loss, and cannot be accurately merged from peer
slices (each tracker runs its own requests, so merging EMA values doesn't give the true
combined rate).
### 2. Per-tracker stat slices + additive gossip
Each tracker keeps only its own request slice. Gossip exchanges these slices and each tracker
stores a `{tracker_url → {model → rpms}}` map. `get_combined_stats()` sums all slices.
This is additive: if tracker A sees 10 RPM for model X and tracker B sees 5 RPM, combined
is 15 RPM. Slices are keyed by tracker URL so a stale peer update simply overwrites its
own key without corrupting other peers' data.
Alternative: one global aggregator. Rejected — single point of failure, contradicts the
distributed model.
### 3. Assignment scoring formula
```
score(model) = (demand_rpm + 1.0) × (coverage_deficit + 0.01)
```
- `demand_rpm` = `get_combined_stats()[model]["rpm_last_hour"]`
- `coverage_deficit` = fraction of model layers with zero-node coverage ∈ [0.0, 1.0]
- `+1.0` floor: zero-traffic models still compete by coverage
- `+0.01` floor: fully-covered models can still attract nodes if they have high demand
The product ensures both dimensions matter: high demand but full coverage scores lower
than high demand with partial coverage. Pure coverage deficits without traffic score
lower than even modest traffic combined with any gap.
`price_per_token: 0.0` is returned in the assignment response, reserved for future billing.
### 4. Throughput tiebreak in route selection
```
effective_throughput(node) = benchmark_tokens_per_sec / (queue_depth + 1)
```
`_select_route` uses this as a tiebreak only: when two candidates reach the same
maximum `shard_end`, the one with higher effective throughput is preferred.
Coverage maximization remains the primary objective.
`benchmark_tokens_per_sec` comes from the hardware profile at registration.
`queue_depth` comes from the most recent heartbeat. A busy node (high queue)
is deprioritized without being excluded.
### 5. SQLite persistence
Stats are saved to SQLite (configurable via `--stats-db PATH`) every 60 seconds and
on clean shutdown. Schema: `model_rpm_buckets(model, window, bucket_idx, bucket_epoch, count)`.
The circular-bucket structure maps directly — each slot is one row.
## Consequences
- Tracker startup is slightly slower when loading a large stats DB (sub-second for typical sizes)
- Peer gossip adds one round-trip per gossip interval per peer
- `price_per_token` is a stable wire field; future billing sets it to a real value
- `effective_throughput` depends on `benchmark_tokens_per_sec` being set correctly at registration; nodes that don't set it get the default `1.0` and are treated as slowest

View File

@@ -0,0 +1,120 @@
# ADR-0014: Relay outbound client for NAT/internet pipeline hops
## Status: Accepted
## Context
ADR-0010 describes the relay server: a public WebSocket hub where nodes behind NAT
connect outbound and register as reachable peers. That ADR focused on the *inbound*
side: how the tracker reaches a behind-NAT node for the initial chat request.
The *pipeline hop* problem is different: when node A has the head shard and node B
(behind NAT) has the tail shard, node A must forward binary activations to node B
for *every generated token*. Direct HTTP from A to B is blocked. The relay must
carry this per-hop activation traffic.
### Why this is harder than tracker → node
The tracker-to-node relay (ADR-0010) proxies a single JSON request. The activation
hop carries raw bfloat16 tensors — binary data that must survive round-tripping
through the relay's JSON message envelope without precision loss.
Also, the relay `/rpc/{peer_id}` endpoint (one WebSocket connection per request)
must be opened and closed for every token in the autoregressive loop. Latency
of connection setup matters.
## Options considered
**A. Relay hop (WebSocket per hop, chosen)**
Node A opens a WebSocket to `wss://relay/rpc/{peer_id_B}`, sends the activation,
receives the response, closes. The relay's `_handle_rpc` forwards it to B's persistent
connection via the existing `relay-http-request` envelope mechanism.
Pros: reuses the existing relay server unchanged. Each hop is independent; failures don't
affect other requests.
Cons: WebSocket connection setup adds ~50150 ms per hop on a fast relay. For
autoregressive inference (N tokens × M hops), this adds up.
**B. Persistent per-session tunnel**
Node A opens a persistent WebSocket to the relay for the duration of an inference session
and multiplexes all token hops over it.
Pros: amortises connection setup across tokens.
Cons: requires session-level state on the relay; complicates relay shutdown/failover;
the current relay is stateless by design. Deferred for a future optimization.
**C. Tracker-proxied activations**
Route all activation traffic through the tracker's HTTP proxy.
Cons: the tracker is the control plane, not the data plane. High-volume binary tensor
traffic through the tracker would saturate it. Rejected.
## Decision
Option A — per-hop WebSocket relay. Simple, reuses existing infrastructure, correct.
Option B is noted as a future optimization when activation-path latency becomes the
bottleneck.
## Protocol
```
Node A opens WS → wss://relay/rpc/{peer_id_B}
Node A sends:
{
"request_id": "<hex>",
"method": "POST",
"path": "/forward",
"headers": { "X-Meshnet-Shape": "...", "X-Meshnet-Start-Layer": "12", ... },
"body_base64": "<base64(bfloat16 tensor)>"
}
Relay forwards to Node B as relay-http-request envelope.
Node B's RelayHttpBridge decodes body_base64, calls POST /forward locally.
Response:
{
"request_id": "<hex>",
"status": 200,
"headers": { "x-meshnet-shape": "...", "content-type": "application/octet-stream" },
"body_base64": "<base64(output tensor)>" ← for binary responses
# OR
"body": "<json string>" ← for text (last-hop decode)
}
Relay sends response JSON back to Node A.
Node A decodes body_base64, continues pipeline.
```
### Binary data through JSON: base64
Raw bfloat16 bytes cannot safely transit JSON (no UTF-8 guarantee, lossy decode).
`body_base64` carries the tensor as base64; the bridge decodes it before calling
the local HTTP endpoint, and re-encodes the response. No precision loss.
Text responses (final hop, `application/json`) use `body` (plain string) for efficiency.
### Fallback
If `_relay_hop` raises (relay unreachable, peer disconnected), `_run_downstream_pipeline`
logs a warning and retries via direct HTTP. If both fail, the hop returns a pipeline error
string and the token is skipped.
### Tracker injection
The tracker's `_handle_proxy_chat` includes `relay_addr` in each downstream hop dict
when the node has one registered:
```json
{"endpoint": "http://172.29.x.x:7002", "start_layer": 12, "relay_addr": "wss://relay/rpc/abc123"}
```
The head node reads `relay_addr` from the injected `X-Meshnet-Route` header and calls
`_relay_hop` instead of direct HTTP.
## Consequences
- Nodes behind NAT (WSL2, 5G, home routers) can now participate in distributed pipeline inference without opening firewall ports
- `relay_addr` is a stable registration field; nodes without a relay omit it and receive direct HTTP hops
- Per-hop WebSocket setup adds latency proportional to relay RTT; acceptable for prototype, optimize later with persistent tunnels
- Base64 encoding increases payload size by ~33%; acceptable for prototype
- The relay server remains stateless and horizontally scalable; only the persistent per-peer `/ws` connections are stateful