Skip to content

fix(core): match module refs by IS-A so a subclass provider resolves - #3408

Merged
mustafab0 merged 1 commit into
mainfrom
core-module-ref-subclass-match
Aug 8, 2026
Merged

fix(core): match module refs by IS-A so a subclass provider resolves#3408
mustafab0 merged 1 commit into
mainfrom
core-module-ref-subclass-match

Conversation

@mustafab0

Copy link
Copy Markdown
Contributor

Problem

A module can declare that it depends on another module by naming that module's class. The wiring layer matched that dependency by exact class. If a deployment swapped in a subclass, the match failed and the dependency was quietly set to
nothing at all.

Solution

Match with issubclass, which is what declaring the dependency means.

Only two modules in the tree are depended on by concrete class this way:
ControlCoordinator and GO2Connection. Every other dependency is declared
through a *Spec, which already matched structurally and so accepted subclasses.
That is why this never surfaced before: no deployment had yet subclassed either
one in a blueprint that also contained a module depending on it.


Breaking Changes

None.


How to Test

  1. pytest dimos/core/coordination/test_module_coordinator.py — 48 pass.
  2. The added test_module_ref_direct_accepts_a_subclass_provider is the regression
    guard; revert the one-line change and it fails, which is how it was verified.
  3. pytest dimos/core — one unrelated pre-existing failure, test_module_reloading,
    which fails identically on a clean main checkout.
    EOF
    gh pr create --repo dimensionalOS/dimos --base main --head core-module-ref-subclass-match --title "fix(core): match module refs by IS-A so a subclass provider resolves" --body-file /tmp/claude-1001/-home-mustafa-dimos/2e38385f-08b5-4bd8-ac72-4fd29234694d/scratchpad/core-pr.md 2>&1 | tail -3

A module ref declared as a concrete class (`coordinator: ControlCoordinator`)
was matched with `cls is spec`. A deployment that swaps in a subclass — to add
per-instance ports, say — therefore satisfied nothing: _resolve_single_ref fell
through to returning the spec class, get_instance() found no deployed instance
of it, and the ref was set to None. Nothing raised at wiring time; the failure
surfaced later as an AttributeError on the first call through the ref.

Match with issubclass instead, which is what a declared dependency means.

This is not hypothetical: the per-instance command-stream change in this branch
moves eight manipulator deployments onto ControlCoordinator subclasses, which
silently unbound ManipulationModule._control_coordinator (trajectory execution,
gripper get/set) in ten blueprints and ArmCommandModule.coordinator (E-STOP) in
the two hosted ones. Verified across every blueprint in all_blueprints: twelve
non-optional refs went unresolved before this fix and none after, with no ref
becoming ambiguous.

Only ControlCoordinator and GO2Connection are referenced by concrete class
anywhere in the tree; every other ref goes through a *Spec, which already
matched structurally and so accepted subclasses. GO2Connection carried the
same latent trap for whoever subclassed it next.
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
4207 1 4206 69
View the top 1 failed test(s) by shortest run time
dimos.e2e_tests.test_dimsim_path_replaning::test_path_replanning
Stack Traces | 222s run time
lcm_spy = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x73b978192270>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x73b95a386480>
dim_sim = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x73b9780bce30>
direct_cmd_vel_explorer = <dimos.simulation.mujoco.direct_cmd_vel_explorer.DirectCmdVelExplorer object at 0x73ba7aa25880>
spawn_wall_on_pose = <function spawn_wall_on_pose.<locals>.spawn at 0x73b95a386de0>

    @pytest.mark.self_hosted_large
    def test_path_replanning(
        lcm_spy, start_blueprint, dim_sim, direct_cmd_vel_explorer, spawn_wall_on_pose
    ) -> None:
        start_blueprint(
            "--dimsim-scene=empty",
            "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)
    
        # robot spawns at (3, 2)
    
        # side wall
        dim_sim.add_wall(2, -2.5, 12, -2.5)
        # other side wall
        dim_sim.add_wall(2, 3.5, 12, 3.5)
        # back wall (behind robot)
        dim_sim.add_wall(2, -2.5, 2, 3.5)
        # forward wall (far end)
        dim_sim.add_wall(12, -2.5, 12, 3.5)
        # dividing wall at x=7 with doors at y=[-1.5,-0.5] and y=[1.5,2.5]
        dim_sim.add_wall(7, -2.5, 7, -1.5)
        dim_sim.add_wall(7, -0.5, 7, 1.5)
        dim_sim.add_wall(7, 2.5, 7, 3.5)
    
        direct_cmd_vel_explorer.linear_speed = 0.8
        direct_cmd_vel_explorer.follow_points([(10, 2), (2.5, 2), (3, 2)])
    
        # When the robot comes within 1.5 m of the left door's centre, drop a wall
        # in the opening so the planner has to bail out and route through the
        # right door at y=-1 instead.
        spawn_wall_on_pose(
            point=(7, 2),
            threshold=1.5,
            wall=(7, 1.5, 7, 2.5),
        )
    
        dim_sim.publish_goal(10.913, 0.588)
    
>       lcm_spy.wait_until_odom_position(10.913, 0.588, threshold=1, timeout=120)

dim_sim    = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x73b9780bce30>
direct_cmd_vel_explorer = <dimos.simulation.mujoco.direct_cmd_vel_explorer.DirectCmdVelExplorer object at 0x73ba7aa25880>
lcm_spy    = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x73b978192270>
spawn_wall_on_pose = <function spawn_wall_on_pose.<locals>.spawn at 0x73b95a386de0>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x73b95a386480>

dimos/e2e_tests/test_dimsim_path_replaning.py:60: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
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 0x73b95a3876a0>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x73b978192270>
        threshold  = 1
        timeout    = 120
        x          = 10.913
        y          = 0.588
dimos/e2e_tests/lcm_spy.py:153: in wait_for_message_result
    wait_until(
        event      = <threading.Event at 0x73b95abf3680: unset>
        fail_message = 'Failed to get to position x=10.913, y=0.588'
        listener   = <function LcmSpy.wait_for_message_result.<locals>.listener at 0x73b95a387740>
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x73b95a3876a0>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x73b978192270>
        timeout    = 120
        topic      = '/odom#geometry_msgs.PoseStamped'
        type       = <class 'dimos.msgs.geometry_msgs.PoseStamped.PoseStamped'>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

predicate = <bound method Event.is_set of <threading.Event at 0x73b95abf3680: 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=10.913, y=0.588

deadline   = 2699491.899925911
interval   = 0.1
message    = 'Failed to get to position x=10.913, y=0.588'
predicate  = <bound method Event.is_set of <threading.Event at 0x73b95abf3680: unset>>
timeout    = 120

.../utils/testing/waiting.py:35: TimeoutError

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

@leshy leshy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving, but good for @jeff-hykin to look a this and confirm

@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Aug 8, 2026
@mustafab0
mustafab0 added this pull request to the merge queue Aug 8, 2026
Comment thread dimos/core/coordination/module_coordinator.py
Merged via the queue into main with commit 8fac510 Aug 8, 2026
53 of 54 checks passed
@mustafab0
mustafab0 deleted the core-module-ref-subclass-match branch August 8, 2026 23:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Required CI checks have passed on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants