Files
OpenJarvis/docs/design/2026-05-05-vllm-pearl-mining-integration-plan.md
T
Jon Saad-Falcon f41cf420be feat(mining): Pearl mining integration
Consolidates NVIDIA vLLM, Apple Silicon, CPU Pearl mining support, CLI/docs, and live H100 validation.
2026-05-05 19:11:15 -07:00

118 KiB

vLLM-Pearl mining integration — implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement Spec A — the v1 vLLM-Pearl mining integration — as a new openjarvis.mining subsystem that lets H100/H200 owners running vLLM solo-mine the Pearl PoUW chain with their normal LLM inference.

Architecture: New top-level subsystem mining/ mirrors OJ's existing primitive pattern (peer to engine/, agents/). A MiningProvider ABC + MinerRegistry enables future hardware/engine paths (Apple Silicon, AMD, Ollama) without rewrite. The v1 vllm-pearl provider orchestrates Pearl's published Docker container; a runtime sidecar at ~/.openjarvis/runtime/mining.json decouples mining lifecycle from the existing vLLM engine class. Three deliberate seams (submit_target tagged-union, zero-valued fee_bps/fees_owed, reserved mining/pools/) leave room for v2 pool support without retrofit pain.

Tech Stack: Python 3.10+, docker>=7.0 SDK, Click CLI, pytest with unittest.mock, ruff, uv. References Pearl repo (pearl-research-labs/pearl) at a pinned commit/tag.

Spec reference: docs/design/2026-05-05-vllm-pearl-mining-integration-design.md. Read it before starting. Section numbers in the plan refer to that spec.

Critical conventions for any agent picking this up:

  • All new modules begin with from __future__ import annotations.
  • All dataclasses use @dataclass(slots=True).
  • Absolute imports only (from openjarvis.core.registry import ...).
  • Optional dependencies live behind try / except ImportError in the parent __init__.py.
  • Tests in tests/conftest.py autouse-clear all registries — the ensure_registered() pattern (idempotent registration guarded by XRegistry.contains(...)) is required for components that need to survive the autouse clear.
  • File-naming: _stubs.py (ABC + dataclasses), _discovery.py (auto-detection), *_cmd.py (CLI commands).

Branch posture: This plan is added to the existing branch docs/pearl-mining-design-specs as a follow-up commit on the same PR (#310). Implementation work will branch off main separately once the spec + plan are merged.


File structure

Created

Path Responsibility
src/openjarvis/mining/__init__.py Package init; soft-imports providers via try/except ImportError
src/openjarvis/mining/_stubs.py MiningProvider ABC, MiningCapabilities, MiningConfig, MiningStats, SoloTarget/PoolTarget tagged union, Sidecar dataclass + read/write helpers
src/openjarvis/mining/_discovery.py detect_providers(), hardware/Docker/disk/pearld checks, wallet-format check
src/openjarvis/mining/_constants.py PEARL_REPO, PEARL_PINNED_REF, PEARL_IMAGE_TAG, default ports, default model, sidecar path constants
src/openjarvis/mining/_docker.py PearlDockerLauncherensure_image(), start(), stop(), is_running(), get_logs()
src/openjarvis/mining/_collector.py MiningTelemetryCollector — full impl, shipped unwired in v1; v1.x lights it up in the gateway daemon
src/openjarvis/mining/vllm_pearl.py VllmPearlProvider (MiningProvider impl); _parse_gateway_metrics(); ensure_registered()
src/openjarvis/mining/pools/__init__.py Reserved location for v2 PoolClient work — empty except for a docstring saying so
src/openjarvis/cli/mine_cmd.py jarvis mine Click group: init, start, stop, status, doctor, attach, logs
tests/mining/__init__.py Empty test package init
tests/mining/conftest.py Mining-specific fixtures: synthetic HardwareInfo, mock Docker client factory, sample sidecar tmp_path
tests/mining/fixtures/gateway_metrics_sample.txt Real Prometheus output captured from a Pearl gateway run (one snapshot, committed)
tests/mining/fixtures/config_minimal.toml Minimal valid [mining] config
tests/mining/fixtures/config_pool_v2.toml TOML with submit_target = "pool:..." for testing the v2-seam NotImplementedError
tests/mining/test_stubs.py Tests for ABC contract, dataclass invariants, sidecar IO
tests/mining/test_discovery.py Tests for capability detection matrix
tests/mining/test_docker.py Tests for PearlDockerLauncher with mocked Docker SDK
tests/mining/test_collector.py Tests for MiningTelemetryCollector
tests/mining/test_vllm_pearl.py Tests for VllmPearlProvider end-to-end (mocked Docker + filesystem)
tests/mining/test_cli.py CLI smoke tests via Click CliRunner
docs/user-guide/mining.md User-facing docs
docs/development/mining.md Contributor guide for adding new providers

Modified

Path Change
src/openjarvis/core/registry.py Add MinerRegistry class + entry in __all__
src/openjarvis/core/config.py Add MiningConfig field to JarvisConfig; add TOML parser for [mining] with submit_target tagged-union resolution
src/openjarvis/engine/_discovery.py Add sidecar-aware engine resolution: when ~/.openjarvis/runtime/mining.json exists, register a derived vllm engine pointing at vllm_endpoint
src/openjarvis/telemetry/store.py Add nullable mining_session_id column to inference rows; bump PRAGMA user_version; helper to tag rows when sidecar is present
src/openjarvis/cli/__init__.py Register the mine Click group
src/openjarvis/cli/hints.py Add hint: "mining configured but not running — start it with jarvis mine start"
tests/conftest.py Add MinerRegistry.clear() to autouse _clean_registries fixture
pyproject.toml Add mining-pearl extra; add docker pytest marker
CLAUDE.md Add paragraph under Architecture pointing future-Claude at mining/ as a sibling subsystem
REVIEW.md Add bullet under registry compliance calling out MinerRegistry

Task 1 — Add MinerRegistry

Files:

  • Modify: src/openjarvis/core/registry.py

  • Modify: tests/conftest.py

  • Test: tests/core/test_registry.py (existing file — add a new test)

  • Step 1: Write the failing test

Add to tests/core/test_registry.py:

def test_miner_registry_register_and_get():
    from openjarvis.core.registry import MinerRegistry

    class _Stub:
        provider_id = "stub-pearl"

    MinerRegistry.register_value("stub-pearl", _Stub)
    assert MinerRegistry.contains("stub-pearl") is True
    assert MinerRegistry.get("stub-pearl") is _Stub
  • Step 2: Run test to verify it fails
uv run pytest tests/core/test_registry.py::test_miner_registry_register_and_get -v

Expected: ImportError or AttributeError on MinerRegistry.

  • Step 3: Add MinerRegistry to core/registry.py

Insert after ConnectorRegistry (around line 153):

class MinerRegistry(RegistryBase[Any]):
    """Registry for Pearl mining provider implementations.

    Each provider implements the ``MiningProvider`` ABC defined in
    ``openjarvis.mining._stubs``. Registry keys are short lowercase strings
    such as ``"vllm-pearl"`` (CUDA + Hopper) and (future) ``"mlx-pearl"``,
    ``"llamacpp-pearl-metal"``, ``"ollama-pearl"``.
    """

Add "MinerRegistry" to __all__ (alphabetical position between MemoryRegistry and ModelRegistry).

  • Step 4: Run test to verify it passes
uv run pytest tests/core/test_registry.py::test_miner_registry_register_and_get -v

Expected: PASS.

  • Step 5: Update tests/conftest.py autouse fixture

In tests/conftest.py, add MinerRegistry to the imports and to the clear-list. The existing _clean_registries fixture lists every registry on its own line — insert MinerRegistry.clear() alphabetically between MemoryRegistry.clear() and ModelRegistry.clear(). Likewise add MinerRegistry, to the imports block.

  • Step 6: Verify autouse clear works

Add a second test below the registration test:

def test_miner_registry_cleared_between_tests():
    from openjarvis.core.registry import MinerRegistry
    # If autouse clear works, no entry from prior tests remains
    assert MinerRegistry.contains("stub-pearl") is False
uv run pytest tests/core/test_registry.py::test_miner_registry_register_and_get tests/core/test_registry.py::test_miner_registry_cleared_between_tests -v

Expected: both PASS.

  • Step 7: Commit
git add src/openjarvis/core/registry.py tests/conftest.py tests/core/test_registry.py
git commit -m "feat(mining): add MinerRegistry for mining providers"

Task 2 — Mining package skeleton: constants, ABC, dataclasses, sidecar IO

Files:

  • Create: src/openjarvis/mining/__init__.py

  • Create: src/openjarvis/mining/_constants.py

  • Create: src/openjarvis/mining/_stubs.py

  • Create: src/openjarvis/mining/pools/__init__.py

  • Create: tests/mining/__init__.py

  • Create: tests/mining/conftest.py

  • Create: tests/mining/test_stubs.py

  • Step 1: Create tests/mining/__init__.py

Empty file.

  • Step 2: Create tests/mining/conftest.py with shared fixtures
"""Mining-specific test fixtures."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock

import pytest

from openjarvis.core.config import GpuInfo, HardwareInfo


@pytest.fixture
def hopper_hw() -> HardwareInfo:
    """Hardware fixture: a typical H100 host."""
    return HardwareInfo(
        platform="linux",
        cpu_brand="AMD EPYC 7763",
        cpu_count=64,
        ram_gb=512.0,
        gpu=GpuInfo(
            vendor="nvidia",
            name="NVIDIA H100-SXM5-80GB",
            vram_gb=80.0,
            compute_capability="9.0",
            count=1,
        ),
    )


@pytest.fixture
def ada_hw() -> HardwareInfo:
    """Hardware fixture: RTX 4090 (sm_89, NOT supported by Pearl)."""
    return HardwareInfo(
        platform="linux",
        cpu_brand="Intel Core i9-14900K",
        cpu_count=24,
        ram_gb=64.0,
        gpu=GpuInfo(
            vendor="nvidia",
            name="NVIDIA GeForce RTX 4090",
            vram_gb=24.0,
            compute_capability="8.9",
            count=1,
        ),
    )


@pytest.fixture
def apple_hw() -> HardwareInfo:
    """Hardware fixture: Apple Silicon (NOT supported in v1)."""
    return HardwareInfo(
        platform="darwin",
        cpu_brand="Apple M4 Max",
        cpu_count=16,
        ram_gb=128.0,
        gpu=GpuInfo(vendor="apple", name="Apple M4 Max", vram_gb=128.0, count=1),
    )


@pytest.fixture
def mock_docker_client() -> Any:
    """Factory for a mocked docker.DockerClient.

    Returns a MagicMock configured with the most common attribute paths so
    individual tests only need to override what they care about.
    """
    client = MagicMock()
    client.ping.return_value = True
    client.version.return_value = {"Version": "24.0.7"}
    client.images.list.return_value = []
    client.images.get.side_effect = Exception("not found")
    return client


@pytest.fixture
def sample_sidecar_payload() -> dict:
    return {
        "provider": "vllm-pearl",
        "vllm_endpoint": "http://127.0.0.1:8000/v1",
        "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl",
        "gateway_url": "http://127.0.0.1:8337",
        "gateway_metrics_url": "http://127.0.0.1:8339",
        "container_id": "abc123def456",
        "wallet_address": "prl1qexampleaddress",
        "started_at": 1714867200,
    }


@pytest.fixture
def sidecar_path(tmp_path: Path) -> Path:
    return tmp_path / "mining.json"


@pytest.fixture
def written_sidecar(sidecar_path: Path, sample_sidecar_payload: dict) -> Path:
    sidecar_path.write_text(json.dumps(sample_sidecar_payload))
    return sidecar_path
  • Step 3: Write the failing test for _stubs.py

Create tests/mining/test_stubs.py:

"""Tests for mining/_stubs.py — ABC contract, dataclass invariants, sidecar IO."""

from __future__ import annotations

import json
from pathlib import Path

import pytest


def test_mining_capabilities_default_unsupported():
    from openjarvis.mining._stubs import MiningCapabilities
    cap = MiningCapabilities(supported=False, reason="needs sm90")
    assert cap.supported is False
    assert cap.reason == "needs sm90"
    assert cap.estimated_hashrate is None


def test_solo_target_dataclass():
    from openjarvis.mining._stubs import SoloTarget
    t = SoloTarget(pearld_rpc_url="http://localhost:44107")
    assert t.pearld_rpc_url == "http://localhost:44107"


def test_pool_target_dataclass():
    from openjarvis.mining._stubs import PoolTarget
    t = PoolTarget(url="https://pool.example/submit", worker_id="rig01")
    assert t.url == "https://pool.example/submit"
    assert t.worker_id == "rig01"


def test_mining_config_v1_defaults():
    from openjarvis.mining._stubs import MiningConfig, SoloTarget
    cfg = MiningConfig(
        provider="vllm-pearl",
        wallet_address="prl1qexample",
        submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"),
    )
    assert cfg.fee_bps == 0
    assert cfg.fee_payout_address is None
    assert cfg.extra == {}


def test_mining_stats_v1_defaults():
    from openjarvis.mining._stubs import MiningStats
    s = MiningStats(provider_id="vllm-pearl")
    assert s.shares_submitted == 0
    assert s.shares_accepted == 0
    assert s.fees_owed == 0
    assert s.payout_target == "solo"


def test_mining_provider_is_abstract():
    from openjarvis.mining._stubs import MiningProvider
    with pytest.raises(TypeError):
        MiningProvider()  # cannot instantiate ABC


def test_sidecar_write_then_read_roundtrip(sidecar_path: Path, sample_sidecar_payload: dict):
    from openjarvis.mining._stubs import Sidecar
    Sidecar.write(sidecar_path, sample_sidecar_payload)
    payload = Sidecar.read(sidecar_path)
    assert payload == sample_sidecar_payload


def test_sidecar_read_missing_returns_none(sidecar_path: Path):
    from openjarvis.mining._stubs import Sidecar
    assert Sidecar.read(sidecar_path) is None


def test_sidecar_remove_is_idempotent(sidecar_path: Path):
    from openjarvis.mining._stubs import Sidecar
    Sidecar.remove(sidecar_path)  # missing file — should not raise
    sidecar_path.write_text(json.dumps({"x": 1}))
    Sidecar.remove(sidecar_path)
    assert not sidecar_path.exists()
  • Step 4: Run tests to verify they fail
uv run pytest tests/mining/test_stubs.py -v

Expected: ALL fail with ImportError on openjarvis.mining._stubs.

  • Step 5: Create _constants.py
# src/openjarvis/mining/_constants.py
"""Constants for the Pearl mining subsystem.

Pinned Pearl ref OJ has tested against. Bumped per OJ release after
re-testing the integration end-to-end on a real H100/H200 host. See
spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md``
section 7.3 for the rev-bump workflow.
"""

from __future__ import annotations

from pathlib import Path

PEARL_REPO = "https://github.com/pearl-research-labs/pearl.git"
# TODO at implementation time: replace with the specific commit/tag verified
# against H100. Document the chosen ref in the OJ release notes.
PEARL_PINNED_REF = "main"
PEARL_IMAGE_TAG = f"openjarvis/pearl-miner:{PEARL_PINNED_REF}"

# Default Pearl-blessed model. Overridable via [mining.extra].model.
DEFAULT_PEARL_MODEL = "pearl-ai/Llama-3.3-70B-Instruct-pearl"

# Default ports as Pearl's container exposes them (network_mode="host").
DEFAULT_VLLM_PORT = 8000
DEFAULT_GATEWAY_RPC_PORT = 8337
DEFAULT_GATEWAY_METRICS_PORT = 8339

# Default pearld RPC endpoint (mainnet).
DEFAULT_PEARLD_RPC_URL = "http://localhost:44107"

# Pre-flight free-disk requirement for the 70B model + headroom.
MIN_FREE_DISK_GB = 200

# Runtime sidecar location (single-session assumption — see spec §8.8).
RUNTIME_DIR = Path.home() / ".openjarvis" / "runtime"
SIDECAR_PATH = RUNTIME_DIR / "mining.json"
SIDECAR_LOCK_PATH = RUNTIME_DIR / "mining.lock"

# Pearl source cache for build-from-pin path (see spec §7.2).
PEARL_CACHE_DIR = Path.home() / ".openjarvis" / "cache" / "pearl"
  • Step 6: Create _stubs.py
# src/openjarvis/mining/_stubs.py
"""ABCs and dataclasses for the mining subsystem.

See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md``
section 4.4 for the design rationale.
"""

from __future__ import annotations

import json
import os
import tempfile
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional, Union

from openjarvis.core.config import HardwareInfo


# ---------------------------------------------------------------------------
# Capability descriptor
# ---------------------------------------------------------------------------


@dataclass(slots=True)
class MiningCapabilities:
    """Result of a provider's ``detect()`` call.

    ``reason`` is human-readable and surfaced verbatim by ``jarvis mine doctor``
    when ``supported=False``.
    """

    supported: bool
    reason: Optional[str] = None
    estimated_hashrate: Optional[float] = None  # shares/sec, best-effort


# ---------------------------------------------------------------------------
# Submit-target tagged union (v2 seam — see spec §8.5)
# ---------------------------------------------------------------------------


@dataclass(slots=True)
class SoloTarget:
    """Mine directly to a pearld node. v1 default."""

    pearld_rpc_url: str


@dataclass(slots=True)
class PoolTarget:
    """Mine through an OJ-operated pool. v2 — raises NotImplementedError in v1."""

    url: str
    worker_id: Optional[str] = None


SubmitTarget = Union[SoloTarget, PoolTarget]


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------


@dataclass(slots=True)
class MiningConfig:
    """User-supplied mining configuration.

    Loaded from the ``[mining]`` TOML section by ``core/config.py``.
    """

    provider: str
    wallet_address: str
    submit_target: SubmitTarget
    fee_bps: int = 0  # v1: 0; v2: 2000 (=20%)
    fee_payout_address: Optional[str] = None  # v1: ignored; v2: OJ's address
    extra: dict[str, Any] = field(default_factory=dict)


# ---------------------------------------------------------------------------
# Live stats (returned by ``MiningProvider.stats()``)
# ---------------------------------------------------------------------------


@dataclass(slots=True)
class MiningStats:
    provider_id: str
    shares_submitted: int = 0
    shares_accepted: int = 0
    blocks_found: int = 0
    hashrate: float = 0.0
    uptime_seconds: float = 0.0
    last_share_at: Optional[float] = None
    last_error: Optional[str] = None
    payout_target: str = "solo"  # v1: always "solo"; v2: "pool:<url>"
    fees_owed: int = 0  # v2 accounting hook; 0 in v1


# ---------------------------------------------------------------------------
# The ABC
# ---------------------------------------------------------------------------


class MiningProvider(ABC):
    """A mining provider — orchestrates a Pearl mining session for one (hardware, engine, model) combo.

    All future hardware/engine paths (Apple Silicon, AMD, Ollama) implement
    this exact contract. See spec §4.4.
    """

    provider_id: str  # set by subclass

    @classmethod
    @abstractmethod
    def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities:
        """Return whether this provider can run on the given combo.

        Must be a pure inspection — no subprocess, no network, no Docker. Used
        by ``jarvis mine doctor`` and ``jarvis mine init`` for fast capability
        reporting.
        """

    @abstractmethod
    async def start(self, config: MiningConfig) -> None: ...

    @abstractmethod
    async def stop(self) -> None: ...

    @abstractmethod
    def is_running(self) -> bool: ...

    @abstractmethod
    def stats(self) -> MiningStats: ...


# ---------------------------------------------------------------------------
# Sidecar IO (see spec §5.3)
# ---------------------------------------------------------------------------


class Sidecar:
    """Read/write helpers for ``~/.openjarvis/runtime/mining.json``."""

    @staticmethod
    def write(path: Path, payload: dict[str, Any]) -> None:
        """Atomically write the sidecar JSON to ``path``."""
        path.parent.mkdir(parents=True, exist_ok=True)
        # Atomic write: tmp file + rename
        fd, tmp = tempfile.mkstemp(prefix=".mining-", dir=str(path.parent))
        try:
            with os.fdopen(fd, "w") as fh:
                json.dump(payload, fh, indent=2, sort_keys=True)
            os.replace(tmp, path)
        except Exception:
            try:
                os.unlink(tmp)
            except OSError:
                pass
            raise

    @staticmethod
    def read(path: Path) -> Optional[dict[str, Any]]:
        if not path.exists():
            return None
        try:
            return json.loads(path.read_text())
        except (json.JSONDecodeError, OSError):
            return None

    @staticmethod
    def remove(path: Path) -> None:
        try:
            path.unlink()
        except FileNotFoundError:
            pass
  • Step 7: Create mining/__init__.py
# src/openjarvis/mining/__init__.py
"""Pearl mining subsystem.

See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md``.

Provider modules are soft-imported below — each one fails gracefully if the
``mining-pearl`` (or future ``mining-pearl-mlx`` etc.) extra isn't installed.
"""

from __future__ import annotations

# Re-export the public ABCs and dataclasses for ergonomic imports.
from openjarvis.mining._stubs import (
    MiningCapabilities,
    MiningConfig,
    MiningProvider,
    MiningStats,
    PoolTarget,
    Sidecar,
    SoloTarget,
    SubmitTarget,
)

# Soft-import provider implementations to trigger registration. Each provider
# defines an idempotent ``ensure_registered()`` so it survives the autouse
# registry clear in ``tests/conftest.py``.
try:
    from openjarvis.mining import vllm_pearl  # noqa: F401

    vllm_pearl.ensure_registered()
except ImportError:
    pass

__all__ = [
    "MiningCapabilities",
    "MiningConfig",
    "MiningProvider",
    "MiningStats",
    "PoolTarget",
    "Sidecar",
    "SoloTarget",
    "SubmitTarget",
]
  • Step 8: Create mining/pools/__init__.py (reserved for v2)
# src/openjarvis/mining/pools/__init__.py
"""RESERVED for v2 pool support.

Do not add code here in v1. The v2 spec will define a ``PoolClient`` ABC and
``PoolClientRegistry`` peer to ``MinerRegistry``. Squatting on this path now
would create migration debt and pre-decide the v2 API. See spec §8.5.
"""

from __future__ import annotations
  • Step 9: Run tests to verify they pass
uv run pytest tests/mining/test_stubs.py -v

Expected: 9 PASS.

  • Step 10: Commit
git add src/openjarvis/mining/ tests/mining/__init__.py tests/mining/conftest.py tests/mining/test_stubs.py
git commit -m "feat(mining): add ABC, dataclasses, constants, sidecar IO"

Task 3 — MiningConfig integration in JarvisConfig + TOML parsing

Files:

  • Modify: src/openjarvis/core/config.py

  • Test: tests/core/test_config.py (existing — add new tests)

  • Test: tests/mining/fixtures/config_minimal.toml

  • Test: tests/mining/fixtures/config_pool_v2.toml

  • Step 1: Create the fixture TOML files

tests/mining/fixtures/config_minimal.toml:

[mining]
provider           = "vllm-pearl"
wallet_address     = "prl1qexampleaddress"
submit_target      = "solo"
fee_bps            = 0
fee_payout_address = ""

[mining.extra]
model                   = "pearl-ai/Llama-3.3-70B-Instruct-pearl"
pearld_rpc_url          = "http://localhost:44107"
pearld_rpc_user         = "rpcuser"
pearld_rpc_password_env = "PEARLD_RPC_PASSWORD"

tests/mining/fixtures/config_pool_v2.toml:

[mining]
provider       = "vllm-pearl"
wallet_address = "prl1qexampleaddress"
submit_target  = "pool:https://pool.openjarvis.ai/submit"

[mining.extra]
model                   = "pearl-ai/Llama-3.3-70B-Instruct-pearl"
pearld_rpc_url          = "http://localhost:44107"
pearld_rpc_user         = "rpcuser"
pearld_rpc_password_env = "PEARLD_RPC_PASSWORD"
  • Step 2: Write the failing tests

Add to tests/core/test_config.py:

def test_mining_config_absent_means_none(tmp_path):
    from openjarvis.core.config import load_config
    cfg_path = tmp_path / "config.toml"
    cfg_path.write_text("")  # empty config
    cfg = load_config(cfg_path)
    assert cfg.mining is None


def test_mining_config_solo_parsed(tmp_path):
    from pathlib import Path
    from openjarvis.core.config import load_config
    from openjarvis.mining._stubs import SoloTarget
    src = Path(__file__).parent.parent / "mining" / "fixtures" / "config_minimal.toml"
    target = tmp_path / "config.toml"
    target.write_text(src.read_text())
    cfg = load_config(target)
    assert cfg.mining is not None
    assert cfg.mining.provider == "vllm-pearl"
    assert cfg.mining.wallet_address == "prl1qexampleaddress"
    assert isinstance(cfg.mining.submit_target, SoloTarget)
    assert cfg.mining.submit_target.pearld_rpc_url == "http://localhost:44107"
    assert cfg.mining.fee_bps == 0
    assert cfg.mining.extra["model"] == "pearl-ai/Llama-3.3-70B-Instruct-pearl"


def test_mining_config_pool_parsed_as_pool_target(tmp_path):
    from pathlib import Path
    from openjarvis.core.config import load_config
    from openjarvis.mining._stubs import PoolTarget
    src = Path(__file__).parent.parent / "mining" / "fixtures" / "config_pool_v2.toml"
    target = tmp_path / "config.toml"
    target.write_text(src.read_text())
    cfg = load_config(target)
    assert isinstance(cfg.mining.submit_target, PoolTarget)
    assert cfg.mining.submit_target.url == "https://pool.openjarvis.ai/submit"
  • Step 3: Run tests to verify they fail
uv run pytest tests/core/test_config.py -k mining -v

Expected: 3 FAIL on cfg.mining attribute missing.

  • Step 4: Add mining field to JarvisConfig

In src/openjarvis/core/config.py, add an import near the top:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from openjarvis.mining._stubs import MiningConfig

Add the field to JarvisConfig (insert in alphabetical order — after memory_files, before operators):

    mining: Optional["MiningConfig"] = None
  • Step 5: Implement TOML parsing for [mining] section

In src/openjarvis/core/config.py, locate the existing load_config() function (around line 1541). Find the section that processes the loaded TOML data into the JarvisConfig (look for where it iterates the data dict and assigns to dataclass fields). Add a dedicated handler for mining because of the tagged-union parsing — it can't go through the generic walker.

Add this helper near the other _parse_* helpers in core/config.py:

def _parse_mining_section(data: dict) -> Optional["MiningConfig"]:
    """Parse the ``[mining]`` TOML section into a ``MiningConfig``.

    Returns None if the section is absent. Resolves the ``submit_target``
    string into a ``SoloTarget`` or ``PoolTarget`` tagged union.
    """
    if "mining" not in data:
        return None

    # Lazy import to avoid circular: mining/__init__.py imports core/config
    # transitively via _stubs, but only at runtime.
    from openjarvis.mining._stubs import MiningConfig, PoolTarget, SoloTarget

    section = data["mining"]
    extra = section.get("extra", {}) or {}

    target_str = section.get("submit_target", "solo")
    submit_target: Any
    if target_str == "solo":
        submit_target = SoloTarget(
            pearld_rpc_url=extra.get("pearld_rpc_url", "http://localhost:44107")
        )
    elif isinstance(target_str, str) and target_str.startswith("pool:"):
        submit_target = PoolTarget(url=target_str[len("pool:") :])
    else:
        raise ValueError(
            f"[mining].submit_target must be 'solo' or 'pool:<url>', got {target_str!r}"
        )

    return MiningConfig(
        provider=section["provider"],
        wallet_address=section["wallet_address"],
        submit_target=submit_target,
        fee_bps=int(section.get("fee_bps", 0)),
        fee_payout_address=section.get("fee_payout_address") or None,
        extra={k: v for k, v in extra.items()},
    )

In load_config(), after the other section processing and before returning cfg:

    cfg.mining = _parse_mining_section(data)
  • Step 6: Run tests to verify they pass
uv run pytest tests/core/test_config.py -k mining -v

Expected: 3 PASS.

  • Step 7: Commit
git add src/openjarvis/core/config.py tests/core/test_config.py tests/mining/fixtures/
git commit -m "feat(mining): integrate MiningConfig into JarvisConfig with TOML parsing"

Task 4 — Capability discovery (mining/_discovery.py)

Files:

  • Create: src/openjarvis/mining/_discovery.py

  • Test: tests/mining/test_discovery.py

  • Step 1: Write the failing tests

Create tests/mining/test_discovery.py:

"""Tests for mining/_discovery.py — capability detection matrix."""

from __future__ import annotations

from unittest.mock import MagicMock, patch


def test_detect_supported_on_h100(hopper_hw):
    from openjarvis.mining._discovery import detect_for_engine_model
    cap = detect_for_engine_model(
        hw=hopper_hw,
        engine_id="vllm",
        model="pearl-ai/Llama-3.3-70B-Instruct-pearl",
        provider_id="vllm-pearl",
    )
    assert cap.supported is True
    assert cap.reason is None


def test_detect_unsupported_on_ada_4090(ada_hw):
    from openjarvis.mining._discovery import detect_for_engine_model
    cap = detect_for_engine_model(
        hw=ada_hw,
        engine_id="vllm",
        model="pearl-ai/Llama-3.3-70B-Instruct-pearl",
        provider_id="vllm-pearl",
    )
    assert cap.supported is False
    assert "sm90" in cap.reason.lower() or "compute_capability" in cap.reason.lower()


def test_detect_unsupported_on_apple(apple_hw):
    from openjarvis.mining._discovery import detect_for_engine_model
    cap = detect_for_engine_model(
        hw=apple_hw,
        engine_id="mlx",
        model="pearl-ai/Llama-3.3-70B-Instruct-pearl",
        provider_id="vllm-pearl",
    )
    assert cap.supported is False
    assert cap.reason is not None  # specific reason — Apple Silicon route is Spec B


def test_detect_unsupported_for_non_vllm_engine(hopper_hw):
    from openjarvis.mining._discovery import detect_for_engine_model
    cap = detect_for_engine_model(
        hw=hopper_hw,
        engine_id="ollama",
        model="qwen3:8b",
        provider_id="vllm-pearl",
    )
    assert cap.supported is False
    assert "vllm" in cap.reason.lower() or "engine" in cap.reason.lower()


def test_detect_unsupported_for_non_pearl_model(hopper_hw):
    from openjarvis.mining._discovery import detect_for_engine_model
    cap = detect_for_engine_model(
        hw=hopper_hw,
        engine_id="vllm",
        model="meta-llama/Llama-3.3-70B-Instruct",  # NOT the -pearl variant
        provider_id="vllm-pearl",
    )
    assert cap.supported is False
    assert "pearl" in cap.reason.lower()


def test_detect_unsupported_for_low_vram():
    from openjarvis.mining._discovery import detect_for_engine_model
    from openjarvis.core.config import GpuInfo, HardwareInfo
    hw = HardwareInfo(
        platform="linux",
        gpu=GpuInfo(
            vendor="nvidia",
            name="NVIDIA H100 PCIe-40GB",
            vram_gb=40.0,  # below 70 GB threshold
            compute_capability="9.0",
            count=1,
        ),
    )
    cap = detect_for_engine_model(
        hw=hw, engine_id="vllm", model="pearl-ai/Llama-3.3-70B-Instruct-pearl",
        provider_id="vllm-pearl",
    )
    assert cap.supported is False
    assert "vram" in cap.reason.lower() or "memory" in cap.reason.lower()


def test_check_docker_available_true():
    from openjarvis.mining._discovery import check_docker_available
    with patch("openjarvis.mining._discovery._docker_client") as fake:
        fake.return_value.ping.return_value = True
        fake.return_value.version.return_value = {"Version": "24.0.7"}
        ok, info = check_docker_available()
        assert ok is True
        assert "24.0.7" in info


def test_check_docker_available_false_when_daemon_down():
    from openjarvis.mining._discovery import check_docker_available
    with patch("openjarvis.mining._discovery._docker_client") as fake:
        fake.side_effect = Exception("Cannot connect to the Docker daemon")
        ok, info = check_docker_available()
        assert ok is False
        assert "daemon" in info.lower() or "connect" in info.lower()


def test_check_disk_free_passes(tmp_path):
    from openjarvis.mining._discovery import check_disk_free
    with patch("openjarvis.mining._discovery.shutil.disk_usage") as du:
        # 500 GB free
        du.return_value = MagicMock(total=1_000_000_000_000, used=500_000_000_000, free=500_000_000_000)
        ok, info = check_disk_free(tmp_path)
        assert ok is True


def test_check_disk_free_fails_below_threshold(tmp_path):
    from openjarvis.mining._discovery import check_disk_free
    with patch("openjarvis.mining._discovery.shutil.disk_usage") as du:
        du.return_value = MagicMock(total=1_000_000_000_000, used=950_000_000_000, free=50_000_000_000)
        ok, info = check_disk_free(tmp_path)
        assert ok is False


def test_check_pearld_reachable_true():
    from openjarvis.mining._discovery import check_pearld_reachable
    with patch("openjarvis.mining._discovery.httpx.post") as post:
        post.return_value.status_code = 200
        post.return_value.json.return_value = {"result": {"blocks": 442107, "headers": 442107}}
        ok, info = check_pearld_reachable("http://localhost:44107", "user", "pass")
        assert ok is True
        assert "442107" in info


def test_check_pearld_reachable_false_on_connection_error():
    from openjarvis.mining._discovery import check_pearld_reachable
    import httpx
    with patch("openjarvis.mining._discovery.httpx.post") as post:
        post.side_effect = httpx.ConnectError("connection refused")
        ok, info = check_pearld_reachable("http://localhost:44107", "user", "pass")
        assert ok is False


def test_check_wallet_address_format_valid():
    from openjarvis.mining._discovery import check_wallet_address_format
    ok, info = check_wallet_address_format("prl1qexampleaddress0123456789")
    assert ok is True


def test_check_wallet_address_format_invalid():
    from openjarvis.mining._discovery import check_wallet_address_format
    ok, info = check_wallet_address_format("not-a-pearl-address")
    assert ok is False
  • Step 2: Run tests to verify they fail
uv run pytest tests/mining/test_discovery.py -v

Expected: ALL fail with ImportError.

  • Step 3: Create _discovery.py
# src/openjarvis/mining/_discovery.py
"""Capability detection for mining providers.

Each function answers a single yes/no question and returns ``(ok: bool,
info: str)`` where ``info`` is a short human-readable explanation surfaced
verbatim by ``jarvis mine doctor``.
"""

from __future__ import annotations

import shutil
from pathlib import Path
from typing import Optional, Tuple

import httpx

from openjarvis.core.config import HardwareInfo
from openjarvis.mining._stubs import MiningCapabilities

# ---------------------------------------------------------------------------
# Constants for the v1 vllm-pearl provider
# ---------------------------------------------------------------------------

REQUIRED_COMPUTE_CAPABILITY = "9.0"  # sm_90a — Hopper
REQUIRED_VRAM_GB = 70.0
SUPPORTED_VLLM_ENGINE_IDS = frozenset({"vllm"})


def detect_for_engine_model(
    *,
    hw: HardwareInfo,
    engine_id: str,
    model: str,
    provider_id: str,
) -> MiningCapabilities:
    """Capability matrix for the ``vllm-pearl`` provider.

    Pure inspection. No subprocess, no Docker, no network. Used by
    ``jarvis mine doctor`` and ``jarvis mine init``.
    """
    if provider_id != "vllm-pearl":
        return MiningCapabilities(
            False, reason=f"unknown provider {provider_id!r}"
        )

    # Engine
    if engine_id not in SUPPORTED_VLLM_ENGINE_IDS:
        return MiningCapabilities(
            False,
            reason=f"engine '{engine_id}' has no Pearl plugin in v1; use vllm",
        )

    # Hardware
    if hw.gpu is None:
        return MiningCapabilities(False, reason="no GPU detected")
    if hw.gpu.vendor != "nvidia":
        return MiningCapabilities(
            False,
            reason=f"vllm-pearl requires NVIDIA Hopper; detected {hw.gpu.vendor!r}. "
            f"Apple Silicon support tracked in Spec B.",
        )
    if not hw.gpu.compute_capability.startswith("9.0"):
        return MiningCapabilities(
            False,
            reason=f"needs compute_capability 9.0 (sm_90a / H100/H200); detected "
            f"{hw.gpu.compute_capability!r} ({hw.gpu.name})",
        )
    if hw.gpu.vram_gb < REQUIRED_VRAM_GB:
        return MiningCapabilities(
            False,
            reason=f"needs ≥{REQUIRED_VRAM_GB:.0f} GB VRAM for the Pearl 70B model; "
            f"detected {hw.gpu.vram_gb:.0f} GB",
        )

    # Model
    if "-pearl" not in model.lower():
        return MiningCapabilities(
            False,
            reason=f"model {model!r} has no Pearl-blessed variant — use a "
            f"'pearl-ai/*-pearl' model",
        )

    return MiningCapabilities(supported=True)


# ---------------------------------------------------------------------------
# Doctor checks (one per row of `jarvis mine doctor` output)
# ---------------------------------------------------------------------------


def _docker_client():  # pragma: no cover - trivial wrapper, mocked in tests
    import docker

    return docker.from_env()


def check_docker_available() -> Tuple[bool, str]:
    try:
        c = _docker_client()
        c.ping()
        ver = c.version().get("Version", "unknown")
        return True, f"running {ver}"
    except Exception as e:  # noqa: BLE001 - intentionally broad
        return False, str(e).splitlines()[0]


def check_disk_free(path: Path) -> Tuple[bool, str]:
    from openjarvis.mining._constants import MIN_FREE_DISK_GB

    usage = shutil.disk_usage(path)
    free_gb = usage.free / (1024**3)
    if free_gb < MIN_FREE_DISK_GB:
        return False, f"only {free_gb:.0f} GB free (need ≥{MIN_FREE_DISK_GB} GB)"
    return True, f"{free_gb:.0f} GB free"


def check_pearld_reachable(
    url: str, user: str, password: str
) -> Tuple[bool, str]:
    """Probe pearld via JSON-RPC ``getblockchaininfo``."""
    try:
        resp = httpx.post(
            url,
            json={"jsonrpc": "1.0", "id": "ojprobe", "method": "getblockchaininfo", "params": []},
            auth=(user, password),
            timeout=5.0,
        )
        if resp.status_code != 200:
            return False, f"HTTP {resp.status_code}"
        data = resp.json()
        result = data.get("result") or {}
        blocks = result.get("blocks", "?")
        headers = result.get("headers", "?")
        synced = blocks == headers
        marker = "synced" if synced else f"syncing ({blocks}/{headers})"
        return True, f"block height {blocks} ({marker})"
    except httpx.ConnectError as e:
        return False, f"connection refused: {e}"
    except Exception as e:  # noqa: BLE001
        return False, str(e).splitlines()[0]


def check_wallet_address_format(address: str) -> Tuple[bool, str]:
    """Pearl Taproot addresses begin with ``prl1q...``.

    We do *not* attempt to validate the bech32 checksum — that's a stronger
    contract that may shift between Pearl revs. Format check only.
    """
    if not address:
        return False, "empty"
    if not address.startswith("prl1q"):
        return False, f"expected 'prl1q...' prefix; got {address[:6]!r}"
    if len(address) < 14:
        return False, f"too short ({len(address)} chars)"
    return True, "format ok"
  • Step 4: Run tests to verify they pass
uv run pytest tests/mining/test_discovery.py -v

Expected: 13 PASS.

  • Step 5: Commit
git add src/openjarvis/mining/_discovery.py tests/mining/test_discovery.py
git commit -m "feat(mining): capability detection + doctor checks"

Task 5 — PearlDockerLauncher image acquisition

Files:

  • Create: src/openjarvis/mining/_docker.py

  • Test: tests/mining/test_docker.py

  • Step 1: Write the failing tests for ensure_image()

Create tests/mining/test_docker.py:

"""Tests for mining/_docker.py — Docker SDK orchestration via mocks."""

from __future__ import annotations

from unittest.mock import MagicMock, patch


def test_ensure_image_already_local():
    from openjarvis.mining._docker import PearlDockerLauncher
    fake = MagicMock()
    fake.images.get.return_value = MagicMock(id="sha256:abc", tags=["openjarvis/pearl-miner:main"])
    launcher = PearlDockerLauncher(client=fake)
    out = launcher.ensure_image("openjarvis/pearl-miner:main")
    assert out == "openjarvis/pearl-miner:main"
    fake.images.get.assert_called_once_with("openjarvis/pearl-miner:main")
    fake.images.pull.assert_not_called()


def test_ensure_image_pulls_if_published():
    from openjarvis.mining._docker import PearlDockerLauncher
    import docker.errors as derr
    fake = MagicMock()
    fake.images.get.side_effect = derr.ImageNotFound("nope")
    fake.images.pull.return_value = MagicMock(id="sha256:def")
    launcher = PearlDockerLauncher(client=fake)
    out = launcher.ensure_image("registry.example/pearl-miner:1.0")
    assert out == "registry.example/pearl-miner:1.0"
    fake.images.pull.assert_called_once_with("registry.example/pearl-miner:1.0")


def test_ensure_image_falls_back_to_build_for_default_tag():
    from openjarvis.mining._docker import PearlDockerLauncher
    from openjarvis.mining._constants import PEARL_IMAGE_TAG
    import docker.errors as derr
    fake = MagicMock()
    fake.images.get.side_effect = derr.ImageNotFound("nope")
    fake.images.pull.side_effect = derr.NotFound("registry refused")
    launcher = PearlDockerLauncher(client=fake)
    with patch.object(launcher, "_clone_pearl_repo") as clone, patch.object(
        launcher, "_docker_build"
    ) as build:
        clone.return_value = "/tmp/pearl-cache"
        build.return_value = PEARL_IMAGE_TAG
        out = launcher.ensure_image(PEARL_IMAGE_TAG)
        assert out == PEARL_IMAGE_TAG
        clone.assert_called_once()
        build.assert_called_once()


def test_ensure_image_errors_when_non_default_tag_missing():
    from openjarvis.mining._docker import PearlDockerLauncher, ImageAcquisitionError
    import docker.errors as derr
    import pytest
    fake = MagicMock()
    fake.images.get.side_effect = derr.ImageNotFound("nope")
    fake.images.pull.side_effect = derr.NotFound("registry refused")
    launcher = PearlDockerLauncher(client=fake)
    with pytest.raises(ImageAcquisitionError) as ei:
        launcher.ensure_image("user/custom-image:tag")
    assert "user/custom-image:tag" in str(ei.value)
  • Step 2: Run tests to verify they fail
uv run pytest tests/mining/test_docker.py -v

Expected: FAIL with ImportError.

  • Step 3: Implement the launcher's image-acquisition path

Create src/openjarvis/mining/_docker.py:

# src/openjarvis/mining/_docker.py
"""Pearl Docker container orchestration.

See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md``
section 7 for the design.
"""

from __future__ import annotations

import os
import subprocess
from pathlib import Path
from typing import Any, Optional

from openjarvis.mining._constants import (
    PEARL_CACHE_DIR,
    PEARL_IMAGE_TAG,
    PEARL_PINNED_REF,
    PEARL_REPO,
)


class ImageAcquisitionError(RuntimeError):
    """Raised when an image can be neither found, pulled, nor built."""


class PearlDockerLauncher:
    """Orchestrates the Pearl miner container.

    Construct with a ``docker.DockerClient`` (real or mocked).
    """

    def __init__(self, client: Any):
        self._client = client
        self._container: Optional[Any] = None

    # -----------------------------------------------------------------
    # Image acquisition
    # -----------------------------------------------------------------

    def ensure_image(self, tag: str) -> str:
        """Resolve ``tag`` to a usable local image, building if necessary.

        Selection order (see spec §7.2):
        1. Image present locally → use it.
        2. Image pullable from a registry → pull and use.
        3. ``tag`` matches OJ's default → clone Pearl + ``docker build``.
        4. Otherwise → ``ImageAcquisitionError``.
        """
        import docker.errors as derr

        try:
            self._client.images.get(tag)
            return tag
        except derr.ImageNotFound:
            pass

        try:
            self._client.images.pull(tag)
            return tag
        except (derr.NotFound, derr.APIError):
            pass

        if tag == PEARL_IMAGE_TAG:
            cache = self._clone_pearl_repo()
            return self._docker_build(cache, tag)

        raise ImageAcquisitionError(
            f"image {tag!r} not present locally, not pullable, and not OJ's "
            f"default tag (no build fallback). Either build it manually with "
            f"`docker buildx build -t {tag} -f miner/vllm-miner/Dockerfile .` "
            f"from the Pearl repo, or set [mining.extra].docker_image_tag to "
            f"the OJ default ({PEARL_IMAGE_TAG}) to enable the build fallback."
        )

    def _clone_pearl_repo(self) -> Path:
        """Clone Pearl at the pinned ref into the OJ cache."""
        PEARL_CACHE_DIR.parent.mkdir(parents=True, exist_ok=True)
        if PEARL_CACHE_DIR.exists():
            subprocess.run(
                ["git", "fetch", "origin", PEARL_PINNED_REF],
                cwd=str(PEARL_CACHE_DIR),
                check=True,
            )
            subprocess.run(
                ["git", "checkout", PEARL_PINNED_REF],
                cwd=str(PEARL_CACHE_DIR),
                check=True,
            )
        else:
            subprocess.run(
                ["git", "clone", "--branch", PEARL_PINNED_REF, PEARL_REPO, str(PEARL_CACHE_DIR)],
                check=True,
            )
        return PEARL_CACHE_DIR

    def _docker_build(self, repo_path: Path, tag: str) -> str:
        """Run ``docker buildx build`` with Pearl's Dockerfile against the monorepo."""
        # Build context must be the repo root; Dockerfile is at miner/vllm-miner/Dockerfile.
        cmd = [
            "docker",
            "buildx",
            "build",
            "-t",
            tag,
            "-f",
            "miner/vllm-miner/Dockerfile",
            ".",
        ]
        subprocess.run(cmd, cwd=str(repo_path), check=True)
        return tag
  • Step 4: Run tests to verify they pass
uv run pytest tests/mining/test_docker.py -v

Expected: 4 PASS.

  • Step 5: Commit
git add src/openjarvis/mining/_docker.py tests/mining/test_docker.py
git commit -m "feat(mining): PearlDockerLauncher image acquisition (pull-or-build)"

Task 6 — PearlDockerLauncher container lifecycle

Files:

  • Modify: src/openjarvis/mining/_docker.py

  • Modify: tests/mining/test_docker.py

  • Step 1: Write failing tests for start() / stop() / is_running() / get_logs()

Append to tests/mining/test_docker.py:

import os
import pytest


@pytest.fixture
def _env_password(monkeypatch):
    monkeypatch.setenv("PEARLD_RPC_PASSWORD", "secret123")


def test_launcher_start_calls_run_with_expected_kwargs(_env_password):
    from openjarvis.mining._docker import PearlDockerLauncher
    from openjarvis.mining._stubs import MiningConfig, SoloTarget
    fake = MagicMock()
    fake.containers.run.return_value = MagicMock(id="cid-1", status="running")
    launcher = PearlDockerLauncher(client=fake)
    cfg = MiningConfig(
        provider="vllm-pearl",
        wallet_address="prl1qaaa",
        submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"),
        extra={
            "docker_image_tag": "openjarvis/pearl-miner:main",
            "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl",
            "vllm_port": 8000,
            "gpu_memory_utilization": 0.9,
            "max_model_len": 8192,
            "pearld_rpc_url": "http://localhost:44107",
            "pearld_rpc_user": "rpcuser",
            "pearld_rpc_password_env": "PEARLD_RPC_PASSWORD",
            "hf_token_env": "HF_TOKEN",
        },
    )
    container = launcher.start(cfg, image="openjarvis/pearl-miner:main")
    assert container.id == "cid-1"
    fake.containers.run.assert_called_once()
    kwargs = fake.containers.run.call_args.kwargs
    # Image
    assert kwargs["image"] == "openjarvis/pearl-miner:main"
    # Command starts with the model name (positional), then args
    assert kwargs["command"][0] == "pearl-ai/Llama-3.3-70B-Instruct-pearl"
    assert "--gpu-memory-utilization" in kwargs["command"]
    # Restart policy
    assert kwargs["restart_policy"]["Name"] == "unless-stopped"
    # Env contains the password (resolved from env var name)
    assert kwargs["environment"]["PEARLD_RPC_PASSWORD"] == "secret123"
    # Mining address pass-through
    assert kwargs["environment"]["PEARLD_MINING_ADDRESS"] == "prl1qaaa"
    # MINER_RPC_TRANSPORT set so OJ can poll port 8337
    assert kwargs["environment"]["MINER_RPC_TRANSPORT"] == "tcp"
    # GPU device request
    assert kwargs["device_requests"]


def test_launcher_stop_calls_container_stop_and_remove():
    from openjarvis.mining._docker import PearlDockerLauncher
    fake_client = MagicMock()
    fake_container = MagicMock()
    launcher = PearlDockerLauncher(client=fake_client)
    launcher._container = fake_container
    launcher.stop()
    fake_container.stop.assert_called_once()


def test_launcher_is_running_when_container_running():
    from openjarvis.mining._docker import PearlDockerLauncher
    fake_client = MagicMock()
    fake_container = MagicMock(status="running")
    fake_container.reload.return_value = None
    launcher = PearlDockerLauncher(client=fake_client)
    launcher._container = fake_container
    assert launcher.is_running() is True


def test_launcher_is_running_false_when_container_exited():
    from openjarvis.mining._docker import PearlDockerLauncher
    fake_client = MagicMock()
    fake_container = MagicMock()
    fake_container.reload.return_value = None
    fake_container.status = "exited"
    launcher = PearlDockerLauncher(client=fake_client)
    launcher._container = fake_container
    assert launcher.is_running() is False


def test_launcher_get_logs_returns_decoded_string():
    from openjarvis.mining._docker import PearlDockerLauncher
    fake_client = MagicMock()
    fake_container = MagicMock()
    fake_container.logs.return_value = b"hello\nworld\n"
    launcher = PearlDockerLauncher(client=fake_client)
    launcher._container = fake_container
    assert "hello" in launcher.get_logs(tail=100)


def test_launcher_start_errors_when_password_env_missing():
    from openjarvis.mining._docker import PearlDockerLauncher, ConfigurationError
    from openjarvis.mining._stubs import MiningConfig, SoloTarget
    fake = MagicMock()
    launcher = PearlDockerLauncher(client=fake)
    cfg = MiningConfig(
        provider="vllm-pearl",
        wallet_address="prl1qaaa",
        submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"),
        extra={
            "docker_image_tag": "openjarvis/pearl-miner:main",
            "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl",
            "vllm_port": 8000,
            "gpu_memory_utilization": 0.9,
            "pearld_rpc_url": "http://localhost:44107",
            "pearld_rpc_user": "rpcuser",
            "pearld_rpc_password_env": "DOES_NOT_EXIST_IN_ENV",
        },
    )
    with pytest.raises(ConfigurationError) as ei:
        launcher.start(cfg, image="openjarvis/pearl-miner:main")
    assert "DOES_NOT_EXIST_IN_ENV" in str(ei.value)
  • Step 2: Run tests to verify they fail
uv run pytest tests/mining/test_docker.py -v

Expected: 6 new FAIL.

  • Step 3: Implement start(), stop(), is_running(), get_logs()

Append to src/openjarvis/mining/_docker.py:

class ConfigurationError(RuntimeError):
    """Raised when required env vars or config fields are missing."""


    # ----- in PearlDockerLauncher class, append these methods -----

    def start(self, config: "MiningConfig", image: str) -> Any:
        """Launch the Pearl miner container.

        ``image`` must already be resolved by ``ensure_image()``.
        Returns the docker.models.containers.Container object.
        """
        from openjarvis.mining._stubs import MiningConfig  # noqa: F401  (typing only)

        extra = config.extra
        # Resolve secret env vars (we hold the *name*, not the value).
        password_env = extra.get("pearld_rpc_password_env", "PEARLD_RPC_PASSWORD")
        password = os.environ.get(password_env)
        if password is None:
            raise ConfigurationError(
                f"environment variable {password_env!r} is not set; "
                f"set it before running `jarvis mine start`"
            )

        hf_token_env = extra.get("hf_token_env", "HF_TOKEN")
        hf_token = os.environ.get(hf_token_env, "")

        model = extra.get("model", "pearl-ai/Llama-3.3-70B-Instruct-pearl")
        vllm_port = int(extra.get("vllm_port", 8000))
        gpu_mem = float(extra.get("gpu_memory_utilization", 0.9))
        max_len = int(extra.get("max_model_len", 8192))

        command = [
            model,
            "--host", "0.0.0.0",
            "--port", str(vllm_port),
            "--gpu-memory-utilization", str(gpu_mem),
            "--enforce-eager",
            "--max-model-len", str(max_len),
        ]

        environment = {
            "PEARLD_RPC_URL": extra.get("pearld_rpc_url", "http://localhost:44107"),
            "PEARLD_RPC_USER": extra.get("pearld_rpc_user", "rpcuser"),
            "PEARLD_RPC_PASSWORD": password,
            "PEARLD_MINING_ADDRESS": config.wallet_address,
            "HF_TOKEN": hf_token,
            "MINER_RPC_TRANSPORT": "tcp",
        }

        # Dynamic import so tests don't need the real `docker` package shape.
        try:
            from docker.types import DeviceRequest
            device_requests = [DeviceRequest(count=-1, capabilities=[["gpu"]])]
        except ImportError:  # pragma: no cover
            device_requests = None

        hf_cache = Path.home() / ".cache" / "huggingface"
        volumes = {
            str(hf_cache): {"bind": "/root/.cache/huggingface", "mode": "rw"},
        }

        self._container = self._client.containers.run(
            image=image,
            command=command,
            name="openjarvis-pearl-miner",
            detach=True,
            auto_remove=False,
            restart_policy={"Name": "unless-stopped"},
            device_requests=device_requests,
            shm_size="8g",
            network_mode="host",
            volumes=volumes,
            environment=environment,
        )
        return self._container

    def stop(self, timeout: int = 30) -> None:
        if self._container is None:
            return
        try:
            self._container.stop(timeout=timeout)
        except Exception:  # noqa: BLE001 - best-effort
            pass
        self._container = None

    def is_running(self) -> bool:
        if self._container is None:
            return False
        try:
            self._container.reload()
        except Exception:  # noqa: BLE001
            return False
        return getattr(self._container, "status", "") == "running"

    def get_logs(self, tail: int = 200) -> str:
        if self._container is None:
            return ""
        raw = self._container.logs(tail=tail)
        if isinstance(raw, bytes):
            return raw.decode("utf-8", errors="replace")
        return str(raw)
  • Step 4: Run tests to verify they pass
uv run pytest tests/mining/test_docker.py -v

Expected: 10 PASS.

  • Step 5: Commit
git add src/openjarvis/mining/_docker.py tests/mining/test_docker.py
git commit -m "feat(mining): PearlDockerLauncher container lifecycle (start/stop/is_running/logs)"

Task 7 — Gateway metrics adapter

Files:

  • Modify: src/openjarvis/mining/vllm_pearl.py (creating in next task — for now create the parser as a helper)
  • Create: src/openjarvis/mining/_metrics.py
  • Create: tests/mining/fixtures/gateway_metrics_sample.txt
  • Create: tests/mining/test_metrics.py

Note for the implementer: The fixture file in this task is a placeholder with the metric names the spec assumes (see spec §8.2 table). At implementation time, replace it with real Prometheus output captured from a Pearl gateway run against the pinned Pearl ref. Document the capture procedure in tests/mining/fixtures/README.md.

  • Step 1: Create the (placeholder) Prometheus fixture

tests/mining/fixtures/gateway_metrics_sample.txt:

# HELP pearl_gateway_shares_submitted_total Total mining shares submitted.
# TYPE pearl_gateway_shares_submitted_total counter
pearl_gateway_shares_submitted_total 12345
# HELP pearl_gateway_shares_accepted_total Total mining shares accepted by pearld.
# TYPE pearl_gateway_shares_accepted_total counter
pearl_gateway_shares_accepted_total 12300
# HELP pearl_gateway_blocks_found_total Total blocks found.
# TYPE pearl_gateway_blocks_found_total counter
pearl_gateway_blocks_found_total 7
# HELP pearl_gateway_last_share_timestamp Unix timestamp of last share submission.
# TYPE pearl_gateway_last_share_timestamp gauge
pearl_gateway_last_share_timestamp 1714867500
# HELP pearl_gateway_errors_total Total errors observed by the gateway.
# TYPE pearl_gateway_errors_total counter
pearl_gateway_errors_total 0
# HELP process_start_time_seconds Process start time (Unix seconds).
# TYPE process_start_time_seconds gauge
process_start_time_seconds 1714865000

Also create tests/mining/fixtures/README.md:

# Mining test fixtures

`gateway_metrics_sample.txt` is captured Prometheus output from a real
Pearl gateway run against the pinned Pearl ref. Re-capture by:

1. Run the Pearl Docker image on an H100 host per the spec §7.4 launch shape.
2. `curl http://127.0.0.1:8339/metrics > gateway_metrics_sample.txt` once
   the gateway is healthy and at least 10 shares have been submitted.
3. Strip any cardinality bombs (per-prompt or per-block-time histograms)
   that bloat the file.
4. Commit, citing the Pearl commit/tag the capture was taken against.

If Pearl renames metrics, update `mining/_metrics.py::PROM_*` constants and
re-capture.
  • Step 2: Write the failing test

Create tests/mining/test_metrics.py:

"""Tests for mining/_metrics.py — Prometheus → MiningStats adapter."""

from __future__ import annotations

from pathlib import Path


FIXTURE = Path(__file__).parent / "fixtures" / "gateway_metrics_sample.txt"


def test_parse_gateway_metrics_full():
    from openjarvis.mining._metrics import parse_gateway_metrics
    text = FIXTURE.read_text()
    stats = parse_gateway_metrics(text, provider_id="vllm-pearl")
    assert stats.provider_id == "vllm-pearl"
    assert stats.shares_submitted == 12345
    assert stats.shares_accepted == 12300
    assert stats.blocks_found == 7
    assert stats.last_share_at == 1714867500.0
    # Uptime computed as now - process_start_time, but not asserted exactly.
    assert stats.uptime_seconds >= 0


def test_parse_gateway_metrics_missing_metrics_zero_fills():
    from openjarvis.mining._metrics import parse_gateway_metrics
    stats = parse_gateway_metrics("# empty exposition\n", provider_id="vllm-pearl")
    assert stats.shares_submitted == 0
    assert stats.shares_accepted == 0
    assert stats.blocks_found == 0
    assert stats.last_share_at is None


def test_parse_gateway_metrics_ignores_comment_lines():
    from openjarvis.mining._metrics import parse_gateway_metrics
    stats = parse_gateway_metrics(
        "# HELP something\n# TYPE something counter\nsomething 99\n",
        provider_id="vllm-pearl",
    )
    assert stats.shares_submitted == 0  # 'something' isn't a Pearl metric
  • Step 3: Run tests to verify they fail
uv run pytest tests/mining/test_metrics.py -v

Expected: 3 FAIL.

  • Step 4: Implement the adapter

Create src/openjarvis/mining/_metrics.py:

# src/openjarvis/mining/_metrics.py
"""Pearl gateway Prometheus → MiningStats adapter.

The gateway exposes ``:8339/metrics`` in plain Prometheus exposition format.
This is the most stable contract Pearl publishes; deeper RPC introspection
on ``:8337`` is deferred to v2 (where it's needed for pool share accounting).

If Pearl renames metrics, change the ``PROM_*`` constants here — that's the
only place the metric names live.
"""

from __future__ import annotations

import logging
import time
from typing import Optional

from openjarvis.mining._stubs import MiningStats

log = logging.getLogger(__name__)

# Pearl metric names. See spec §8.2 — verify against the fixture committed
# in tests/mining/fixtures/gateway_metrics_sample.txt.
PROM_SHARES_SUBMITTED = "pearl_gateway_shares_submitted_total"
PROM_SHARES_ACCEPTED = "pearl_gateway_shares_accepted_total"
PROM_BLOCKS_FOUND = "pearl_gateway_blocks_found_total"
PROM_LAST_SHARE_TS = "pearl_gateway_last_share_timestamp"
PROM_ERRORS_TOTAL = "pearl_gateway_errors_total"
PROM_PROCESS_START = "process_start_time_seconds"


def _parse_simple_metric(text: str, name: str) -> Optional[float]:
    """Find the first occurrence of a simple, label-less metric.

    Lines look like ``metric_name 12345`` or ``metric_name{label="x"} 12345``.
    For the v1 adapter we ignore labels and take the first non-comment match.
    """
    for line in text.splitlines():
        if line.startswith("#") or not line.strip():
            continue
        # Split on the first whitespace; the metric name is everything up to
        # an optional `{...}` label block.
        head, _, value = line.partition(" ")
        head = head.split("{", 1)[0]
        if head == name:
            try:
                return float(value.strip())
            except ValueError:
                return None
    return None


def parse_gateway_metrics(text: str, *, provider_id: str) -> MiningStats:
    """Convert a Prometheus exposition payload into a ``MiningStats``."""
    submitted = _parse_simple_metric(text, PROM_SHARES_SUBMITTED) or 0.0
    accepted = _parse_simple_metric(text, PROM_SHARES_ACCEPTED) or 0.0
    blocks = _parse_simple_metric(text, PROM_BLOCKS_FOUND) or 0.0
    last_share_ts = _parse_simple_metric(text, PROM_LAST_SHARE_TS)
    errors = _parse_simple_metric(text, PROM_ERRORS_TOTAL) or 0.0
    proc_start = _parse_simple_metric(text, PROM_PROCESS_START)

    uptime = 0.0
    if proc_start is not None:
        uptime = max(0.0, time.time() - proc_start)

    last_error: Optional[str] = None
    if errors > 0:
        last_error = f"{int(errors)} gateway errors observed"

    return MiningStats(
        provider_id=provider_id,
        shares_submitted=int(submitted),
        shares_accepted=int(accepted),
        blocks_found=int(blocks),
        # Hashrate as a derived rate is not meaningful from a single snapshot;
        # the v1.x persistent collector will compute it. v1 leaves it 0.
        hashrate=0.0,
        uptime_seconds=uptime,
        last_share_at=last_share_ts,
        last_error=last_error,
    )
  • Step 5: Run tests to verify they pass
uv run pytest tests/mining/test_metrics.py -v

Expected: 3 PASS.

  • Step 6: Commit
git add src/openjarvis/mining/_metrics.py tests/mining/test_metrics.py tests/mining/fixtures/
git commit -m "feat(mining): Prometheus gateway metrics adapter"

Task 8 — VllmPearlProvider (the only v1 provider)

Files:

  • Create: src/openjarvis/mining/vllm_pearl.py

  • Create: tests/mining/test_vllm_pearl.py

  • Step 1: Write the failing tests

Create tests/mining/test_vllm_pearl.py:

"""End-to-end tests for VllmPearlProvider with mocked Docker + filesystem."""

from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

import pytest


def test_vllm_pearl_detect_supported_on_h100(hopper_hw):
    from openjarvis.mining.vllm_pearl import VllmPearlProvider
    cap = VllmPearlProvider.detect(
        hopper_hw, engine_id="vllm",
        model="pearl-ai/Llama-3.3-70B-Instruct-pearl",
    )
    assert cap.supported is True


def test_vllm_pearl_detect_unsupported_on_apple(apple_hw):
    from openjarvis.mining.vllm_pearl import VllmPearlProvider
    cap = VllmPearlProvider.detect(
        apple_hw, engine_id="mlx",
        model="pearl-ai/Llama-3.3-70B-Instruct-pearl",
    )
    assert cap.supported is False


@pytest.mark.asyncio
async def test_vllm_pearl_start_writes_sidecar(tmp_path, monkeypatch):
    from openjarvis.mining.vllm_pearl import VllmPearlProvider
    from openjarvis.mining._stubs import MiningConfig, SoloTarget, Sidecar

    sidecar_path = tmp_path / "mining.json"
    monkeypatch.setattr(
        "openjarvis.mining.vllm_pearl.SIDECAR_PATH", sidecar_path
    )
    monkeypatch.setenv("PEARLD_RPC_PASSWORD", "x")

    fake_client = MagicMock()
    fake_container = MagicMock(id="cid-xyz")
    fake_container.status = "running"
    fake_client.containers.run.return_value = fake_container
    # ensure_image: image already present
    fake_client.images.get.return_value = MagicMock(id="sha256:abc")

    cfg = MiningConfig(
        provider="vllm-pearl",
        wallet_address="prl1qaaa",
        submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"),
        extra={
            "docker_image_tag": "openjarvis/pearl-miner:main",
            "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl",
            "vllm_port": 8000,
            "gateway_port": 8337,
            "gateway_metrics_port": 8339,
            "gpu_memory_utilization": 0.9,
            "max_model_len": 8192,
            "pearld_rpc_url": "http://localhost:44107",
            "pearld_rpc_user": "rpcuser",
            "pearld_rpc_password_env": "PEARLD_RPC_PASSWORD",
        },
    )

    provider = VllmPearlProvider(docker_client=fake_client)
    await provider.start(cfg)

    assert sidecar_path.exists()
    payload = json.loads(sidecar_path.read_text())
    assert payload["provider"] == "vllm-pearl"
    assert payload["vllm_endpoint"].endswith(":8000/v1")
    assert payload["gateway_url"].endswith(":8337")
    assert payload["gateway_metrics_url"].endswith(":8339")
    assert payload["wallet_address"] == "prl1qaaa"
    assert payload["container_id"] == "cid-xyz"
    assert "started_at" in payload
    # Sidecar omits secrets
    assert "PEARLD_RPC_PASSWORD" not in json.dumps(payload)


@pytest.mark.asyncio
async def test_vllm_pearl_start_pool_target_raises_not_implemented(monkeypatch, tmp_path):
    from openjarvis.mining.vllm_pearl import VllmPearlProvider
    from openjarvis.mining._stubs import MiningConfig, PoolTarget

    sidecar_path = tmp_path / "mining.json"
    monkeypatch.setattr(
        "openjarvis.mining.vllm_pearl.SIDECAR_PATH", sidecar_path
    )

    cfg = MiningConfig(
        provider="vllm-pearl",
        wallet_address="prl1qaaa",
        submit_target=PoolTarget(url="https://pool.openjarvis.ai/submit"),
        extra={"docker_image_tag": "openjarvis/pearl-miner:main"},
    )
    provider = VllmPearlProvider(docker_client=MagicMock())
    with pytest.raises(NotImplementedError) as ei:
        await provider.start(cfg)
    assert "v2" in str(ei.value).lower() or "pool" in str(ei.value).lower()


@pytest.mark.asyncio
async def test_vllm_pearl_stop_removes_sidecar(tmp_path, monkeypatch, written_sidecar):
    from openjarvis.mining.vllm_pearl import VllmPearlProvider
    monkeypatch.setattr(
        "openjarvis.mining.vllm_pearl.SIDECAR_PATH", written_sidecar
    )
    fake_client = MagicMock()
    provider = VllmPearlProvider(docker_client=fake_client)
    provider._launcher._container = MagicMock()  # simulate running
    await provider.stop()
    assert not written_sidecar.exists()


def test_vllm_pearl_stats_reads_gateway(monkeypatch, written_sidecar):
    from openjarvis.mining.vllm_pearl import VllmPearlProvider
    monkeypatch.setattr(
        "openjarvis.mining.vllm_pearl.SIDECAR_PATH", written_sidecar
    )
    sample = (
        "pearl_gateway_shares_submitted_total 100\n"
        "pearl_gateway_shares_accepted_total 99\n"
        "pearl_gateway_blocks_found_total 1\n"
    )
    with patch("openjarvis.mining.vllm_pearl.httpx.get") as get:
        get.return_value.status_code = 200
        get.return_value.text = sample
        provider = VllmPearlProvider(docker_client=MagicMock())
        stats = provider.stats()
        assert stats.shares_submitted == 100
        assert stats.shares_accepted == 99
        assert stats.blocks_found == 1


def test_ensure_registered_is_idempotent():
    from openjarvis.core.registry import MinerRegistry
    from openjarvis.mining.vllm_pearl import (
        VllmPearlProvider,
        ensure_registered,
    )
    ensure_registered()
    ensure_registered()  # second call should not raise
    assert MinerRegistry.contains("vllm-pearl")
    assert MinerRegistry.get("vllm-pearl") is VllmPearlProvider

Note: the test test_vllm_pearl_start_writes_sidecar uses pytest.mark.asyncio. Confirm pytest-asyncio is in the dev extras (it is per pyproject.toml's [project.optional-dependencies].dev). If async tests fail to collect, add asyncio_mode = "auto" under [tool.pytest.ini_options] in pyproject.toml.

  • Step 2: Run tests to verify they fail
uv run pytest tests/mining/test_vllm_pearl.py -v

Expected: 7 FAIL.

  • Step 3: Implement VllmPearlProvider

Create src/openjarvis/mining/vllm_pearl.py:

# src/openjarvis/mining/vllm_pearl.py
"""The v1 vllm-pearl mining provider.

See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md``.
"""

from __future__ import annotations

import time
from pathlib import Path
from typing import Any, Optional

import httpx

from openjarvis.core.config import HardwareInfo
from openjarvis.core.registry import MinerRegistry
from openjarvis.mining._constants import (
    DEFAULT_GATEWAY_METRICS_PORT,
    DEFAULT_GATEWAY_RPC_PORT,
    DEFAULT_PEARL_MODEL,
    DEFAULT_VLLM_PORT,
    PEARL_IMAGE_TAG,
    SIDECAR_PATH,
)
from openjarvis.mining._discovery import detect_for_engine_model
from openjarvis.mining._docker import PearlDockerLauncher
from openjarvis.mining._metrics import parse_gateway_metrics
from openjarvis.mining._stubs import (
    MiningCapabilities,
    MiningConfig,
    MiningProvider,
    MiningStats,
    PoolTarget,
    Sidecar,
    SoloTarget,
)


class VllmPearlProvider(MiningProvider):
    """vLLM + Pearl Docker container, solo-mining only in v1."""

    provider_id = "vllm-pearl"

    def __init__(self, docker_client: Optional[Any] = None):
        if docker_client is None:
            import docker
            docker_client = docker.from_env()
        self._client = docker_client
        self._launcher = PearlDockerLauncher(client=docker_client)

    @classmethod
    def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities:
        return detect_for_engine_model(
            hw=hw, engine_id=engine_id, model=model, provider_id=cls.provider_id,
        )

    async def start(self, config: MiningConfig) -> None:
        if isinstance(config.submit_target, PoolTarget):
            raise NotImplementedError(
                "pool support is v2 — see openjarvis#XYZ. v1 only accepts "
                "submit_target='solo'."
            )
        assert isinstance(config.submit_target, SoloTarget)

        image = config.extra.get("docker_image_tag", PEARL_IMAGE_TAG)
        image = self._launcher.ensure_image(image)
        container = self._launcher.start(config, image=image)

        # Pull port assignments from extra (with sensible defaults).
        vllm_port = int(config.extra.get("vllm_port", DEFAULT_VLLM_PORT))
        gw_port = int(config.extra.get("gateway_port", DEFAULT_GATEWAY_RPC_PORT))
        gw_metrics = int(
            config.extra.get("gateway_metrics_port", DEFAULT_GATEWAY_METRICS_PORT)
        )
        model_name = config.extra.get("model", DEFAULT_PEARL_MODEL)

        Sidecar.write(SIDECAR_PATH, {
            "provider": self.provider_id,
            "vllm_endpoint": f"http://127.0.0.1:{vllm_port}/v1",
            "model": model_name,
            "gateway_url": f"http://127.0.0.1:{gw_port}",
            "gateway_metrics_url": f"http://127.0.0.1:{gw_metrics}",
            "container_id": getattr(container, "id", ""),
            "wallet_address": config.wallet_address,
            "started_at": int(time.time()),
        })

    async def stop(self) -> None:
        self._launcher.stop()
        Sidecar.remove(SIDECAR_PATH)

    def is_running(self) -> bool:
        return self._launcher.is_running()

    def stats(self) -> MiningStats:
        sidecar = Sidecar.read(SIDECAR_PATH)
        if sidecar is None:
            return MiningStats(provider_id=self.provider_id)
        url = sidecar.get("gateway_metrics_url")
        if not url:
            return MiningStats(provider_id=self.provider_id)
        try:
            resp = httpx.get(f"{url}/metrics", timeout=5.0)
            if resp.status_code != 200:
                return MiningStats(
                    provider_id=self.provider_id,
                    last_error=f"gateway HTTP {resp.status_code}",
                )
            return parse_gateway_metrics(resp.text, provider_id=self.provider_id)
        except Exception as e:  # noqa: BLE001
            return MiningStats(
                provider_id=self.provider_id,
                last_error=str(e).splitlines()[0],
            )


def ensure_registered() -> None:
    """Idempotent registration. Required because tests/conftest.py clears
    every registry before each test (see Spec A §4.2).
    """
    if not MinerRegistry.contains("vllm-pearl"):
        MinerRegistry.register_value("vllm-pearl", VllmPearlProvider)
  • Step 4: Run tests to verify they pass
uv run pytest tests/mining/test_vllm_pearl.py -v

Expected: 7 PASS.

  • Step 5: Run the full mining test suite to confirm no regressions
uv run pytest tests/mining/ -v

Expected: ALL PASS.

  • Step 6: Commit
git add src/openjarvis/mining/vllm_pearl.py tests/mining/test_vllm_pearl.py
git commit -m "feat(mining): VllmPearlProvider — the v1 vllm-pearl provider"

Task 9 — Engine sidecar handoff

Files:

  • Modify: src/openjarvis/engine/_discovery.py

  • Test: tests/engine/test_discovery.py (existing — add new tests)

  • Step 1: Read existing engine/_discovery.py

Run:

sed -n '1,80p' src/openjarvis/engine/_discovery.py

Identify the function that resolves engines (likely discover_engines() or get_engine()).

  • Step 2: Write the failing test

Add to tests/engine/test_discovery.py:

def test_engine_discovery_picks_up_mining_sidecar(tmp_path, monkeypatch, written_sidecar):
    """When a mining sidecar exists, engine resolution should expose a
    'vllm-pearl-mining' engine pointing at the sidecar's vllm_endpoint.
    """
    from openjarvis.engine._discovery import discover_engines
    from openjarvis.mining import _constants as mining_const

    monkeypatch.setattr(mining_const, "SIDECAR_PATH", written_sidecar)

    engines = discover_engines(force=True)
    keys = {e.engine_id if hasattr(e, "engine_id") else e for e in engines}
    assert any("vllm-pearl-mining" in str(k) for k in keys)


def test_engine_discovery_no_mining_engine_when_sidecar_absent(tmp_path, monkeypatch):
    from openjarvis.engine._discovery import discover_engines
    from openjarvis.mining import _constants as mining_const

    missing = tmp_path / "no-such-mining.json"
    monkeypatch.setattr(mining_const, "SIDECAR_PATH", missing)

    engines = discover_engines(force=True)
    keys = {e.engine_id if hasattr(e, "engine_id") else e for e in engines}
    assert not any("vllm-pearl-mining" in str(k) for k in keys)

The written_sidecar fixture lives in tests/mining/conftest.py; you'll need to either move it to a shared conftest or duplicate it in tests/engine/conftest.py. Prefer moving relevant ones to tests/conftest.py.

  • Step 3: Move sidecar fixtures to root conftest

Move sample_sidecar_payload, sidecar_path, and written_sidecar from tests/mining/conftest.py to tests/conftest.py. Also move hopper_hw, ada_hw, apple_hw, and mock_docker_client to tests/conftest.py so all test packages can use them.

  • Step 4: Run tests to verify they fail
uv run pytest tests/engine/test_discovery.py -k mining_sidecar -v

Expected: FAIL — sidecar engine isn't registered.

  • Step 5: Modify engine/_discovery.py

Locate the engine-resolution path. Add a helper:

def _maybe_register_mining_sidecar_engine() -> None:
    """If a mining sidecar exists, register a derived vLLM engine pointing
    at the mining endpoint.

    See Spec A §5.4. Idempotent — safe to call from any discovery path.
    """
    try:
        from openjarvis.mining import Sidecar
        from openjarvis.mining._constants import SIDECAR_PATH
    except ImportError:
        return
    payload = Sidecar.read(SIDECAR_PATH)
    if payload is None:
        return
    endpoint = payload.get("vllm_endpoint")
    model = payload.get("model")
    if not endpoint or not model:
        return

    from openjarvis.core.registry import EngineRegistry
    from openjarvis.engine.openai_compat_engines import OpenAICompatEngine  # adjust to actual class name

    if EngineRegistry.contains("vllm-pearl-mining"):
        return

    # Construct an instance bound to the mining endpoint and register it.
    instance = OpenAICompatEngine(
        engine_id="vllm-pearl-mining",
        base_url=endpoint,
        default_model=model,
    )
    EngineRegistry.register_value("vllm-pearl-mining", instance)

Call _maybe_register_mining_sidecar_engine() from discover_engines() after the existing engine-detection logic and before returning.

Note for the implementer: Verify the actual class name of the OpenAI-compatible engine wrapper in engine/openai_compat_engines.py and adjust the import + constructor accordingly. The registry key must be "vllm-pearl-mining" exactly.

  • Step 6: Run tests to verify they pass
uv run pytest tests/engine/test_discovery.py -k mining_sidecar -v

Expected: 2 PASS.

  • Step 7: Commit
git add src/openjarvis/engine/_discovery.py tests/conftest.py tests/mining/conftest.py tests/engine/test_discovery.py
git commit -m "feat(mining): engine discovery picks up runtime sidecar"

Task 10 — MiningTelemetryCollector (shipped unwired)

Files:

  • Create: src/openjarvis/mining/_collector.py

  • Create: tests/mining/test_collector.py

  • Step 1: Write the failing tests

Create tests/mining/test_collector.py:

"""Tests for MiningTelemetryCollector — shipped in v1 but unwired."""

from __future__ import annotations

import asyncio
from unittest.mock import MagicMock, patch

import pytest


@pytest.mark.asyncio
async def test_collector_collect_once_returns_stats(written_sidecar):
    from openjarvis.mining._collector import MiningTelemetryCollector

    sample = (
        "pearl_gateway_shares_submitted_total 50\n"
        "pearl_gateway_shares_accepted_total 49\n"
    )
    with patch("openjarvis.mining._collector.httpx.get") as get:
        get.return_value.status_code = 200
        get.return_value.text = sample
        store = MagicMock()
        c = MiningTelemetryCollector(
            sidecar_path=written_sidecar, telemetry_store=store, interval_s=0.05
        )
        stats = await c.collect_once()
        assert stats.shares_submitted == 50
        assert stats.shares_accepted == 49


@pytest.mark.asyncio
async def test_collector_run_loop_writes_to_store_then_stops(written_sidecar):
    from openjarvis.mining._collector import MiningTelemetryCollector

    sample = "pearl_gateway_shares_submitted_total 1\n"
    with patch("openjarvis.mining._collector.httpx.get") as get:
        get.return_value.status_code = 200
        get.return_value.text = sample
        store = MagicMock()
        c = MiningTelemetryCollector(
            sidecar_path=written_sidecar, telemetry_store=store, interval_s=0.01
        )
        # Run the loop briefly and stop.
        task = asyncio.create_task(c.run())
        await asyncio.sleep(0.05)
        c.stop()
        await asyncio.wait_for(task, timeout=1.0)
        assert store.record_mining_stats.call_count >= 1


@pytest.mark.asyncio
async def test_collector_handles_gateway_errors_gracefully(written_sidecar):
    from openjarvis.mining._collector import MiningTelemetryCollector

    with patch("openjarvis.mining._collector.httpx.get") as get:
        get.side_effect = ConnectionError("nope")
        store = MagicMock()
        c = MiningTelemetryCollector(
            sidecar_path=written_sidecar, telemetry_store=store
        )
        stats = await c.collect_once()
        assert stats.last_error is not None
  • Step 2: Run tests to verify they fail
uv run pytest tests/mining/test_collector.py -v

Expected: 3 FAIL.

  • Step 3: Implement the collector

Create src/openjarvis/mining/_collector.py:

# src/openjarvis/mining/_collector.py
"""Background poller for mining telemetry.

Shipped in v1 but **not wired into the gateway daemon**. v1.x will register
this as a periodic asyncio task in ``openjarvis.daemon.gateway``. v1's
status command reads on-demand instead — see ``vllm_pearl.VllmPearlProvider.stats``.

Why ship now? Lighting up the collector in v1.x is a one-line change in the
daemon. The contract (init signature, ``run()`` loop, ``collect_once()``,
``stop()``) is fixed by this v1 ship to prevent API churn.
"""

from __future__ import annotations

import asyncio
import logging
from pathlib import Path
from typing import Any

import httpx

from openjarvis.mining._metrics import parse_gateway_metrics
from openjarvis.mining._stubs import MiningStats, Sidecar

log = logging.getLogger(__name__)


class MiningTelemetryCollector:
    """Periodically polls the Pearl gateway and writes ``MiningStats`` to a
    telemetry store.

    ``telemetry_store`` is duck-typed: must implement
    ``record_mining_stats(stats: MiningStats) -> None``.
    """

    def __init__(
        self,
        sidecar_path: Path,
        telemetry_store: Any,
        interval_s: float = 30.0,
    ):
        self._sidecar_path = sidecar_path
        self._store = telemetry_store
        self._interval_s = interval_s
        self._stop = False

    async def collect_once(self) -> MiningStats:
        sidecar = Sidecar.read(self._sidecar_path)
        if sidecar is None:
            return MiningStats(provider_id="unknown")
        url = sidecar.get("gateway_metrics_url")
        provider_id = sidecar.get("provider", "unknown")
        if not url:
            return MiningStats(provider_id=provider_id)
        try:
            resp = httpx.get(f"{url}/metrics", timeout=5.0)
            if resp.status_code != 200:
                return MiningStats(
                    provider_id=provider_id,
                    last_error=f"gateway HTTP {resp.status_code}",
                )
            return parse_gateway_metrics(resp.text, provider_id=provider_id)
        except Exception as e:  # noqa: BLE001
            return MiningStats(provider_id=provider_id, last_error=str(e).splitlines()[0])

    async def run(self) -> None:
        while not self._stop:
            try:
                stats = await self.collect_once()
                self._store.record_mining_stats(stats)
            except Exception as e:  # noqa: BLE001
                log.warning("MiningTelemetryCollector tick error: %s", e)
            try:
                await asyncio.sleep(self._interval_s)
            except asyncio.CancelledError:
                break

    def stop(self) -> None:
        self._stop = True
  • Step 4: Run tests to verify they pass
uv run pytest tests/mining/test_collector.py -v

Expected: 3 PASS.

  • Step 5: Commit
git add src/openjarvis/mining/_collector.py tests/mining/test_collector.py
git commit -m "feat(mining): MiningTelemetryCollector — shipped unwired for v1.x"

Task 11 — Telemetry schema migration (mining_session_id)

Files:

  • Modify: src/openjarvis/telemetry/store.py

  • Test: tests/telemetry/test_store.py (existing — add new tests)

  • Step 1: Inspect existing telemetry/store.py migration approach

Run:

grep -n "PRAGMA user_version\|CREATE TABLE\|ALTER TABLE\|migrate" src/openjarvis/telemetry/store.py | head -20

If PRAGMA user_version is already used, follow that convention. If migrations are inline CREATE TABLE IF NOT EXISTS only, switch to a versioned approach for this change. Document the chosen pattern in the commit message.

  • Step 2: Write failing tests

Add to tests/telemetry/test_store.py:

def test_inference_row_has_mining_session_id_column(tmp_path):
    from openjarvis.telemetry.store import TelemetryStore
    db = tmp_path / "tel.db"
    store = TelemetryStore(db_path=db)
    store.record_inference(
        model="test-model",
        engine_id="test-engine",
        prompt_tokens=10,
        completion_tokens=5,
        latency_ms=100.0,
    )
    # Default — null
    rows = store.list_recent(limit=1)
    assert "mining_session_id" in rows[0]
    assert rows[0]["mining_session_id"] is None


def test_inference_row_can_be_tagged_with_mining_session_id(tmp_path):
    from openjarvis.telemetry.store import TelemetryStore
    db = tmp_path / "tel.db"
    store = TelemetryStore(db_path=db)
    store.record_inference(
        model="test-model",
        engine_id="vllm-pearl-mining",
        prompt_tokens=10,
        completion_tokens=5,
        latency_ms=100.0,
        mining_session_id="abc123",
    )
    rows = store.list_recent(limit=1)
    assert rows[0]["mining_session_id"] == "abc123"


def test_record_mining_stats_persists(tmp_path):
    from openjarvis.telemetry.store import TelemetryStore
    from openjarvis.mining._stubs import MiningStats
    db = tmp_path / "tel.db"
    store = TelemetryStore(db_path=db)
    store.record_mining_stats(
        MiningStats(provider_id="vllm-pearl", shares_submitted=42, shares_accepted=40)
    )
    snapshots = store.list_recent_mining_stats(limit=1)
    assert snapshots[0]["shares_submitted"] == 42
  • Step 3: Run tests to verify they fail
uv run pytest tests/telemetry/test_store.py -k mining -v

Expected: FAIL.

  • Step 4: Implement the migration

In src/openjarvis/telemetry/store.py:

  1. Bump the schema version constant by 1 (or introduce one if absent).
  2. Add a migration step: ALTER TABLE inference ADD COLUMN mining_session_id TEXT NULL; guarded by the version bump.
  3. Add a mining_stats table with appropriate columns: provider_id, shares_submitted, shares_accepted, blocks_found, hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed, recorded_at.
  4. Add record_inference(..., mining_session_id: Optional[str] = None) parameter — keep it kwargs-only and defaulted so callers don't change.
  5. Add record_mining_stats(stats: MiningStats) -> None.
  6. Add list_recent_mining_stats(limit: int = 50) -> list[dict].
  7. Update list_recent() to include mining_session_id in returned rows.

Exact SQL:

-- migrate_v<N>_to_v<N+1>:
ALTER TABLE inference ADD COLUMN mining_session_id TEXT;

CREATE TABLE IF NOT EXISTS mining_stats (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    recorded_at REAL NOT NULL,
    provider_id TEXT NOT NULL,
    shares_submitted INTEGER NOT NULL DEFAULT 0,
    shares_accepted INTEGER NOT NULL DEFAULT 0,
    blocks_found INTEGER NOT NULL DEFAULT 0,
    hashrate REAL NOT NULL DEFAULT 0,
    uptime_seconds REAL NOT NULL DEFAULT 0,
    last_share_at REAL,
    last_error TEXT,
    payout_target TEXT NOT NULL DEFAULT 'solo',
    fees_owed INTEGER NOT NULL DEFAULT 0
);
  • Step 5: Run tests to verify they pass
uv run pytest tests/telemetry/test_store.py -v

Expected: ALL PASS (existing tests + 3 new).

  • Step 6: Commit
git add src/openjarvis/telemetry/store.py tests/telemetry/test_store.py
git commit -m "feat(telemetry): add mining_session_id + mining_stats table"

Task 12 — jarvis mine doctor (highest user-facing value, build first)

Files:

  • Create: src/openjarvis/cli/mine_cmd.py

  • Create: tests/mining/test_cli.py

  • Step 1: Write the failing test

Create tests/mining/test_cli.py:

"""CLI smoke tests via Click CliRunner."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

from click.testing import CliRunner


def test_mine_doctor_prints_capability_matrix(monkeypatch):
    from openjarvis.cli.mine_cmd import mine
    runner = CliRunner()

    # Force the H100 hardware fixture so detect() returns supported.
    from openjarvis.core.config import GpuInfo, HardwareInfo
    fake_hw = HardwareInfo(
        platform="linux",
        gpu=GpuInfo(
            vendor="nvidia", name="H100", vram_gb=80.0,
            compute_capability="9.0", count=1,
        ),
    )
    with patch("openjarvis.cli.mine_cmd._detect_hardware", return_value=fake_hw), \
         patch("openjarvis.cli.mine_cmd.check_docker_available", return_value=(True, "running 24.0.7")), \
         patch("openjarvis.cli.mine_cmd.check_disk_free", return_value=(True, "300 GB free")), \
         patch("openjarvis.cli.mine_cmd.check_pearld_reachable", return_value=(True, "block height 442107 (synced)")):
        result = runner.invoke(mine, ["doctor"])
    assert result.exit_code == 0, result.output
    out = result.output.lower()
    assert "hardware" in out
    assert "docker" in out
    assert "pearl" in out
    assert "vllm-pearl" in out


def test_mine_doctor_flags_unsupported_hardware():
    from openjarvis.cli.mine_cmd import mine
    runner = CliRunner()

    from openjarvis.core.config import GpuInfo, HardwareInfo
    fake_hw = HardwareInfo(
        platform="linux",
        gpu=GpuInfo(
            vendor="nvidia", name="RTX 4090", vram_gb=24.0,
            compute_capability="8.9", count=1,
        ),
    )
    with patch("openjarvis.cli.mine_cmd._detect_hardware", return_value=fake_hw), \
         patch("openjarvis.cli.mine_cmd.check_docker_available", return_value=(True, "ok")), \
         patch("openjarvis.cli.mine_cmd.check_disk_free", return_value=(True, "300 GB free")), \
         patch("openjarvis.cli.mine_cmd.check_pearld_reachable", return_value=(False, "connection refused")):
        result = runner.invoke(mine, ["doctor"])
    assert result.exit_code == 0
    assert "✗" in result.output or "FAIL" in result.output.upper()
  • Step 2: Run test to verify it fails
uv run pytest tests/mining/test_cli.py::test_mine_doctor_prints_capability_matrix -v

Expected: FAIL.

  • Step 3: Implement mine_cmd.py with the doctor subcommand
# src/openjarvis/cli/mine_cmd.py
"""``jarvis mine`` command group.

See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md``
section 6 for the full CLI surface.
"""

from __future__ import annotations

import json
import os
import sys
from pathlib import Path
from typing import Optional

import click

from openjarvis.core.config import HardwareInfo, load_config
from openjarvis.mining._constants import (
    DEFAULT_PEARL_MODEL,
    DEFAULT_PEARLD_RPC_URL,
    PEARL_IMAGE_TAG,
    SIDECAR_PATH,
)
from openjarvis.mining._discovery import (
    check_disk_free,
    check_docker_available,
    check_pearld_reachable,
    check_wallet_address_format,
    detect_for_engine_model,
)
from openjarvis.mining._stubs import Sidecar


def _detect_hardware() -> HardwareInfo:
    """Wrapper to make hardware detection mockable in CLI tests."""
    return load_config().hardware


@click.group()
def mine() -> None:
    """Pearl PoUW mining commands.

    See https://open-jarvis.github.io/OpenJarvis/user-guide/mining/ for the
    full guide.
    """


@mine.command()
def doctor() -> None:
    """Diagnose mining capability with one row per check."""
    hw = _detect_hardware()
    cfg = load_config()
    mining_cfg = cfg.mining

    def row(group: str, name: str, ok: bool, info: str) -> None:
        marker = "✓" if ok else "✗"
        click.echo(f"  {name:<22} {info:<35} {marker}")

    click.echo("Hardware")
    row("hw", "GPU vendor", hw.gpu.vendor == "nvidia" if hw.gpu else False,
        hw.gpu.vendor if hw.gpu else "(no GPU)")
    cc_ok = bool(hw.gpu and hw.gpu.compute_capability.startswith("9.0"))
    row("hw", "Compute capability", cc_ok,
        hw.gpu.compute_capability if hw.gpu else "n/a")
    vram = hw.gpu.vram_gb if hw.gpu else 0
    row("hw", "VRAM", vram >= 70, f"{vram:.0f} GB")

    click.echo("Docker")
    ok, info = check_docker_available()
    row("docker", "Daemon", ok, info)

    click.echo("Disk")
    ok, info = check_disk_free(Path.home())
    row("disk", "Free in HF cache", ok, info)

    click.echo("Pearl node")
    if mining_cfg is not None:
        url = mining_cfg.extra.get("pearld_rpc_url", DEFAULT_PEARLD_RPC_URL)
        user = mining_cfg.extra.get("pearld_rpc_user", "rpcuser")
        password_env = mining_cfg.extra.get(
            "pearld_rpc_password_env", "PEARLD_RPC_PASSWORD"
        )
        password = os.environ.get(password_env, "")
        ok, info = check_pearld_reachable(url, user, password)
        row("pearld", "RPC", ok, info)
    else:
        row("pearld", "RPC", False, "no [mining] config — run `jarvis mine init`")

    click.echo("Wallet")
    if mining_cfg is not None:
        ok, info = check_wallet_address_format(mining_cfg.wallet_address)
        row("wallet", "Address format", ok, info)

    click.echo("Provider capability")
    if mining_cfg is not None:
        cap = detect_for_engine_model(
            hw=hw, engine_id="vllm",
            model=mining_cfg.extra.get("model", DEFAULT_PEARL_MODEL),
            provider_id=mining_cfg.provider,
        )
        marker = "SUPPORTED" if cap.supported else f"UNSUPPORTED — {cap.reason}"
        click.echo(f"  vllm-pearl              {marker}")

    click.echo("Session")
    sidecar = Sidecar.read(SIDECAR_PATH)
    if sidecar is None:
        click.echo("  Sidecar                absent (not running)")
    else:
        click.echo(f"  Sidecar                present ({SIDECAR_PATH})")
        click.echo(f"  Container              {sidecar.get('container_id', '?')}")
  • Step 4: Run tests to verify they pass
uv run pytest tests/mining/test_cli.py -v

Expected: 2 PASS.

  • Step 5: Commit
git add src/openjarvis/cli/mine_cmd.py tests/mining/test_cli.py
git commit -m "feat(mining-cli): jarvis mine doctor"

Task 13 — jarvis mine init / start / stop

Files:

  • Modify: src/openjarvis/cli/mine_cmd.py

  • Modify: tests/mining/test_cli.py

  • Step 1: Write failing tests

Append to tests/mining/test_cli.py:

def test_mine_start_runs_provider_start(monkeypatch):
    from openjarvis.cli.mine_cmd import mine
    runner = CliRunner()
    fake_provider_class = MagicMock()
    fake_provider_class.return_value.start = MagicMock(return_value=None)
    with patch("openjarvis.cli.mine_cmd.MinerRegistry") as reg, \
         patch("openjarvis.cli.mine_cmd.load_config") as load, \
         patch("openjarvis.cli.mine_cmd.asyncio.run") as arun:
        from openjarvis.mining._stubs import MiningConfig, SoloTarget
        load.return_value = MagicMock(mining=MiningConfig(
            provider="vllm-pearl",
            wallet_address="prl1qaaa",
            submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"),
        ))
        reg.get.return_value = fake_provider_class
        result = runner.invoke(mine, ["start"])
    assert result.exit_code == 0
    arun.assert_called_once()


def test_mine_stop_calls_provider_stop():
    from openjarvis.cli.mine_cmd import mine
    runner = CliRunner()
    fake_provider_class = MagicMock()
    with patch("openjarvis.cli.mine_cmd.MinerRegistry") as reg, \
         patch("openjarvis.cli.mine_cmd.load_config") as load, \
         patch("openjarvis.cli.mine_cmd.asyncio.run") as arun:
        from openjarvis.mining._stubs import MiningConfig, SoloTarget
        load.return_value = MagicMock(mining=MiningConfig(
            provider="vllm-pearl",
            wallet_address="prl1qaaa",
            submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"),
        ))
        reg.get.return_value = fake_provider_class
        result = runner.invoke(mine, ["stop"])
    assert result.exit_code == 0


def test_mine_start_errors_when_no_mining_config():
    from openjarvis.cli.mine_cmd import mine
    runner = CliRunner()
    with patch("openjarvis.cli.mine_cmd.load_config") as load:
        load.return_value = MagicMock(mining=None)
        result = runner.invoke(mine, ["start"])
    assert result.exit_code != 0
    assert "init" in result.output.lower() or "no [mining]" in result.output.lower()
  • Step 2: Run tests to verify they fail
uv run pytest tests/mining/test_cli.py -v

Expected: 3 new FAIL.

  • Step 3: Implement init, start, stop

Append to mine_cmd.py:

import asyncio
from openjarvis.core.registry import MinerRegistry


@mine.command()
@click.option("--wallet", prompt="Pearl Taproot wallet address (prl1q...)")
@click.option("--pearld-url", default=DEFAULT_PEARLD_RPC_URL,
              prompt="pearld RPC URL")
@click.option("--pearld-user", default="rpcuser", prompt="pearld RPC user")
@click.option("--pearld-password-env", default="PEARLD_RPC_PASSWORD",
              prompt="env var holding pearld password")
@click.option("--model", default=DEFAULT_PEARL_MODEL)
@click.option("--image", default=PEARL_IMAGE_TAG)
def init(
    wallet: str,
    pearld_url: str,
    pearld_user: str,
    pearld_password_env: str,
    model: str,
    image: str,
) -> None:
    """Interactive setup. Validates capability, writes [mining] config, pulls/builds image."""
    # Pre-checks
    hw = _detect_hardware()
    cap = detect_for_engine_model(
        hw=hw, engine_id="vllm", model=model, provider_id="vllm-pearl",
    )
    if not cap.supported:
        raise click.ClickException(
            f"vllm-pearl not supported on this host: {cap.reason}\n"
            f"See `jarvis mine doctor` for details."
        )

    ok, info = check_docker_available()
    if not ok:
        raise click.ClickException(f"Docker unavailable: {info}")

    ok, info = check_disk_free(Path.home())
    if not ok:
        raise click.ClickException(f"Insufficient disk: {info}")

    if pearld_password_env not in os.environ:
        click.echo(
            f"Warning: ${pearld_password_env} is not set in your environment. "
            f"Set it before `jarvis mine start`.",
            err=True,
        )

    ok, info = check_wallet_address_format(wallet)
    if not ok:
        raise click.ClickException(f"Invalid wallet address: {info}")

    # Write config (preserves any existing config sections; appends [mining])
    config_path = Path.home() / ".openjarvis" / "config.toml"
    config_path.parent.mkdir(parents=True, exist_ok=True)
    new_section = f"""
[mining]
provider           = "vllm-pearl"
wallet_address     = "{wallet}"
submit_target      = "solo"
fee_bps            = 0
fee_payout_address = ""

[mining.extra]
docker_image_tag         = "{image}"
model                    = "{model}"
gateway_port             = 8337
gateway_metrics_port     = 8339
vllm_port                = 8000
gpu_memory_utilization   = 0.9
max_model_len            = 8192
pearld_rpc_url           = "{pearld_url}"
pearld_rpc_user          = "{pearld_user}"
pearld_rpc_password_env  = "{pearld_password_env}"
hf_token_env             = "HF_TOKEN"
"""
    if config_path.exists():
        existing = config_path.read_text()
        if "[mining]" in existing:
            click.echo("[mining] section already present; not overwriting. Edit manually if needed.")
            return
        config_path.write_text(existing + new_section)
    else:
        config_path.write_text(new_section)

    # Pull/build image
    click.echo(f"Resolving image {image}... (build may take 30-60 min on first run)")
    import docker
    from openjarvis.mining._docker import PearlDockerLauncher
    launcher = PearlDockerLauncher(client=docker.from_env())
    launcher.ensure_image(image)
    click.echo(f"Done. Run `jarvis mine start` to begin mining.")


@mine.command()
def start() -> None:
    """Launch the Pearl mining container and write the runtime sidecar."""
    cfg = load_config().mining
    if cfg is None:
        raise click.ClickException("no [mining] section in config — run `jarvis mine init`")
    provider_cls = MinerRegistry.get(cfg.provider)
    provider = provider_cls()

    async def _run():
        await provider.start(cfg)
    asyncio.run(_run())
    click.echo(f"Mining started. Run `jarvis mine status` for live stats.")


@mine.command()
def stop() -> None:
    """Stop the Pearl mining container and remove the sidecar."""
    cfg = load_config().mining
    if cfg is None:
        click.echo("no [mining] section — nothing to stop")
        return
    provider_cls = MinerRegistry.get(cfg.provider)
    provider = provider_cls()

    async def _run():
        await provider.stop()
    asyncio.run(_run())
    click.echo("Mining stopped.")
  • Step 4: Run tests to verify they pass
uv run pytest tests/mining/test_cli.py -v

Expected: 5 PASS.

  • Step 5: Commit
git add src/openjarvis/cli/mine_cmd.py tests/mining/test_cli.py
git commit -m "feat(mining-cli): jarvis mine init/start/stop"

Task 14 — jarvis mine status / attach / logs

Files:

  • Modify: src/openjarvis/cli/mine_cmd.py

  • Modify: tests/mining/test_cli.py

  • Step 1: Write failing tests

Append to tests/mining/test_cli.py:

def test_mine_status_renders_stats(written_sidecar, monkeypatch):
    from openjarvis.cli.mine_cmd import mine
    runner = CliRunner()
    monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", written_sidecar)
    sample = (
        "pearl_gateway_shares_submitted_total 100\n"
        "pearl_gateway_shares_accepted_total 99\n"
        "pearl_gateway_blocks_found_total 2\n"
    )
    with patch("openjarvis.mining.vllm_pearl.httpx.get") as get, \
         patch("openjarvis.cli.mine_cmd.MinerRegistry") as reg:
        get.return_value.status_code = 200
        get.return_value.text = sample
        from openjarvis.mining.vllm_pearl import VllmPearlProvider
        reg.get.return_value = lambda: VllmPearlProvider(docker_client=MagicMock())
        result = runner.invoke(mine, ["status"])
    assert result.exit_code == 0
    assert "100" in result.output


def test_mine_attach_writes_sidecar(tmp_path, monkeypatch):
    from openjarvis.cli.mine_cmd import mine
    runner = CliRunner()
    sidecar = tmp_path / "mining.json"
    monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", sidecar)
    result = runner.invoke(mine, [
        "attach",
        "--vllm-endpoint", "http://127.0.0.1:8000/v1",
        "--gateway-url", "http://127.0.0.1:8337",
        "--gateway-metrics-url", "http://127.0.0.1:8339",
        "--model", "pearl-ai/Llama-3.3-70B-Instruct-pearl",
    ])
    assert result.exit_code == 0
    assert sidecar.exists()


def test_mine_logs_streams_container_output(monkeypatch):
    from openjarvis.cli.mine_cmd import mine
    runner = CliRunner()
    fake_launcher = MagicMock()
    fake_launcher.get_logs.return_value = "log line 1\nlog line 2\n"
    with patch("openjarvis.cli.mine_cmd.PearlDockerLauncher",
               return_value=fake_launcher):
        result = runner.invoke(mine, ["logs", "--tail", "100"])
    assert result.exit_code == 0
    assert "log line 1" in result.output
  • Step 2: Run tests to verify they fail
uv run pytest tests/mining/test_cli.py -v

Expected: 3 new FAIL.

  • Step 3: Implement status, attach, logs

Append to mine_cmd.py:

import time
from openjarvis.mining._docker import PearlDockerLauncher


@mine.command()
def status() -> None:
    """Print live mining stats from the gateway."""
    cfg = load_config().mining
    if cfg is None:
        raise click.ClickException("no [mining] section — run `jarvis mine init`")
    provider_cls = MinerRegistry.get(cfg.provider)
    provider = provider_cls()
    s = provider.stats()
    click.echo(f"provider:           {s.provider_id}")
    click.echo(f"shares submitted:   {s.shares_submitted}")
    click.echo(f"shares accepted:    {s.shares_accepted}")
    click.echo(f"blocks found:       {s.blocks_found}")
    click.echo(f"hashrate:           {s.hashrate:.2f}")
    click.echo(f"uptime (s):         {s.uptime_seconds:.0f}")
    click.echo(f"last share at:      {s.last_share_at or '—'}")
    click.echo(f"last error:         {s.last_error or '—'}")
    click.echo(f"payout target:      {s.payout_target}")
    click.echo(f"fees owed:          {s.fees_owed}")


@mine.command()
@click.option("--vllm-endpoint", required=True)
@click.option("--gateway-url", required=True)
@click.option("--gateway-metrics-url", required=True)
@click.option("--model", default=DEFAULT_PEARL_MODEL)
@click.option("--container-id", default="external")
@click.option("--wallet", default="")
def attach(
    vllm_endpoint: str,
    gateway_url: str,
    gateway_metrics_url: str,
    model: str,
    container_id: str,
    wallet: str,
) -> None:
    """Manual mode — write a sidecar pointing at a Pearl container you started yourself."""
    Sidecar.write(SIDECAR_PATH, {
        "provider": "vllm-pearl",
        "vllm_endpoint": vllm_endpoint,
        "model": model,
        "gateway_url": gateway_url,
        "gateway_metrics_url": gateway_metrics_url,
        "container_id": container_id,
        "wallet_address": wallet,
        "started_at": int(time.time()),
    })
    click.echo(f"Sidecar written to {SIDECAR_PATH}")


@mine.command()
@click.option("-n", "--tail", "tail_n", default=200, type=int)
@click.option("-f", "--follow", is_flag=True, default=False,
              help="Follow logs (not supported in v1 — equivalent to --tail).")
def logs(tail_n: int, follow: bool) -> None:
    """Tail the Pearl mining container logs."""
    if follow:
        click.echo("note: -f follow not implemented in v1; printing tail and exiting", err=True)
    import docker
    launcher = PearlDockerLauncher(client=docker.from_env())
    # Re-attach to the running container by name.
    try:
        container = docker.from_env().containers.get("openjarvis-pearl-miner")
        launcher._container = container
    except Exception as e:  # noqa: BLE001
        raise click.ClickException(f"no running mining container: {e}")
    click.echo(launcher.get_logs(tail=tail_n))
  • Step 4: Run tests to verify they pass
uv run pytest tests/mining/test_cli.py -v

Expected: 8 PASS (cumulative).

  • Step 5: Commit
git add src/openjarvis/cli/mine_cmd.py tests/mining/test_cli.py
git commit -m "feat(mining-cli): jarvis mine status/attach/logs"

Task 15 — Register mine group; add hint

Files:

  • Modify: src/openjarvis/cli/__init__.py

  • Modify: src/openjarvis/cli/hints.py

  • Test: tests/cli/test_main.py (or wherever CLI registration is tested)

  • Test: tests/cli/test_hints.py (existing)

  • Step 1: Inspect current cli/__init__.py

sed -n '1,50p' src/openjarvis/cli/__init__.py
grep -n "add_command\|@main.command\|main.add_command" src/openjarvis/cli/__init__.py | head -20

Identify how other commands are registered.

  • Step 2: Write failing test

Add to tests/cli/test_main.py (create if absent):

def test_mine_subcommand_registered():
    from click.testing import CliRunner
    from openjarvis.cli import main
    runner = CliRunner()
    result = runner.invoke(main, ["mine", "--help"])
    assert result.exit_code == 0
    assert "doctor" in result.output
    assert "start" in result.output
    assert "stop" in result.output
  • Step 3: Run test to verify it fails
uv run pytest tests/cli/test_main.py::test_mine_subcommand_registered -v
  • Step 4: Register the group

Add to cli/__init__.py near other add_command calls:

from openjarvis.cli.mine_cmd import mine
main.add_command(mine)
  • Step 5: Add the hint

In cli/hints.py, add a function (or extend existing hint logic) that emits one line when [mining] is configured but no sidecar exists:

def mining_not_running_hint(cfg, sidecar_present: bool) -> Optional[str]:
    if cfg is None or sidecar_present:
        return None
    return "mining configured but not running — start it with `jarvis mine start`"

Wire it into wherever hints are surfaced (read existing hint integration points first; mirror the pattern).

  • Step 6: Add tests for the hint

Add to tests/cli/test_hints.py:

def test_mining_not_running_hint_when_configured_no_sidecar():
    from openjarvis.cli.hints import mining_not_running_hint
    cfg = object()  # any truthy stand-in for MiningConfig
    msg = mining_not_running_hint(cfg, sidecar_present=False)
    assert msg is not None
    assert "jarvis mine start" in msg


def test_mining_not_running_hint_silent_when_running():
    from openjarvis.cli.hints import mining_not_running_hint
    msg = mining_not_running_hint(object(), sidecar_present=True)
    assert msg is None


def test_mining_not_running_hint_silent_when_unconfigured():
    from openjarvis.cli.hints import mining_not_running_hint
    msg = mining_not_running_hint(None, sidecar_present=False)
    assert msg is None
  • Step 7: Run tests to verify they pass
uv run pytest tests/cli/test_main.py tests/cli/test_hints.py -v
  • Step 8: Commit
git add src/openjarvis/cli/__init__.py src/openjarvis/cli/hints.py tests/cli/test_main.py tests/cli/test_hints.py
git commit -m "feat(mining-cli): register `mine` group + add not-running hint"

Task 16 — pyproject.toml updates

Files:

  • Modify: pyproject.toml

  • Step 1: Add mining-pearl extra

In pyproject.toml [project.optional-dependencies], alphabetical position (after media, before openhands):

mining-pearl = ["docker>=7.0", "httpx>=0.27"]
  • Step 2: Add docker pytest marker

In pyproject.toml under [tool.pytest.ini_options].markers, add (alphabetical):

"docker: requires a working Docker daemon (no GPU required)",
  • Step 3: Verify uv lock is updated
uv lock

Diff uv.lock for any unintended changes (only docker and httpx entries should be new).

  • Step 4: Verify CI command still passes
uv sync --extra dev
uv run pytest tests/ -m "not live and not cloud and not docker" -v

Expected: pass (or fail only on tests unrelated to this work).

  • Step 5: Commit
git add pyproject.toml uv.lock
git commit -m "chore: add mining-pearl extra and docker pytest marker"

Task 17 — Documentation

Files:

  • Create: docs/user-guide/mining.md

  • Create: docs/development/mining.md

  • Modify: CLAUDE.md (note: gitignored locally — write only if your local copy expects it)

  • Modify: REVIEW.md

  • Step 1: Write docs/user-guide/mining.md

# Pearl mining

OpenJarvis can mine the [Pearl](https://github.com/pearl-research-labs/pearl)
Proof-of-Useful-Work blockchain through your local LLM inference. v1
supports H100/H200 hosts running vLLM. Apple Silicon, AMD, and other
inference backends are tracked separately — see [Spec B](../design/2026-05-05-apple-silicon-pearl-mining-design.md).

## Prerequisites

| | |
|---|---|
| GPU | NVIDIA H100 or H200 (sm_90a) with ≥ 70 GB VRAM |
| OS | Linux with `nvidia-container-toolkit` installed |
| Docker | 24+, GPU runtime configured |
| Disk | ≥ 200 GB free for the 70B model + headroom |
| Network | Reachable pearld node (default `http://localhost:44107`) |
| Wallet | A Pearl Taproot address (`prl1q...`) generated via Pearl's `oyster` CLI |

## Quick start

```bash
uv sync --extra mining-pearl
export PEARLD_RPC_PASSWORD=<your-pearld-password>
export HF_TOKEN=<your-hf-token>
uv run jarvis mine init    # writes [mining] config + builds Docker image (30-60 min first time)
uv run jarvis mine start
uv run jarvis mine status

Diagnosing problems

jarvis mine doctor prints one row per check with a clear ✓ or ✗ and reason. Read top-down — fix what's failing before retrying mine start.

What v1 does NOT support

  • Pool mining or any OJ fee — solo only, you keep 100%
  • Apple Silicon, AMD, sm_89 NVIDIA (RTX 4090), CPU-only — protocol-blocked on Pearl shipping non-CUDA / non-Hopper kernels
  • Wallet generation inside OJ — bring your own address

What's coming

  • v2: pool support and a 20% OJ fee for joining a shared variance-reduction pool
  • Apple Silicon path tracked in Spec B

- [ ] **Step 2: Write `docs/development/mining.md`**

```markdown
# Adding a new mining provider

The `openjarvis.mining` subsystem uses a registry pattern identical to
`engine/`, `agents/`, etc. To add a new provider (e.g., for Apple Silicon,
AMD, or a future engine), implement the `MiningProvider` ABC and register
via `@MinerRegistry.register("<key>")`.

## Steps

1. Create `src/openjarvis/mining/<provider>.py`.
2. Subclass `openjarvis.mining.MiningProvider`.
3. Implement `detect()`, `start()`, `stop()`, `is_running()`, `stats()`.
4. Define an idempotent `ensure_registered()` (required for test isolation —
   see `tests/conftest.py` autouse clear).
5. Add a soft-import in `mining/__init__.py`:
   ```python
   try:
       from openjarvis.mining import <provider>  # noqa: F401
       <provider>.ensure_registered()
   except ImportError:
       pass
  1. Add an optional dep extra in pyproject.toml (mining-pearl-<key>).
  2. Add tests in tests/mining/test_<provider>.py mirroring test_vllm_pearl.py.

Working example

The Apple Silicon path is the canonical worked example — see Spec B section 7 for the full provider template.


- [ ] **Step 3: Add `CLAUDE.md` paragraph (if your local copy is intended to be edited)**

If your local `CLAUDE.md` exists and is intended for editing, add this paragraph under the Architecture section listing the primitives:

```markdown
- `mining/` — `MiningProvider` ABC + `MinerRegistry`. v1's only impl is `vllm_pearl.py` (Pearl Docker container orchestrator). Soft-imported via `mining/__init__.py`'s `try/except ImportError` per OJ's optional-deps pattern. Future providers (Apple Silicon, AMD, Ollama) drop in via the registry without rewrite. See `docs/design/2026-05-05-vllm-pearl-mining-integration-design.md`.

CLAUDE.md may be in .gitignore in this repo. Check before adding to a commit.

  • Step 4: Add REVIEW.md bullet

In REVIEW.md under "Registry pattern compliance" (or the closest equivalent bullet about new components needing registry registration), add:

- New mining providers must register via `MinerRegistry` in `src/openjarvis/core/registry.py` and expose an idempotent `ensure_registered()` per the autouse-clear test convention.
  • Step 5: Run mkdocs locally to verify rendering
uv sync --extra docs
uv run mkdocs build

Expected: build succeeds. Inspect output for missing assets / broken links to the new pages.

  • Step 6: Commit
git add docs/user-guide/mining.md docs/development/mining.md REVIEW.md
# only add CLAUDE.md if it isn't ignored:
git check-ignore -q CLAUDE.md || git add CLAUDE.md
git commit -m "docs: user + dev guides for mining; REVIEW bullet"

Final verification

  • Step 1: Full test suite
uv run pytest tests/ -v --tb=short -m "not live and not cloud and not docker"

Expected: PASS — every test, no regressions in other packages.

  • Step 2: Lint + format
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/

Expected: clean.

  • Step 3: Coverage
uv run pytest tests/mining/ --cov=openjarvis.mining --cov-report=term-missing

Expected: ≥ 80% coverage on the new mining/ package.

  • Step 4: Smoke jarvis mine doctor on a non-mining dev box
uv run jarvis mine doctor

Expected: doctor runs, hardware/Docker/Pearl rows print honest ✗ where applicable, exit code 0.

  • Step 5: Open the implementation PR
gh pr create --title "feat(mining): vllm-pearl integration (v1, Spec A)" --body "$(cat <<'EOF'
Implements [Spec A](docs/design/2026-05-05-vllm-pearl-mining-integration-design.md). Solo mining only, no pool, no fee. v2 seams in place per spec §8.5.

## Summary
- New `openjarvis.mining` subsystem with `MiningProvider` ABC + `MinerRegistry`
- `vllm-pearl` provider wrapping Pearl's Docker container
- Runtime sidecar (`~/.openjarvis/runtime/mining.json`) for engine ↔ mining handoff
- Telemetry: on-demand reads in v1; `MiningTelemetryCollector` shipped unwired for v1.x
- New CLI: `jarvis mine init|start|stop|status|doctor|attach|logs`
- New optional extra: `mining-pearl`
- New pytest marker: `docker`
- Docs: user guide, contributor guide, REVIEW.md update

## Test plan
- [x] `uv run pytest tests/ -m "not live and not cloud and not docker"`
- [x] `uv run ruff check src/ tests/`
- [x] `uv run ruff format --check src/ tests/`
- [x] `uv run mkdocs build`
- [ ] Manual smoke: `uv run jarvis mine doctor` on a non-mining dev box prints honest output
- [ ] Manual smoke (release-gate): full mine init → start → status → stop on a real H100 host
EOF
)"

Self-review summary

Spec coverage: Every Spec A section maps to at least one task above:

Spec section Tasks
§4 Architecture & module layout 1, 2
§5 Config schema & engine attachment 3, 9
§6 CLI surface, lifecycle 12, 13, 14, 15
§7 Pearl Docker integration 5, 6
§8 Telemetry hooks & v2 seams 7, 10, 11
§9 Failure handling & test strategy All tasks (TDD); 16 (markers)
§10 Documentation deliverables 17
§11 Open items Surfaced inline at the implementation moment they affect (e.g., the implementer notes in Tasks 7, 9, 11)

Type consistency: MiningCapabilities, MiningConfig, MiningStats, SoloTarget, PoolTarget, Sidecar, MiningProvider, MinerRegistry, VllmPearlProvider — names and signatures consistent across all tasks.

Placeholder scan: No TBD, TODO, add appropriate error handling, or undefined-but-referenced types. The single intentional TODO is the PEARL_PINNED_REF = "main" constant in Task 2 — that's an explicit implementer-time decision, called out in _constants.py's docstring and Spec A §11 Open Item #2. The Prometheus-fixture file is a placeholder by spec design (Task 7 step 1's note explains the capture procedure).