Skip to content

feat: dimos spy - universal transport spy over LCM + Zenoh#2735

Open
leshy wants to merge 38 commits into
mainfrom
feat/ivan/spy
Open

feat: dimos spy - universal transport spy over LCM + Zenoh#2735
leshy wants to merge 38 commits into
mainfrom
feat/ivan/spy

Conversation

@leshy

@leshy leshy commented Jul 6, 2026

Copy link
Copy Markdown
Member

replaces lcmspy

2026-07-06_19-39

Design ratified in ticket f6c74d39 (subscribe_all non-conflating decision
confirmed by Ivan). Task package for a worker to implement:

- TASK.md: goal, decisions, implementation map, acceptance
- docs/usage/transports/spy.md: design doc (why, architecture, decisions)
- spec.py: subscribe_all contract tightened (every message, no conflation);
  SubscribeLatestMixin.subscribe_latest() stub = the explicit conflation opt-in
- protocol/pubsub/spy.py: TopicStats / SpySource / LCMSpySource /
  ZenohSpySource / TransportSpy stubs (typed, NotImplementedError bodies)
- utils/cli/spy/run_spy.py + `dimos spy` CLI registration (TUI stub)
- test_spy.py: 17 contract tests, 15 failing until implemented; the zenoh
  subscribe_all conflation bug fails sharply (2 of 50 messages delivered)

mypy clean (796 files); existing pubsub suites green.
Implement the spy core (TopicStats, LCM/Zenoh SpySources, TransportSpy) that
taps the raw-bytes pubsub layer — per-message hot path is (topic, len, ts),
never decoding a payload — plus the Textual TUI (dimos spy) and CLI wiring.

Spec fix: subscribe_all is now non-conflating on every transport; conflation
moves to the opt-in SubscribeLatestMixin.subscribe_latest() (lifted from zenoh,
transport-agnostic). Zenoh subscribe_all widens dimos/** -> ** and keeps its
drain-stop bookkeeping via _register/_unregister_drain_stop hooks. Rerun bridge
migrates to subscribe_latest. dimos lcmspy becomes a deprecated alias for
dimos spy --transport lcm.

Also strip 14 decorative section-banner comments from the delivered test_spy.py
to satisfy codebase_checks/test_no_sections (approved by brain; no logic change).
@mintlify

mintlify Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
dimensional 🟢 Ready View Preview Jul 6, 2026, 4:36 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

- TransportSpy.start() rolls back already-started sources on failure
- subscribe_latest stops+joins the drain thread if subscribe_all raises
- SOURCE_FACTORIES registry: construct only the requested transports (a
  filtered-out transport is never imported or instantiated)
- run_spy/SpyApp validate unknown --transport names up front
- add contract tests for the failure-rollback paths and run_spy selection
@leshy leshy changed the title feat: dimos spy — universal transport spy over LCM + Zenoh feat: dimos spy - universal transport spy over LCM + Zenoh Jul 6, 2026
@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a universal transport spy to replace the old LCM-only spy. The main changes are:

  • New dimos spy command for LCM and Zenoh traffic.
  • lcmspy kept as an LCM-only alias.
  • Strict parsing for spy transport filters and stray arguments.
  • Best-effort default startup when one backend is unavailable.
  • New tests and docs for the spy CLI behavior.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
dimos/robot/cli/dimos.py Adds the spy command and routes lcmspy through the shared spy entry point.
dimos/utils/cli/spy/run_spy.py Adds argument parsing, startup wiring, the Textual UI, and the LCM-only console shim.
dimos/utils/cli/spy/core.py Adds transport sources, per-topic stats, default source discovery, and best-effort source startup.
pyproject.toml Points the lcmspy console entry to the new spy shim.

Reviews (18): Last reviewed commit: "Merge branch 'main' into feat/ivan/spy" | Re-trigger Greptile

Comment thread dimos/protocol/pubsub/spy.py Outdated
Comment thread dimos/utils/cli/spy/run_spy.py
- dimos lcmspy subcommand: drop deprecation/guard, just run spy --transport lcm
- lcmspy console-script repointed to run_spy:lcm_main shim (LCM-only spy)
- delete the superseded dimos/utils/cli/lcmspy/ module (TUI + stats + tests)
@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
2722 1 2721 71
View the top 1 failed test(s) by shortest run time
dimos.protocol.pubsub.test_spec::test_high_volume_messages[ros_context-topic1-values1]
Stack Traces | 4.59s run time
pubsub_context = <function ros_context at 0x7c356c32ad40>
topic = RawROSTopic(topic='/test_ros_topic', ros_type=<class 'geometry_msgs.msg._vector3.Vector3'>, qos=<rclpy.qos.QoSProfile object at 0x7c356c1f5cb0>)
values = [geometry_msgs.msg.Vector3(x=1.0, y=2.0, z=3.0), geometry_msgs.msg.Vector3(x=4.0, y=5.0, z=6.0), geometry_msgs.msg.Vector3(x=7.0, y=8.0, z=9.0)]

    @pytest.mark.self_hosted
    @pytest.mark.skipif_macos_bug
    @pytest.mark.parametrize("pubsub_context, topic, values", testdata)
    def test_high_volume_messages(
        pubsub_context: Callable[[], Any], topic: Any, values: list[Any]
    ) -> None:
        """Test that all 5k messages are received correctly.
        Limited to 5k because ros transport cannot handle more.
        Might want to have separate expectations per transport later
        """
        with pubsub_context() as x:
            # Create a list to capture received messages
            received_messages: list[Any] = []
            last_message_time = [time.time()]  # Use list to allow modification in callback
    
            # Define callback function
            def callback(message: Any, topic: Any) -> None:
                received_messages.append(message)
                last_message_time[0] = time.time()
    
            # Subscribe to the topic
            x.subscribe(topic, callback)
    
            # Publish 5000 messages
            num_messages = 5000
            for _ in range(num_messages):
                x.publish(topic, values[0])
    
            # Wait until no messages received for 0.5 seconds
            timeout = 2.0  # Maximum time to wait
            stable_duration = 0.1  # Time without new messages to consider done
            start_time = time.time()
    
            while time.time() - start_time < timeout:
                if time.time() - last_message_time[0] >= stable_duration:
                    break
                time.sleep(0.1)
    
            # Capture count and clear list to avoid printing huge list on failure
            received_len = len(received_messages)
            received_messages.clear()
>           assert received_len == num_messages, f"Expected {num_messages} messages, got {received_len}"
E           AssertionError: Expected 5000 messages, got 2900
E           assert 2900 == 5000

_          = 4999
callback   = <function test_high_volume_messages.<locals>.callback at 0x7c356b64e840>
last_message_time = [1783453930.6014397]
num_messages = 5000
pubsub_context = <function ros_context at 0x7c356c32ad40>
received_len = 2900
received_messages = [geometry_msgs.msg.Vector3(x=1.0, y=2.0, z=3.0), geometry_msgs.msg.Vector3(x=1.0, y=2.0, z=3.0), geometry_msgs.msg.Vec....0, y=2.0, z=3.0), geometry_msgs.msg.Vector3(x=1.0, y=2.0, z=3.0), geometry_msgs.msg.Vector3(x=1.0, y=2.0, z=3.0), ...]
stable_duration = 0.1
start_time = 1783453928.4367003
timeout    = 2.0
topic      = RawROSTopic(topic='/test_ros_topic', ros_type=<class 'geometry_msgs.msg._vector3.Vector3'>, qos=<rclpy.qos.QoSProfile object at 0x7c356c1f5cb0>)
values     = [geometry_msgs.msg.Vector3(x=1.0, y=2.0, z=3.0), geometry_msgs.msg.Vector3(x=4.0, y=5.0, z=6.0), geometry_msgs.msg.Vector3(x=7.0, y=8.0, z=9.0)]
x          = <dimos.protocol.pubsub.impl.rospubsub.RawROS object at 0x7c35202fdcd0>

.../protocol/pubsub/test_spec.py:365: AssertionError
View the full list of 2 ❄️ flaky test(s)
dimos.e2e_tests.test_dimsim_spatial_memory::test_go_to_the_bed

Flake rate in main: 20.35% (Passed 90 times, Failed 23 times)

Stack Traces | 559s run time
lcm_spy = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7c5c260ecad0>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7c5c268ee520>
human_input = <function human_input.<locals>.send_human_input at 0x7c5c268ee5c0>
dim_sim = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7c5c2605ac60>
explore_house = <function explore_house.<locals>.explore at 0x7c5c268eeac0>

    @pytest.mark.self_hosted_large
    def test_go_to_the_bed(lcm_spy, start_blueprint, human_input, dim_sim, explore_house) -> None:
        start_blueprint(
            "run",
            "unitree-go2-agentic",
            simulator="dimsim",
        )
        lcm_spy.save_topic(".../McpClient/on_system_modules/res")
        lcm_spy.wait_for_saved_topic(".../McpClient/on_system_modules/res", timeout=1200.0)
    
        explore_house()
    
        human_input("go to the bed")
    
>       lcm_spy.wait_until_odom_position(-3.567, -1.332, threshold=2, timeout=180)

dim_sim    = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7c5c2605ac60>
explore_house = <function explore_house.<locals>.explore at 0x7c5c268eeac0>
human_input = <function human_input.<locals>.send_human_input at 0x7c5c268ee5c0>
lcm_spy    = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7c5c260ecad0>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7c5c268ee520>

dimos/e2e_tests/test_dimsim_spatial_memory.py:32: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
dimos/e2e_tests/lcm_spy.py:167: in wait_until_odom_position
    self.wait_for_message_result(
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x7c5c268eeca0>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7c5c260ecad0>
        threshold  = 2
        timeout    = 180
        x          = -3.567
        y          = -1.332
dimos/e2e_tests/lcm_spy.py:153: in wait_for_message_result
    wait_until(
        event      = <threading.Event at 0x7c5c2605b770: unset>
        fail_message = 'Failed to get to position x=-3.567, y=-1.332'
        listener   = <function LcmSpy.wait_for_message_result.<locals>.listener at 0x7c5c268eeb60>
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x7c5c268eeca0>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7c5c260ecad0>
        timeout    = 180
        topic      = '/odom#geometry_msgs.PoseStamped'
        type       = <class 'dimos.msgs.geometry_msgs.PoseStamped.PoseStamped'>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

predicate = <bound method Event.is_set of <threading.Event at 0x7c5c2605b770: unset>>

    def wait_until(
        predicate: Callable[[], bool],
        *,
        timeout: float,
        interval: float = 0.1,
        message: str | None = None,
    ) -> None:
        """Poll ``predicate`` until it returns truthy or ``timeout`` elapses."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            if predicate():
                return
            time.sleep(interval)
>       raise TimeoutError(message or f"Timed out after {timeout}s waiting for condition")
E       TimeoutError: Failed to get to position x=-3.567, y=-1.332

deadline   = 3519794.279166228
interval   = 0.1
message    = 'Failed to get to position x=-3.567, y=-1.332'
predicate  = <bound method Event.is_set of <threading.Event at 0x7c5c2605b770: unset>>
timeout    = 180

.../utils/testing/waiting.py:35: TimeoutError
dimos.perception.test_image_embedding.TestImageEmbedding::test_clip_embedding_initialization

Flake rate in main: 30.00% (Passed 7 times, Failed 3 times)

Stack Traces | 27.9s run time
request = <SubRequest 'monitor_threads' for <Function test_clip_embedding_initialization>>

    @pytest.fixture(autouse=True)
    def monitor_threads(request):
        # Capture threads before test runs
        test_name = request.node.nodeid
        with _seen_threads_lock:
            _before_test_threads[test_name] = {
                t.ident for t in threading.enumerate() if t.ident is not None
            }
    
        yield
    
        with _seen_threads_lock:
            before = _before_test_threads.get(test_name, set())
    
        # Threads intentionally left running for the whole process and cleaned up on
        # exit, so they don't count as per-test leaks.
        expected_persistent_thread_prefixes = [
            "Dask-Offload",
            # HuggingFace safetensors conversion thread - no user cleanup API
            # https://github..../transformers/issues/29513
            "Thread-auto_conversion",
        ]
    
        def live_new_threads():
            # Threads created during this test that are still running. A thread that
            # has already stopped is not a leak -- it's done, just not yet reaped
            # from threading's registry -- so we key on is_alive(), not presence.
            result = []
            for t in threading.enumerate():
                if t.ident is None or t.ident in before or t.name == "MainThread":
                    continue
                if any(t.name.startswith(prefix) for prefix in expected_persistent_thread_prefixes):
                    continue
                if t.is_alive():
                    result.append(t)
            return result
    
        # Some C extensions tear their callback threads down asynchronously, so a
        # thread can stay alive briefly after the test cleaned up its owner (notably
        # zenoh's pyo3-closure threads, freed shortly after the session is closed).
        # A single snapshot races that teardown and flags a thread that is about to
        # exit. Give new threads a grace period to drain; only ones that stay alive
        # are real leaks (a genuinely leaked thread never exits).
        deadline = time.monotonic() + 5.0
        leaked = live_new_threads()
        while leaked and time.monotonic() < deadline:
            time.sleep(0.02)
            leaked = live_new_threads()
    
        if not leaked:
            return
    
        with _seen_threads_lock:
            # Report each leaked thread only once across the session.
            truly_new = [t for t in leaked if t.ident not in _seen_threads]
            for t in leaked:
                _seen_threads.add(t.ident)
    
        if not truly_new:
            return
    
        thread_names = [t.name for t in truly_new]
    
>       pytest.fail(
            f"Non-closed threads created during this test. Thread names: {thread_names}. "
            "Please look at the first test that fails and fix that."
        )
E       Failed: Non-closed threads created during this test. Thread names: ['Thread-249']. Please look at the first test that fails and fix that.

before     = {135308191344320, 135308199737024, 135308216522432, 135308224915136, 135308241700544, 135308250093248, ...}
deadline   = 5934259.892136311
expected_persistent_thread_prefixes = ['Dask-Offload', 'Thread-auto_conversion']
leaked     = [<TMonitor(Thread-249, started daemon 135308174558912)>]
live_new_threads = <function monitor_threads.<locals>.live_new_threads at 0x7b1046b776a0>
request    = <SubRequest 'monitor_threads' for <Function test_clip_embedding_initialization>>
t          = <TMonitor(Thread-249, started daemon 135308174558912)>
test_name  = 'dimos/perception/test_image_embedding.py::TestImageEmbedding::test_clip_embedding_initialization'
thread_names = ['Thread-249']
truly_new  = [<TMonitor(Thread-249, started daemon 135308174558912)>]

dimos/conftest.py:273: Failed

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

- default_sources() skips backends that fail to import with a one-line
  stderr warning and errors only if no transport is available; an
  explicitly requested --transport keeps the hard error
- spy web mode serves the child with sys.executable and forwards the
  --transport filter args (validated before serving)
- restore the lcmspy --transport override guard dropped by the thin-alias
  refactor
- tests: default degradation, explicit hard error, web command construction
Comment thread dimos/robot/cli/dimos.py Outdated
dimos lcmspy web previously became spy --transport lcm web, which missed
run_spy.main()'s web branch (web must be argv[0]) and silently launched
the local TUI. Keep web first and forward the LCM filter behind it; the
--transport override guard stays. Alias routing is now covered by
CliRunner tests (tui, web, override rejection).
Reverses the ratified subscribe_all non-conflation spec change (Ivan's call):
- zenoh subscribe_all: restore original latest-per-topic drain over dimos/**
- spec: drop SubscribeLatestMixin/subscribe_latest entirely
- rerun bridge: back to subscribe_all (its original conflating consumer)
- spy is transport-agnostic — it just calls bus.subscribe_all(), accepting
  whatever delivery semantics each transport provides (LCM: every message;
  zenoh: latest-per-topic). No raw/session-level special-casing.
- drop the now-invalid contract tests: subscribe_all-delivers-every[zenoh]
  and subscribe_latest[*]; docstrings updated to match.

Keeps the three Greptile fixes already on feat/ivan/spy (default_sources
degradation, web-mode filter forwarding, lcmspy-web routing).
Comment thread dimos/utils/cli/spy/run_spy.py Outdated
Per maintainer decision, drop the browser 'web' mode entirely:
- run_spy: remove the web branch + _web_command; main() just runs the TUI
- docs: drop the 'dimos spy web' line

Fix the standalone lcmspy console entry (Greptile P1): it discarded
sys.argv[1:]. Extract _lcm_only_argv() — shared by lcm_main() and the
dimos lcmspy alias — which forwards args to spy and rejects an explicit
--transport override (LCM-only). Tests for forwarding + rejection on both
entry points; drop the now-obsolete web tests.
Comment thread dimos/utils/cli/spy/run_spy.py Outdated
Comment thread dimos/protocol/pubsub/spy.py Outdated
import sys in run_spy and the test-only imports had no reason to be
inline. The lazy backend imports in core.py stay inline but now carry a
comment saying why (an unavailable backend must not break the others).
The 60.0s default was duplicated in TopicStats.__init__ and
TransportSpy.__init__.
- App -> App[None], **kwargs: Any, DataTable -> DataTable[Text]
- cursor_type=None -> the documented "none" CursorType literal; drop
  zebra_stripes=False (the default)
- narrow self.table once at the top of refresh_table instead of three
  union-attr ignores
_parse_transports and SpyApp.__init__ built near-identical error
messages; both now use validate_transport_names in core, with the CLI
converting the ValueError to SystemExit.
Comment thread dimos/utils/cli/spy/test_spy.py Outdated
Source construction/validation, the autoconf warning, and spy.start()
move to a new start_spy() called from main(), which now owns the spy's
lifecycle with try/finally around app.run(). SpyApp just receives the
started TransportSpy, so a failed start can no longer leave a half-built
app. Tests exercise start_spy() through a fixture that always stops the
spy on teardown.
Replace the two print(..., file=sys.stderr) warnings in core with
structured logger.warning calls. Tests assert on caplog (via a shared
spy_warnings fixture that hooks the non-propagating dimos logger)
instead of capsys.
Comment thread dimos/utils/cli/spy/test_run_spy.py
paul-nechifor and others added 2 commits July 7, 2026 01:42
If one untap() or source.stop() raised, the remaining sources were
never stopped. Each failure is now logged and the shutdown loop
continues.
Wrap the subscribe_all delivery test's unsub() and the two FakeSource
TransportSpy tests' stop() in try/finally so a failing assertion no longer
leaks the subscription / leaves the spy running. The source-counting tests
and the SpyApp tests already clean up in finally.
Comment thread dimos/utils/cli/spy/run_spy.py Outdated
Only tests called it; the TUI shows freq, bandwidth, total and age.
Comment thread dimos/utils/cli/spy/run_spy.py Outdated
paul-nechifor and others added 5 commits July 7, 2026 01:44
total_bytes/total_msgs/last_seen are written under the stats lock by
transport threads but were read unlocked from the UI thread, and each
refresh traversed+copied the history twice per row (freq +
bytes_per_sec). window_stats() now returns a consistent WindowStats
reading computed in a single locked pass without building intermediate
lists; refresh_table sorts and renders from it. freq/bytes_per_sec stay
as thin wrappers for the contract tests.
The feature is implemented on this branch; TASK.md and the
agent/spy-architect branch no longer exist.
Buses, sources, publishers and the zenoh session pool were cleaned up
with hand-rolled try/finally (or a contextmanager) in test bodies; move
them to yield-fixtures / request.addfinalizer so teardown also runs
when an assertion fails before the cleanup line.
Two review nits on PR #2735:
- import sys was a local import in both main() and lcm_main(); hoist it to
  the module imports.
- SpyApp subclassed the generic App with a type: ignore[type-arg]; parameterize
  it as App[None] (the app's run() return type) instead of ignoring.
Replace the fixed establishment sleeps with a probe loop: publish on a
probe topic until the tap/wildcard subscription delivers one, with a
deadline. The 0.01s pacing sleeps in publish loops stay (throttling,
not waiting). Cuts the spy test suite from ~3.2s to ~1.4s.
Comment thread dimos/robot/cli/dimos.py
leshdaemon and others added 2 commits July 7, 2026 01:54
Greptile P1 on PR #2735: 'dimos --transport zenoh spy' let the root callback
consume --transport into GlobalConfig, so the spy saw no args and silently ran
the default all-transport view (including LCM) despite the zenoh request.

Root --transport sets the stack's pubsub backend (which single transport dimos
processes participate on); the spy is an observer that watches every transport
and takes its own repeatable --transport filter after the subcommand. They mean
different things, so reject the root placement with a clear error pointing at
'dimos spy --transport <name>' rather than silently ignoring the filter. Env/
default DIMOS_TRANSPORT is unaffected (only an explicit root CLI flag triggers it).
Comment thread dimos/robot/cli/dimos.py
Takes all 15 automated-fix commits from feat/ivan/spy-autofixes (authorship
preserved). They supersede my two overlapping commits (try/finally cleanup,
sys-import + App[None]) with more complete versions (fixture teardown, full
type resolution). My reject-root-transport and cli.md docs commits are kept.
@leshy

leshy commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

Merged Paul's autofix PR #2748 into this branch at bf819c1 — all 15 commits taken (authorship preserved via merge). They resolve several of the earlier review threads (type ignores, inline imports, thread-safety of the stats read, degrade-warning logging, test teardown). Gate green: 54 spy/CLI tests + codebase checks pass, mypy + ruff clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants