Skip to content

Commit c880937

Browse files
JarbasAlclaude
andcommitted
fix(stop): rely on bus bridge for legacy mycroft.stop; drain active_skills
Address CodeRabbit review on #777 (OVOS-STOP-1 conformance): - handle_global_stop: drop the hand-rolled `mycroft.stop` emit. The spec-tools MIGRATION_MAP bridges `ovos.stop -> mycroft.stop` and the bus re-emits the legacy counterpart by default (emit_legacy ON during the migration window), so the manual emit double-delivered to anyone on both topics. Same fix as ovos-audio #171. Also switch the bare `ovos.utterance.handled` string to `SpecMessage.UTTERANCE_HANDLED` for a single source of truth (the only other spec-topic member in this file; `mycroft.*` / `ovos.skills.converse.force_timeout` are not SpecMessage members and stay as strings). - _drain_global_stop_session: also clear `session.active_skills` (the legacy recency list `get_active_skills` reads for stop-target routing), atomically with active_handlers/converse_handlers/response_mode. Left stale, a committed global_stop could route a later targeted stop to a skill it already drained. The two CI/pyproject nitpicks (dead bus-client git-ref + floor bump) were already resolved upstream by 851c6e1 + 87b940d. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 21a1347 commit c880937

4 files changed

Lines changed: 37 additions & 49 deletions

File tree

ovos_core/intent_services/stop_service.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,22 +79,19 @@ def handle_global_stop(self, message: Message) -> None:
7979
OVOS-STOP-1 §5.3: the global-stop handler emits the spec broadcast
8080
``ovos.stop`` (``SpecMessage.STOP``).
8181
82-
Back-compat: the MIGRATION_MAP bus bridge only re-delivers ``ovos.stop``
83-
on the legacy ``mycroft.stop`` topic when the bridge's legacy direction is
84-
enabled, which is NOT guaranteed (it is an opt-in on MessageBusClient and
85-
off in the pure-spec path). Skills have NOT migrated their stop handler —
86-
ovos-workshop still subscribes ONLY ``mycroft.stop`` — so a spec-only
87-
broadcast would silently fail to stop them. We therefore also emit the
88-
legacy ``mycroft.stop`` directly until the skill side migrates, mirroring
89-
the back-compat per-skill ``{skill_id}.stop.ping`` kept in
90-
``_collect_stop_skills``. The two topics target disjoint subscriber sets
91-
(spec vs un-migrated), so this is not a double broadcast to any one skill.
82+
Back-compat: ``mycroft.stop ↔ ovos.stop`` is a payload-compatible rename
83+
in the spec-tools MIGRATION_MAP, and the bus bridge re-delivers a spec
84+
emit on its legacy counterpart by default (``emit_legacy`` defaults ON
85+
during the migration window). So emitting ``ovos.stop`` ALSO reaches
86+
un-migrated skills still listening on ``mycroft.stop`` — without us
87+
hand-rolling a second emit. A manual ``mycroft.stop`` here would
88+
double-deliver to anyone subscribed on both topics, so we rely on the
89+
bridge (same fix as ovos-audio #171).
9290
"""
9391
with HandlerLifecycle(self.bus, message,
9492
skill_id="stop.openvoiceos",
9593
data={"name": "StopService.handle_global_stop"}):
9694
self.bus.emit(message.forward(SpecMessage.STOP))
97-
self.bus.emit(message.forward("mycroft.stop"))
9895

9996
@staticmethod
10097
def _drain_global_stop_session(session) -> "Session":
@@ -105,13 +102,17 @@ def _drain_global_stop_session(session) -> "Session":
105102
106103
- ``active_handlers`` → ``[]`` (§5.2 / §6.2)
107104
- ``converse_handlers`` → ``[]`` (§5.2 / §6.2, OVOS-CONVERSE-1 §2.1)
105+
- ``active_skills`` → ``[]`` (legacy recency list read by
106+
``get_active_skills`` → stop-target routing; left stale it could
107+
route a later targeted stop to a skill this global_stop drained)
108108
- ``response_mode`` → absent (§5.2 / §6.1)
109109
110-
All three are cleared atomically at match time so the drained state is
110+
All four are cleared atomically at match time so the drained state is
111111
committed before dispatch (PIPELINE-1 §4.2).
112112
"""
113113
session.active_handlers = []
114114
session.converse_handlers = []
115+
session.active_skills = []
115116
session.clear_response_mode()
116117
return session
117118

test/end2end/test_stop.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,6 @@ def _run_exact(self, namespace):
179179
Message("mycroft.skill.handler.start",
180180
{"name": "StopService.handle_global_stop"}),
181181
Message(STOP_BROADCAST, {}), # OVOS-STOP-1 §5.3 spec broadcast
182-
# back-compat: handle_global_stop also emits the legacy
183-
# mycroft.stop directly for un-migrated skills
184-
Message("mycroft.stop", {}),
185182
Message("mycroft.skill.handler.complete",
186183
{"name": "StopService.handle_global_stop"}),
187184

@@ -263,9 +260,6 @@ def _run_not_exact_med(self, namespace):
263260
Message("mycroft.skill.handler.start",
264261
{"name": "StopService.handle_global_stop"}),
265262
Message(STOP_BROADCAST, {}), # OVOS-STOP-1 §5.3 spec broadcast
266-
# back-compat: handle_global_stop also emits the legacy
267-
# mycroft.stop directly for un-migrated skills
268-
Message("mycroft.stop", {}),
269263
Message("mycroft.skill.handler.complete",
270264
{"name": "StopService.handle_global_stop"}),
271265

@@ -430,10 +424,6 @@ def make_it_count():
430424
{"skill_id": "stop.openvoiceos"}),
431425
Message(STOP_BROADCAST, {},
432426
{"skill_id": "stop.openvoiceos"}),
433-
# back-compat: handle_global_stop also emits the legacy mycroft.stop
434-
# directly — the un-migrated count skill listens there and stops
435-
Message("mycroft.stop", {},
436-
{"skill_id": "stop.openvoiceos"}),
437427
Message("mycroft.skill.handler.complete",
438428
{"name": "StopService.handle_global_stop"},
439429
{"skill_id": "stop.openvoiceos"}),

test/end2end/test_stop_refactor.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,11 @@ def _run_global_stop_voc_no_active_skills(self, namespace):
128128
message,
129129
Message("stop.openvoiceos.activate", {}),
130130
Message("stop:global", {}),
131-
# StopService wraps the global-stop handler in HandlerLifecycle
131+
# OVOS-STOP-1 §5.3: global-stop handler emits spec broadcast only.
132+
# Legacy compatibility is provided by the bus bridge (MIGRATION_MAP).
132133
Message("mycroft.skill.handler.start",
133134
{"name": "StopService.handle_global_stop"}),
134-
Message(STOP_BROADCAST, {}), # OVOS-STOP-1 §5.3 spec broadcast
135-
# back-compat: handle_global_stop also emits the legacy mycroft.stop
136-
# directly for un-migrated skills (no spec->legacy bridge guaranteed)
137-
Message("mycroft.stop", {}),
135+
Message(STOP_BROADCAST, {}), # OVOS-STOP-1 §5.3 spec broadcast (bridged to legacy mycroft.stop)
138136
Message("mycroft.skill.handler.complete",
139137
{"name": "StopService.handle_global_stop"}),
140138
Message(SpecMessage.UTTERANCE_HANDLED, {}),
@@ -175,13 +173,11 @@ def _run_stop_voc_exact_still_works(self, namespace):
175173
message,
176174
Message("stop.openvoiceos.activate", {}),
177175
Message("stop:global", {}),
178-
# StopService wraps the global-stop handler in HandlerLifecycle
176+
# OVOS-STOP-1 §5.3: global-stop handler emits spec broadcast only.
177+
# Legacy compatibility is provided by the bus bridge (MIGRATION_MAP).
179178
Message("mycroft.skill.handler.start",
180179
{"name": "StopService.handle_global_stop"}),
181-
Message(STOP_BROADCAST, {}), # OVOS-STOP-1 §5.3 spec broadcast
182-
# back-compat: handle_global_stop also emits the legacy mycroft.stop
183-
# directly for un-migrated skills (no spec->legacy bridge guaranteed)
184-
Message("mycroft.stop", {}),
180+
Message(STOP_BROADCAST, {}), # OVOS-STOP-1 §5.3 spec broadcast (bridged to legacy mycroft.stop)
185181
Message("mycroft.skill.handler.complete",
186182
{"name": "StopService.handle_global_stop"}),
187183
Message(SpecMessage.UTTERANCE_HANDLED, {}),
@@ -242,13 +238,11 @@ def _run_global_stop_voc_with_active_skill(self, namespace):
242238
message,
243239
Message("stop.openvoiceos.activate", {}),
244240
Message("stop:global", {}),
245-
# StopService wraps the global-stop handler in HandlerLifecycle
241+
# OVOS-STOP-1 §5.3: global-stop handler emits spec broadcast only.
242+
# Legacy compatibility is provided by the bus bridge (MIGRATION_MAP).
246243
Message("mycroft.skill.handler.start",
247244
{"name": "StopService.handle_global_stop"}),
248-
Message(STOP_BROADCAST, {}), # OVOS-STOP-1 §5.3 spec broadcast
249-
# back-compat: handle_global_stop also emits the legacy mycroft.stop
250-
# directly for un-migrated skills (no spec->legacy bridge guaranteed)
251-
Message("mycroft.stop", {}),
245+
Message(STOP_BROADCAST, {}), # OVOS-STOP-1 §5.3 spec broadcast (bridged to legacy mycroft.stop)
252246
Message("mycroft.skill.handler.complete",
253247
{"name": "StopService.handle_global_stop"}),
254248
Message(SpecMessage.UTTERANCE_HANDLED, {}),
@@ -416,13 +410,11 @@ def _run_stop_service_is_not_a_skill(self, namespace):
416410
message,
417411
Message("stop.openvoiceos.activate", {}),
418412
Message("stop:global", {}),
419-
# StopService wraps the global-stop handler in HandlerLifecycle
413+
# OVOS-STOP-1 §5.3: global-stop handler emits spec broadcast only.
414+
# Legacy compatibility is provided by the bus bridge (MIGRATION_MAP).
420415
Message("mycroft.skill.handler.start",
421416
{"name": "StopService.handle_global_stop"}),
422-
Message(STOP_BROADCAST, {}), # OVOS-STOP-1 §5.3 spec broadcast
423-
# back-compat: handle_global_stop also emits the legacy mycroft.stop
424-
# directly for un-migrated skills (no spec->legacy bridge guaranteed)
425-
Message("mycroft.stop", {}),
417+
Message(STOP_BROADCAST, {}), # OVOS-STOP-1 §5.3 spec broadcast (bridged to legacy mycroft.stop)
426418
Message("mycroft.skill.handler.complete",
427419
{"name": "StopService.handle_global_stop"}),
428420
Message(SpecMessage.UTTERANCE_HANDLED, {}),

test/unittests/test_stop_service.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from ovos_bus_client.message import Message
2020
from ovos_bus_client.session import Session, SessionManager, UtteranceState
21+
from ovos_spec_tools.messages import SpecMessage
2122
from ovos_utils.fakebus import FakeBus
2223

2324
from ovos_core.intent_services.stop_service import StopService
@@ -548,9 +549,10 @@ class TestBusHandlers(unittest.TestCase):
548549

549550
def test_handle_global_stop_emits_ovos_stop(self):
550551
# OVOS-STOP-1 §5.3: global-stop handler emits the spec topic ``ovos.stop``.
551-
# Back-compat: it ALSO emits the legacy ``mycroft.stop`` directly, because the
552-
# spec->legacy bus bridge is not guaranteed (opt-in / off on the pure-spec
553-
# path) and skills have not migrated their stop handler off ``mycroft.stop``.
552+
# Back-compat to un-migrated ``mycroft.stop`` listeners is provided by the
553+
# bus MIGRATION_MAP bridge (emit_legacy ON by default), NOT a manual
554+
# second emit — hand-rolling ``mycroft.stop`` here would double-deliver to
555+
# anyone on both topics (cf. ovos-audio #171).
554556
svc = _make_service()
555557
emitted = []
556558
svc.bus.emit = lambda m: emitted.append(m)
@@ -559,10 +561,9 @@ def test_handle_global_stop_emits_ovos_stop(self):
559561
types = [m.msg_type for m in emitted]
560562
self.assertIn("mycroft.skill.handler.start", types)
561563
self.assertIn("ovos.stop", types)
562-
self.assertIn("mycroft.stop", types)
563564
self.assertIn("mycroft.skill.handler.complete", types)
564-
# the spec broadcast is emitted before the legacy back-compat one
565-
self.assertLess(types.index("ovos.stop"), types.index("mycroft.stop"))
565+
# the handler does NOT hand-roll a legacy emit; the bridge mirrors it
566+
self.assertNotIn("mycroft.stop", types)
566567

567568
def test_handle_skill_stop_forwards_to_skill(self):
568569
svc = _make_service()
@@ -698,10 +699,11 @@ def test_targeted_stop_clears_target_response_mode_only(self):
698699

699700
def test_global_stop_drains_both_lists_and_response_mode(self):
700701
"""§5.2: global_stop updated_session sets active_handlers=[],
701-
converse_handlers=[], and removes response_mode."""
702+
converse_handlers=[], active_skills=[], and removes response_mode."""
702703
sess = Session("s")
703704
sess.active_handlers = [{"skill_id": "a", "activated_at": 1.0}]
704705
sess.converse_handlers = [{"skill_id": "b", "activated_at": 1.0}]
706+
sess.active_skills = [["a", 1.0]]
705707
sess.set_response_mode("c", 9999999999.0)
706708

707709
with patch.object(self.svc, "voc_match",
@@ -714,6 +716,9 @@ def test_global_stop_drains_both_lists_and_response_mode(self):
714716
self.assertEqual(result.match_type, "stop:global")
715717
self.assertEqual(result.updated_session.active_handlers, [])
716718
self.assertEqual(result.updated_session.converse_handlers, [])
719+
# §5.2: the legacy recency list is drained too, so a later targeted stop
720+
# cannot route to a skill this global_stop already cleared
721+
self.assertEqual(result.updated_session.active_skills, [])
717722
self.assertIsNone(result.updated_session.response_mode)
718723

719724
def test_global_stop_no_positive_pong_drains_session(self):

0 commit comments

Comments
 (0)