Skip to content

feat: dimos spy universal transport spy over LCM + Zenoh#2734

Closed
leshy wants to merge 3 commits into
mainfrom
agent/spy-worker
Closed

feat: dimos spy universal transport spy over LCM + Zenoh#2734
leshy wants to merge 3 commits into
mainfrom
agent/spy-worker

Conversation

@leshy

@leshy leshy commented Jul 6, 2026

Copy link
Copy Markdown
Member

`dimos spy` shows every topic flowing on every DimOS pubsub transport — LCM and Zenoh simultaneously — with per-topic rate, bandwidth, average size, total traffic and liveness in a Textual TUI (same look as `dimos lcmspy`, which stays as a deprecated alias routing to the LCM-only view).

Design doc: `docs/usage/transports/spy.md`.

Key properties:

  • No payload decoding on the hot path — per-message work is `(topic, len, timestamp)` only; enforced by contract tests that publish undecodable garbage and monkeypatch decoders to explode.
  • `subscribe_all` now means the same thing on every transport: every message, no conflation, full `**` scope. Zenoh's latest-wins conflation moved to an explicit opt-in `subscribe_latest()` (spec-level mixin); the rerun bridge — the one consumer that wants conflation — migrated to it.
  • Pluggable `SpySource` protocol (`LCMSpySource`, `ZenohSpySource` in v1); the spy observes both buses regardless of `DIMOS_TRANSPORT`.
  • Fixes uncached `resolve_msg_type` on the LCM per-message path (now `lru_cache`d, matching zenoh's cached key-expr mapping).

Testing: 17 new contract tests (`dimos/protocol/pubsub/test_spy.py`); existing `test_spec` / `test_zenohpubsub` / `test_lcmpubsub` / rerun bridge suites green; mypy clean; TUI smoke-tested against live LCM traffic.

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).
@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 pubsub spy for LCM and Zenoh. The main changes are:

  • New raw-byte spy sources and aggregate topic statistics.
  • New Textual dimos spy UI with transport filtering.
  • subscribe_all() now delivers every message, with subscribe_latest() for conflation.
  • The rerun bridge now uses subscribe_latest().
  • Documentation and contract tests for the new spy behavior.

Confidence Score: 4/5

The new spy paths need fixes before merging. Filtered CLI runs can initialize the wrong transports, and startup or subscription failures can leave background threads running.

  • Filtered commands can crash before the requested transport is selected.
  • Partial TransportSpy.start() failures do not roll back already-started sources.
  • subscribe_latest() can leak its drain thread when the underlying wildcard subscription fails.
  • The main happy-path spy and bridge behavior is covered by the new tests.

dimos/utils/cli/spy/run_spy.py, dimos/protocol/pubsub/spy.py, dimos/protocol/pubsub/spec.py, dimos/robot/cli/dimos.py

Important Files Changed

Filename Overview
dimos/protocol/pubsub/spec.py Adds subscribe_latest() and updates the subscribe-all contract, but subscription failure can leave the new drain thread running.
dimos/protocol/pubsub/impl/zenohpubsub.py Changes Zenoh subscribe-all to a non-conflating wildcard subscription and adds drain-stop hooks for shutdown.
dimos/protocol/pubsub/spy.py Adds spy sources and aggregation, but partial startup failures do not clean up sources already started.
dimos/utils/cli/spy/run_spy.py Adds the Textual spy UI and CLI transport filtering, with issues around source construction and invalid filter values.
dimos/robot/cli/dimos.py Adds dimos spy and routes dimos lcmspy through it, but forwarded transport flags can change the alias behavior.
dimos/visualization/rerun/bridge.py Moves the rerun bridge from subscribe_all() to subscribe_latest() to preserve latest-wins delivery.
dimos/protocol/pubsub/test_spy.py Adds contract tests for stats, raw spy sources, non-conflating subscribe-all, and subscribe-latest behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  CLI[dimos spy / dimos lcmspy] --> App[SpyApp]
  App --> Sources[Selected SpySources]
  Sources --> LCM[LCMSpySource]
  Sources --> Zenoh[ZenohSpySource]
  LCM --> Spy[TransportSpy]
  Zenoh --> Spy
  Spy --> Stats[TopicStats by transport and topic]
  Stats --> TUI[Textual DataTable]
  Bridge[Rerun bridge] --> Latest[subscribe_latest]
  Latest --> All[subscribe_all]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
  CLI[dimos spy / dimos lcmspy] --> App[SpyApp]
  App --> Sources[Selected SpySources]
  Sources --> LCM[LCMSpySource]
  Sources --> Zenoh[ZenohSpySource]
  LCM --> Spy[TransportSpy]
  Zenoh --> Spy
  Spy --> Stats[TopicStats by transport and topic]
  Stats --> TUI[Textual DataTable]
  Bridge[Rerun bridge] --> Latest[subscribe_latest]
  Latest --> All[subscribe_all]
Loading

Reviews (1): Last reviewed commit: "chore: drop internal task package file b..." | Re-trigger Greptile

Comment on lines +116 to +120
def __init__(self, transports: list[str] | None = None, **kwargs) -> None: # type: ignore[no-untyped-def]
super().__init__(**kwargs)
sources = default_sources()
if transports is not None:
sources = [s for s in sources if s.name in transports]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Filtered Transports Still Initialize

SpyApp constructs every default source before applying --transport, so dimos spy --transport lcm still imports and instantiates Zenoh and dimos spy --transport zenoh still constructs LCM. On an LCM-only host without Zenoh available, or a Zenoh-only run with a bad LCM URL, the filtered command can crash before the requested source is selected.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1ed1963 (carried to PR #2735): sources are now built from a SOURCE_FACTORIES name→factory registry, so SpyApp constructs only the requested transports and a filtered-out transport is never imported or instantiated.

Comment on lines +118 to +120
sources = default_sources()
if transports is not None:
sources = [s for s in sources if s.name in transports]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Unknown Transport Becomes Empty Spy

_parse_transports() accepts any string and this filter silently drops all sources when none match. A typo such as dimos spy --transport zneoh launches a working TUI with no sources and no warning, so the user sees an empty table forever instead of a clear CLI error.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1ed1963 (carried to PR #2735): _parse_transports() now validates against the known source names and exits with an error listing the valid choices (dimos spy --transport zneoh → "unknown transport(s) zneoh — valid choices: lcm, zenoh"), and SpyApp rejects unknown names too.

Comment thread dimos/robot/cli/dimos.py

sys.argv = ["lcmspy", *ctx.args]
lcmspy_main()
sys.argv = ["spy", "--transport", "lcm", *ctx.args]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Alias Transport Can Be Overridden

The deprecated alias prepends --transport lcm but still forwards user arguments afterward. dimos lcmspy --transport zenoh becomes spy --transport lcm --transport zenoh, so the LCM-only alias observes both transports instead of preserving the old LCM-only behavior.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1ed1963 (carried to PR #2735): the deprecated lcmspy alias now rejects any user-supplied --transport with an error pointing to dimos spy --transport ..., so it stays LCM-only.

Comment on lines +252 to +255
def start(self) -> None:
for source in self._sources:
source.start()
self._untaps.append(source.tap(self._tap_callback(source.name)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Partial Startup Leaves Sources Running

TransportSpy.start() starts and taps sources in sequence without rollback. If LCM starts successfully and Zenoh startup or tap registration raises, the exception escapes from SpyApp.__init__ before unmount cleanup exists, leaving the already-started LCM service thread and socket running in the failed process.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1ed1963 (carried to PR #2735): TransportSpy.start() now wraps the start/tap loop and on failure untaps and stops the already-started sources (in reverse order) before re-raising; covered by two new rollback tests in test_spy.py.

Comment on lines +197 to +199
if not self._register_drain_stop(stop_drain, thread):
return lambda: None
inner_unsub = self.subscribe_all(collect)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Failed Subscribe Leaks Drain Thread

subscribe_latest() starts the drain thread before the underlying subscribe_all() succeeds. If Zenoh raises while declaring the wildcard subscriber, the exception escapes and no unsubscribe is returned, leaving the daemon drain thread blocked on wake.wait() for the rest of the process.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1ed1963 (carried to PR #2735): subscribe_latest() now wraps subscribe_all() in try/except and on failure unregisters, stops, and joins the drain thread before re-raising; covered by a new leak test in test_spy.py.

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.88186% with 29 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
dimos/protocol/pubsub/impl/zenohpubsub.py 30.00% 7 Missing ⚠️
dimos/robot/cli/dimos.py 22.22% 7 Missing ⚠️
dimos/protocol/pubsub/spec.py 87.23% 4 Missing and 2 partials ⚠️
dimos/protocol/pubsub/test_spy.py 98.29% 3 Missing and 2 partials ⚠️
dimos/protocol/pubsub/spy.py 98.19% 2 Missing ⚠️
dimos/visualization/rerun/bridge.py 0.00% 1 Missing ⚠️
...mos/visualization/rerun/test_viewer_integration.py 50.00% 1 Missing ⚠️
@@            Coverage Diff             @@
##             main    #2734      +/-   ##
==========================================
+ Coverage   72.35%   72.47%   +0.11%     
==========================================
  Files         902      904       +2     
  Lines       81820    82253     +433     
  Branches     7396     7419      +23     
==========================================
+ Hits        59201    59612     +411     
- Misses      20639    20653      +14     
- Partials     1980     1988       +8     
Flag Coverage Δ
OS-ubuntu-24.04-arm 64.71% <93.88%> (+0.17%) ⬆️
OS-ubuntu-latest 67.34% <93.88%> (+0.15%) ⬆️
Py-3.10 67.33% <93.88%> (+0.15%) ⬆️
Py-3.11 67.32% <93.88%> (+0.15%) ⬆️
Py-3.12 67.32% <93.88%> (+0.15%) ⬆️
Py-3.13 67.32% <93.88%> (+0.14%) ⬆️
Py-3.14 67.34% <93.88%> (+0.15%) ⬆️
Py-3.14t 67.32% <93.88%> (+0.14%) ⬆️
SelfHosted-Linux 37.97% <23.83%> (-0.07%) ⬇️
SelfHosted-macOS 36.30% <23.83%> (-0.07%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
dimos/visualization/rerun/bridge.py 42.12% <0.00%> (ø)
...mos/visualization/rerun/test_viewer_integration.py 96.55% <50.00%> (-1.67%) ⬇️
dimos/protocol/pubsub/spy.py 98.19% <98.19%> (ø)
dimos/protocol/pubsub/test_spy.py 98.29% <98.29%> (ø)
dimos/protocol/pubsub/spec.py 67.17% <87.23%> (+10.19%) ⬆️
dimos/protocol/pubsub/impl/zenohpubsub.py 86.71% <30.00%> (-4.14%) ⬇️
dimos/robot/cli/dimos.py 63.09% <22.22%> (+0.04%) ⬆️

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants