Skip to content

feat: orchestrator owns the PIPELINE-1 §8 trio + §9 utterance-terminal events#788

Merged
JarbasAl merged 39 commits into
devfrom
feat/pipeline1-handler-trio
Jun 30, 2026
Merged

feat: orchestrator owns the PIPELINE-1 §8 trio + §9 utterance-terminal events#788
JarbasAl merged 39 commits into
devfrom
feat/pipeline1-handler-trio

Conversation

@JarbasAl

@JarbasAl JarbasAl commented Jun 28, 2026

Copy link
Copy Markdown
Member

What

Make ovos-core (the orchestrator) own the OVOS-PIPELINE-1 §6.1 per-utterance
terminal sequence end-to-end: the §8 handler-lifecycle trio wrapping each dispatch
and the §9 utterance-layer terminal events.

§8 handler-lifecycle trio (per dispatch)

ovos.intent.handler.start     (§8.1, before the <skill_id>:<intent_name> dispatch §7)
  ...handler runs in the skill...
ovos.intent.handler.complete  (§8.1, on the framework done-signal)        ─┐ exactly
 / .error                     (§8.1/§8.3, framework error / timeout)        ┘ one

New IntentDispatcher (ovos_core/intent_services/dispatcher.py). The handler emits
nothing (§8/§11); completion is observed across the bus via the skill framework's
legacy done-signals (mycroft.skill.handler.complete/.error). A per-dispatch §8.3
timeout guarantees exactly one terminal.

The pipeline-plugin "dispatches" that have no skill behind them — stop, converse,
fallback — wrap their work in HandlerLifecycle so they too emit the framework
done-signal, letting the orchestrator resolve the §8 trio promptly instead of waiting
out the timeout.

§9 utterance-layer terminals (per utterance)

  • §9.2 ovos.intent.matched — emitted by _dispatch_match on every accepted
    match, before the dispatch (skill_id, intent_name=<skill>:<intent> match_type,
    lang, utterance, slots, pipeline_id).
  • §9.3 ovos.intent.unmatched — the no-match / all-filtered terminal, replacing
    legacy complete_intent_failure (bridged by ovos-spec-tools, so legacy consumers
    still get it).
  • §6.4 ovos.utterance.cancelled on the cancel path.
  • §9.5 ovos.utterance.handled — exactly one per utterance. Core owns it on the
    no-match, cancel and §8.3-timeout paths; on the ordinary matched path the skill
    framework still emits it (full core ownership gated on the workshop reduction).

Correctness fixes in this PR

  • Fallback dispatch correlation — the fallback match topic
    ovos.skills.fallback.<id>.request carries no :, so the orchestrator's
    correlation key was derived as the whole topic and never matched the done-signal's
    real skill_id. The §8 terminal (and the §9.5 end-marker reacting to it) then only
    fired on the 5-minute §8.3 timeout for every fallback-handled utterance. Fixed
    by deriving the key from match_data['skill_id'] when the topic has no : (activation
    unchanged — match.skill_id stays None).
  • Converse dispatch terminalhandle_converse resolved the lifecycle from two
    threads (the skill.converse.response handler and the timeout timer) with a
    non-atomic check-then-set, so both could emit a done-signal. Resolution is now claimed
    under a Lock, and acks from a different session_id are ignored so a concurrent
    converse dispatch to the same skill in another session cannot cross-resolve.

Verified (real minicroft, e2e)

  • Matched: matched → handler.start → dispatch → mycroft.skill.handler.complete → handler.complete → handled — each exactly once (test_adapt, test_padatious,
    test_intent_pipeline).
  • No-match: play_sound → ovos.intent.unmatched → ovos.utterance.handled (no
    complete_intent_failure on the wire); test_no_skills / test_lang_detect pass.
  • Stop (test_stop, test_stop_refactor): global + skill-stop ping-pong now
    assert the StopService HandlerLifecycle trio around mycroft.stop / {skill}.stop.
  • Converse (test_converse, test_activate): the converse §8 trio is asserted;
    test_deactivate_inside_converse covers the deactivate-inside-converse path.
  • Fallback (test_fallback): asserts the §8 terminal, now emitted via correlation
    rather than the timeout.
  • Full in-repo unit suite green.

Test-harness notes

  • The ping-pong stop tests interrupt a running skill; its async cleanup
    (abort_question / converse.force_timeout / audio.speech.stop) fires or not
    depending on where the stop lands, so those artifacts are filtered via
    ignore_messages to keep the assertion deterministic.
  • e2e suites that assert routing rather than the trio/notification filter §9.2 + the
    §8 trio via ignore_messages (matching the conformance pattern).

Coordination dependencies (separate PRs)

  • ovos-workshop reduction — workshop MUST stop emitting ovos.utterance.handled
    on the matched path so core can own §9.5 there without a double end-marker. It KEEPS
    emitting legacy mycroft.skill.handler.* as core's done-signal.
  • ovos-spec-tools — remove the trio mycroft.skill.handler.* ↔ ovos.intent.handler.*
    MIGRATION_MAP bridge (ordered after this merges). Core is robust to the bridge meanwhile
    (resolved-guard + "message"-aggregate semantics keep counts at one).

Dep floors (pyproject)

ovos-bus-client>=2.6.0a1, ovos-spec-tools>=1.1.0a1, ovos-workshop>=9.0.2a1,
ovoscope>=1.0.0a1.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added richer orchestration lifecycle events for intent handling (intent matched/unmatched and handler start/complete/error), including timeout-based error signaling.
    • Enhanced conversation, fallback, and stop flows to emit consistent handler lifecycle progress.
  • Bug Fixes

    • Standardized no-match outcomes to emit a consistent “unmatched” event and ensured only one terminal outcome per handler run.
  • Tests

    • Updated end-to-end and unit tests to validate the new lifecycle event ordering and timeout behavior.
  • Documentation

    • Updated the changelog with the latest release entry.

Make ovos-core (the orchestrator) the authoritative emitter of the
OVOS-PIPELINE-1 §8 handler-lifecycle trio
(ovos.intent.handler.{start,complete,error}), wrapping every dispatch:
start immediately before the <skill_id>:<intent_name> dispatch (§7),
then exactly one of complete (on the framework done-signal) / error
(on the framework error signal or the §8.3 timeout). Each trio Message
is forward-derived from the dispatch so context (incl. session) is
preserved unchanged (§8, MSG-1 §5.1).

New IntentDispatcher (ovos_core/intent_services/dispatcher.py) owns the
§7 dispatch + §8 trio. Completion is observed across the distributed bus
via the skill framework's long-standing legacy done-signals
(mycroft.skill.handler.complete/.error) — framework infrastructure, not
the user handler (which emits nothing per §8/§11). A per-dispatch §8.3
timeout guarantees exactly one terminal even if the handler never
reports; on that path the orchestrator also owns ovos.utterance.handled.
Reserved-name dispatches get the trio identically (§7.0/§7.3); the
resolved-guard keeps the terminal count at one regardless of the bus
namespace bridge.

This is additive: the §9.5 end-marker on the ordinary matched path and
the §9.2 ovos.intent.matched notification are left to ovos-workshop /
follow-up changes and are out of scope here.

Dep floors: ovos-bus-client>=2.5.1a1, ovos-spec-tools>=0.17.3a1
(SpecMessage.INTENT_HANDLER_* members).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

Adds IntentDispatcher for session-scoped handler lifecycle tracking, routes IntentService matches through _dispatch_match, and updates tests, version, and dependency metadata for the new dispatch flow and terminal topics.

Changes

Intent dispatcher and spec event migration

Layer / File(s) Summary
IntentDispatcher lifecycle and timeout
ovos_core/intent_services/dispatcher.py
Defines dispatcher state, dispatch registration, LIFO resolution, done-signal translation, timeout handling, and shutdown cleanup.
IntentService dispatch and terminal events
ovos_core/intent_services/service.py, pyproject.toml, ovos_core/version.py
IntentService now creates IntentDispatcher, routes matches through _dispatch_match, emits ovos.intent.matched and ovos.intent.unmatched, migrates cancel handling to spec topics, shuts down the dispatcher, and updates dependency/version metadata.
Unit coverage for dispatcher and service integration
test/unittests/test_dispatcher.py, test/unittests/test_intent_service_extended.py
New dispatcher tests cover ordering, payloads, nesting, duplicate terminals, timeout behavior, and match-path dispatch start ordering; service extension tests are updated for the renamed match path and unmatched terminal topic.
End-to-end match lifecycle assertions
test/end2end/test_activate.py, test/end2end/test_adapt.py, test/end2end/test_converse.py, test/end2end/test_fallback.py, test/end2end/test_intent_pipeline.py, test/end2end/test_padatious.py, test/end2end/test_stop.py, test/end2end/test_stop_refactor.py
End-to-end scenarios now assert INTENT_MATCHED, HANDLER_START, and HANDLER_COMPLETE around dispatch paths.
End-to-end unmatched terminal assertions
test/end2end/test_adapt.py, test/end2end/test_converse.py, test/end2end/test_intent_pipeline.py, test/end2end/test_lang_detect.py, test/end2end/test_no_skills.py, test/end2end/test_padatious.py, test/end2end/test_stop.py, test/end2end/test_stop_refactor.py
End-to-end scenarios now expect INTENT_UNMATCHED instead of complete_intent_failure in no-match paths.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • OpenVoiceOS/ovos-core#775: Switches the utterance listener to SpecMessage.UTTERANCE, which connects directly to the updated IntentService dispatch flow.

Poem

🐇 Hop hop, the dispatcher spins,
Matched intents dance where order wins.
Start, then complete, or error glow,
Unmatched whispers drift below.
A timeout tick, a safe refrain,
And handled trails hum through the lane.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.16% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the core change: orchestrator ownership of the PIPELINE-1 handler trio and utterance terminal events.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/pipeline1-handler-trio

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jun 28, 2026

Copy link
Copy Markdown

I've combed through the code with a fine-tooth comb. 🔍

I've aggregated the results of the automated checks for this PR below.

🌍 Locale Build

Checking if everything is still on track. 🛤️

✅ Locale properly configured (64 files, 17 languages)

Locale directories found:

  • ovos_core/intent_services/locale

Localization coverage:

  • ovos_core/intent_services/locale: 64 files in 17 languages (eu-ES, es-es, it-it, de-de, uk-ua...)

pyproject.toml:[tool.setuptools.package-data.ovos_core] includes locale

  • intent_services/locale/*/*.voc

Build manifest: ✅ 31 locale files included in package

🏷️ Release Preview

I've checked the 'Legal' section for the release. ⚖️

Current: 2.2.4a3Next: 2.3.0a1

Signal Value
Label feature
PR title feat: orchestrator owns the PIPELINE-1 §8 trio + §9 utterance-terminal events
Bump minor

✅ PR title follows conventional commit format.


🚀 Release Channel Compatibility

Predicted next version: 2.3.0a1

Channel Status Note Current Constraint
Stable Too new (must be <1.4.0) ovos-core>=1.3.1,<1.4.0
Testing Compatible ovos-core>=2.1.1,<3.0.0
Alpha Compatible ovos-core>=2.2.4a1

📋 Repo Health

The repo's annual physical is complete! 🩺

✅ All required files present.

Latest Version: 2.2.4a3

ovos_core/version.py — Version file
README.md — README
LICENSE — License file
pyproject.toml — pyproject.toml
⚠️ setup.py — setup.py
CHANGELOG.md — Changelog
ovos_core/version.py has valid version block markers

🔒 Security (pip-audit)

Ensuring our defenses are strong against vulnerabilities. 🏰

✅ No known vulnerabilities found (110 packages scanned).

📊 Coverage

Ensuring the code doesn't have any blind spots. 🕶️

⚠️ 62.3% total coverage

Files below 80% coverage (9 files)
File Coverage Missing lines
ovos_core/__init__.py 0.0% 7
ovos_core/__main__.py 0.0% 26
ovos_core/intent_services/__init__.py 0.0% 1
ovos_core/version.py 0.0% 18
ovos_core/skill_installer.py 42.3% 139
ovos_core/intent_services/service.py 49.4% 174
ovos_core/skill_manager.py 58.8% 173
ovos_core/transformers.py 66.0% 49
ovos_core/intent_services/dispatcher.py 73.4% 29

Full report: download the coverage-report artifact.

🔨 Build Tests

Testing the robustness of the build environment. 🏔️

✅ All versions pass

Python Build Install Tests
3.10
3.11
3.12
3.13
3.14

⚖️ License Check

Scanning for any potential trademark infringements. ™️

✅ No license violations found.

Policy: Apache 2.0 (universal donor). StrongCopyleft / NetworkCopyleft / WeakCopyleft / Other / Error categories fail. MPL allowed.

🔌 Skill Tests (ovoscope)

Scanning the conversational landscape for anomalies. 🕵️

2/36 passed

❌ **TestAdaptIntent** — 0/4
Test Result
test_intent_blacklist ❌ subtests passed
test_adapt_match ❌ subtests passed
test_skill_blacklist ❌ subtests passed
test_padatious_no_match ❌ subtests passed

test_intent_blacklist failure:

[gw3] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_adapt_match failure:

[gw2] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_skill_blacklist failure:

[gw3] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestCancelIntentMidSentence** — 0/1
Test Result
test_cancel_match ❌ subtests passed

test_cancel_match failure:

[gw0] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestConverse** — 0/1
Test Result
test_parrot_mode ❌ subtests passed

test_parrot_mode failure:

[gw0] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestCountSkills** — 0/4
Test Result
test_count_infinity_active ❌ subtests passed
test_count_infinity_global ❌ subtests passed
test_count ❌ subtests passed
test_count_infinity_stop_low ❌ subtests passed

test_count_infinity_active failure:

[gw3] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_count_infinity_global failure:

[gw2] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_count failure:

[gw3] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestDeactivate** — 2/3
Test Result
test_deactivate_inside_converse ❌ subtests passed
test_deactivate ✅ passed
test_activate ✅ passed

test_deactivate_inside_converse failure:

[gw1] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestFallback** — 0/1
Test Result
test_fallback_match ❌ subtests passed

test_fallback_match failure:

[gw3] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestGlobalStopVocWithActiveSkill** — 0/1
Test Result
test_global_stop_voc_with_active_skill ❌ subtests passed

test_global_stop_voc_with_active_skill failure:

[gw2] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestGlobalStopVocabulary** — 0/2
Test Result
test_stop_voc_exact_still_works ❌ subtests passed
test_global_stop_voc_no_active_skills ❌ subtests passed

test_stop_voc_exact_still_works failure:

[gw3] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_global_stop_voc_no_active_skills failure:

[gw2] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestIntentPipelineRouting** — 0/4
Test Result
test_no_match_produces_intent_failure ❌ subtests passed
test_high_priority_stage_handles_before_low ❌ subtests passed
test_padatious_intent_matched ❌ subtests passed
test_blacklisted_skill_falls_through_to_failure ❌ subtests passed

test_no_match_produces_intent_failure failure:

[gw0] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_high_priority_stage_handles_before_low failure:

[gw1] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_padatious_intent_matched failure:

[gw0] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestLangDisambiguation** — 0/4
Test Result
test_stt_lang ❌ subtests passed
test_lang_text_detection ❌ subtests passed
test_invalid_lang_detection ❌ subtests passed
test_metadata_preferred_over_text_detection ❌ subtests passed

test_stt_lang failure:

[gw3] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_lang_text_detection failure:

[gw3] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_invalid_lang_detection failure:

[gw3] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestNoSkills** — 0/2
Test Result
test_routing ❌ subtests passed
test_complete_failure ❌ subtests passed

test_routing failure:

[gw0] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_complete_failure failure:

[gw0] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestPadatiousIntent** — 0/4
Test Result
test_skill_blacklist ❌ subtests passed
test_padatious_match ❌ subtests passed
test_intent_blacklist ❌ subtests passed
test_adapt_no_match ❌ subtests passed

test_skill_blacklist failure:

[gw1] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_padatious_match failure:

[gw2] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_intent_blacklist failure:

[gw1] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestStopNoSkills** — 0/3
Test Result
test_not_exact_med ❌ subtests passed
test_not_exact_high ❌ subtests passed
test_exact ❌ subtests passed

test_not_exact_med failure:

[gw1] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_not_exact_high failure:

[gw3] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

test_exact failure:

[gw0] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestStopServiceNotASkill** — 0/1
Test Result
test_stop_service_is_not_a_skill ❌ subtests passed

test_stop_service_is_not_a_skill failure:

[gw3] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python
❌ **TestStopSkillCanHandleFalse** — 0/1
Test Result
test_stop_with_active_skill_ping_pong ❌ subtests passed

test_stop_with_active_skill_ping_pong failure:

[gw1] linux -- Python 3.11.15 /opt/hostedtoolcache/Python/3.11.15/x64/bin/python

🚌 Bus Coverage

I've mapped out the message bus traffic for your skill. 🚥

🔴 Coverage Summary

Metric Status Coverage
Listeners ██░░░░░░░░ 24.2% 43/178 handlers
Emitters ██████████ 100% 23/23 observed
Assertions ██████████ 100% 23/23 asserted

📊 Per-Skill Breakdown

Skill Listeners Observed Asserted
AdaptPipeline 4/14 (28.6%) 0/0 0/0
ConverseService 0/4 (0.0%) 0/0 0/0
DomainAdaptPipeline 4/14 (28.6%) 0/0 0/0
FallbackService 0/2 (0.0%) 0/0 0/0
HierarchicalAdaptPipeline 4/14 (28.6%) 0/0 0/0
IntentDispatcher 1/2 (50.0%) 0/0 0/0
IntentService 2/4 (50.0%) 0/0 0/0
Model2VecIntentPipeline 3/12 (25.0%) 0/0 0/0
Model2VecPrototypePipeline 3/12 (25.0%) 0/0 0/0
PadaciosoPipeline 2/11 (18.2%) 0/0 0/0
PadatiousPipeline 4/15 (26.7%) 0/0 0/0
SkillManager 0/4 (0.0%) 0/0 0/0
StopService 2/2 (100.0%) 0/0 0/0
__core__ 5/20 (25.0%) 5/5 5/5
ovos-skill-count.openvoiceos 4/20 (20.0%) 0/0 0/0
ovos-skill-hello-world.openvoiceos 3/24 (12.5%) 10/10 10/10
stop.openvoiceos 0/0 (0.0%) 8/8 8/8
type 2/4 (50.0%) 0/0 0/0
🔍 Detailed Message Type Breakdown

AdaptPipeline

⚠️ Uncovered Listeners:

  • ovos.intent.deregister (Intent)
  • ovos.intent.disable (Intent)
  • ovos.intent.enable (Intent)
  • ovos.intent.register.keyword (Intent)
  • detach_intent
  • intent.service.adapt.get
  • intent.service.adapt.vocab.manifest.get
  • ovos.entity.deregister
  • ovos.entity.register
  • ovos.skill.deregister
    ✅ Covered Listeners:
  • detach_skill (169x)
  • intent.service.adapt.manifest.get (626x)
  • register_intent (136x)
  • register_vocab (860x)

ConverseService

⚠️ Uncovered Listeners:

  • converse:skill (Intent)
  • intent.service.active_skills.get
  • intent.service.skills.activate
  • intent.service.skills.deactivate

DomainAdaptPipeline

⚠️ Uncovered Listeners:

  • ovos.intent.deregister (Intent)
  • ovos.intent.disable (Intent)
  • ovos.intent.enable (Intent)
  • ovos.intent.register.keyword (Intent)
  • detach_intent
  • intent.service.adapt.get
  • intent.service.adapt.vocab.manifest.get
  • ovos.entity.deregister
  • ovos.entity.register
  • ovos.skill.deregister
    ✅ Covered Listeners:
  • detach_skill (169x)
  • intent.service.adapt.manifest.get (626x)
  • register_intent (136x)
  • register_vocab (860x)

FallbackService

⚠️ Uncovered Listeners:

  • ovos.skills.fallback.deregister
  • ovos.skills.fallback.register

HierarchicalAdaptPipeline

⚠️ Uncovered Listeners:

  • ovos.intent.deregister (Intent)
  • ovos.intent.disable (Intent)
  • ovos.intent.enable (Intent)
  • ovos.intent.register.keyword (Intent)
  • detach_intent
  • intent.service.adapt.get
  • intent.service.adapt.vocab.manifest.get
  • ovos.entity.deregister
  • ovos.entity.register
  • ovos.skill.deregister
    ✅ Covered Listeners:
  • detach_skill (169x)
  • intent.service.adapt.manifest.get (626x)
  • register_intent (136x)
  • register_vocab (860x)

IntentDispatcher

⚠️ Uncovered Listeners:

  • mycroft.skill.handler.error
    ✅ Covered Listeners:
  • mycroft.skill.handler.complete (132x)

IntentService

⚠️ Uncovered Listeners:

  • intent.service.intent.get (Intent)
  • intent.service.skills.deactivate
    ✅ Covered Listeners:
  • intent.service.pipelines.reload (136x)
  • ovos.utterance.handle (86x)

Model2VecIntentPipeline

⚠️ Uncovered Listeners:

  • ovos.intent.deregister (Intent)
  • ovos.intent.disable (Intent)
  • ovos.intent.enable (Intent)
  • ovos.intent.register.template (Intent)
  • detach_intent
  • mycroft.ready
  • ovos.entity.deregister
  • ovos.entity.register
  • ovos.skill.deregister
    ✅ Covered Listeners:
  • detach_skill (169x)
  • padatious:register_intent (185x)
  • register_intent (136x)

Model2VecPrototypePipeline

⚠️ Uncovered Listeners:

  • ovos.intent.deregister (Intent)
  • ovos.intent.disable (Intent)
  • ovos.intent.enable (Intent)
  • ovos.intent.register.template (Intent)
  • detach_intent
  • mycroft.ready
  • ovos.entity.deregister
  • ovos.entity.register
  • ovos.skill.deregister
    ✅ Covered Listeners:
  • detach_skill (169x)
  • padatious:register_intent (185x)
  • register_intent (136x)

PadaciosoPipeline

⚠️ Uncovered Listeners:

  • ovos.intent.deregister (Intent)
  • ovos.intent.disable (Intent)
  • ovos.intent.enable (Intent)
  • ovos.intent.register.template (Intent)
  • padatious:register_entity (Intent)
  • detach_intent
  • ovos.entity.deregister
  • ovos.entity.register
  • ovos.skill.deregister
    ✅ Covered Listeners:
  • detach_skill (169x)
  • padatious:register_intent (185x)

PadatiousPipeline

⚠️ Uncovered Listeners:

  • ovos.intent.deregister (Intent)
  • ovos.intent.disable (Intent)
  • ovos.intent.enable (Intent)
  • ovos.intent.register.template (Intent)
  • padatious:register_entity (Intent)
  • detach_intent
  • intent.service.padatious.entities.manifest.get
  • intent.service.padatious.get
  • ovos.entity.deregister
  • ovos.entity.register
  • ovos.skill.deregister
    ✅ Covered Listeners:
  • detach_skill (169x)
  • intent.service.padatious.manifest.get (626x)
  • mycroft.skills.train (136x)
  • padatious:register_intent (185x)

SkillManager

⚠️ Uncovered Listeners:

  • skillmanager.activate
  • skillmanager.deactivate
  • skillmanager.keep
  • skillmanager.list

StopService

✅ Covered Listeners:

  • stop:global (57x)
  • stop:skill (11x)

__core__

⚠️ Uncovered Listeners:

  • add_context
  • clear_context
  • message
  • mycroft.ovos-skill-count.openvoiceos.all_loaded
  • mycroft.ovos-skill-count.openvoiceos.is_alive
  • mycroft.ovos-skill-count.openvoiceos.is_ready
  • mycroft.ovos-skill-hello-world.openvoiceos.all_loaded
  • mycroft.ovos-skill-hello-world.openvoiceos.is_alive
  • mycroft.ovos-skill-hello-world.openvoiceos.is_ready
  • ovos-skill-count.openvoiceos.set
  • ovos-skill-hello-world.openvoiceos.set
  • ovos.session.sync
  • remove_context
  • skill.converse.get_response.disable
  • skill.converse.get_response.enable
    ✅ Covered Listeners:
  • ovos-skill-count.openvoiceos.stop.response (30x)
  • ovos.session.update_default (136x)
  • ovos.utterance.handled (223x)
  • ovos.utterance.speak (94x)
  • skill.stop.pong (10x)

📤 Emitters:

  • mycroft.audio.play_sound (Asserted ✅)
  • ovos.intent.unmatched (Asserted ✅)
  • ovos.utterance.handle (Asserted ✅)
  • ovos.utterance.handled (Asserted ✅)
  • recognizer_loop:utterance (Asserted ✅)

ovos-skill-count.openvoiceos

⚠️ Uncovered Listeners:

  • question:action (Intent)
  • question:action.ovos-skill-count.openvoiceos (Intent)
  • question:query (Intent)
  • homescreen.metadata.get
  • mycroft.ovos-skill-count.openvoiceos.all_loaded
  • mycroft.ovos-skill-count.openvoiceos.is_alive
  • mycroft.ovos-skill-count.openvoiceos.is_ready
  • mycroft.skill.disable_intent
  • mycroft.skill.enable_intent
  • mycroft.skill.remove_cross_context
  • mycroft.skill.set_cross_context
  • mycroft.skills.settings.changed
  • ovos-skill-count.openvoiceos.converse.get_response
  • ovos-skill-count.openvoiceos.set
  • ovos.common_query.ping
  • ovos.skills.settings_changed
    ✅ Covered Listeners:
  • mycroft.stop (57x)
  • ovos-skill-count.openvoiceos.stop (11x)
  • ovos-skill-count.openvoiceos.stop.ping (11x)
  • ovos-skill-count.openvoiceos:count_to_N.intent (26x)

ovos-skill-hello-world.openvoiceos

⚠️ Uncovered Listeners:

  • ovos-skill-hello-world.openvoiceos:HowAreYou.intent (Intent)
  • ovos-skill-hello-world.openvoiceos:ThankYouIntent (Intent)
  • question:action (Intent)
  • question:action.ovos-skill-hello-world.openvoiceos (Intent)
  • question:query (Intent)
  • hello.world
  • homescreen.metadata.get
  • mycroft.ovos-skill-hello-world.openvoiceos.all_loaded
  • mycroft.ovos-skill-hello-world.openvoiceos.is_alive
  • mycroft.ovos-skill-hello-world.openvoiceos.is_ready
  • mycroft.skill.disable_intent
  • mycroft.skill.enable_intent
  • mycroft.skill.remove_cross_context
  • mycroft.skill.set_cross_context
  • mycroft.skills.settings.changed
  • ovos-skill-hello-world.openvoiceos.converse.get_response
  • ovos-skill-hello-world.openvoiceos.set
  • ovos-skill-hello-world.openvoiceos.stop
  • ovos-skill-hello-world.openvoiceos.stop.ping
  • ovos.common_query.ping
  • ovos.skills.settings_changed
    ✅ Covered Listeners:
  • mycroft.stop (57x)
  • ovos-skill-hello-world.openvoiceos:Greetings.intent (7x)
  • ovos-skill-hello-world.openvoiceos:HelloWorldIntent (31x)

📤 Emitters:

  • mycroft.skill.handler.complete (Asserted ✅)
  • mycroft.skill.handler.start (Asserted ✅)
  • ovos-skill-hello-world.openvoiceos.activate (Asserted ✅)
  • ovos-skill-hello-world.openvoiceos:Greetings.intent (Asserted ✅)
  • ovos-skill-hello-world.openvoiceos:HelloWorldIntent (Asserted ✅)
  • ovos.intent.handler.complete (Asserted ✅)
  • ovos.intent.handler.start (Asserted ✅)
  • ovos.intent.matched (Asserted ✅)
  • ovos.utterance.handled (Asserted ✅)
  • ovos.utterance.speak (Asserted ✅)

stop.openvoiceos

📤 Emitters:

  • mycroft.skill.handler.complete (Asserted ✅)
  • mycroft.skill.handler.start (Asserted ✅)
  • mycroft.stop (Asserted ✅)
  • ovos-skill-count.openvoiceos.stop (Asserted ✅)
  • ovos.utterance.handled (Asserted ✅)
  • stop.openvoiceos.activate (Asserted ✅)
  • stop:global (Asserted ✅)
  • stop:skill (Asserted ✅)

type

⚠️ Uncovered Listeners:

  • recognizer_loop:record_begin (Intent)
  • recognizer_loop:record_end (Intent)
    ✅ Covered Listeners:
  • recognizer_loop:audio_output_end (92x)
  • recognizer_loop:audio_output_start (94x)

📚 Docs

Checking the boxes and crossing the T's. 🖋️

✅ All required documentation files present.

README.md

🔎 Type Check

I've checked the pulse of your pull request. 💓

mypy: 263 error(s) found

ovos_core/main.py:24:1: error: Skipping analyzing "ovos_utils.log": module is installed, but missing library stubs or py.typed marker [import-untyped]

Errors (showing first 10/263)
test/unittests/test_skill/__init__.py:15:1: error: Skipping analyzing "ovos_workshop.skills.ovos": module is installed, but missing library stubs or py.typed marker  [import-untyped]
test/end2end/conftest.py:10:1: error: Cannot find implementation or library stub for module named "pytest"  [import-not-found]
ovos_core/transformers.py:2:1: error: Skipping analyzing "ovos_config": module is installed, but missing library stubs or py.typed marker  [import-untyped]
ovos_core/transformers.py:3:1: error: Skipping analyzing "ovos_plugin_manager.intent_transformers": module is installed, but missing library stubs or py.typed marker  [import-untyped]
ovos_core/transformers.py:4:1: error: Skipping analyzing "ovos_plugin_manager.metadata_transformers": module is installed, but missing library stubs or py.typed marker  [import-untyped]
ovos_core/transformers.py:5:1: error: Skipping analyzing "ovos_plugin_manager.text_transformers": module is installed, but missing library stubs or py.typed marker  [import-untyped]
ovos_core/transformers.py:7:1: error: Skipping analyzing "ovos_plugin_manager.templates.pipeline": module is installed, but missing library stubs or py.typed marker  [import-untyped]
ovos_core/transformers.py:8:1: error: Skipping analyzing "ovos_utils.json_helper": module is installed, but missing library stubs or py.typed marker  [import-untyped]
ovos_core/transformers.py:9:1: error: Skipping analyzing "ovos_utils.log": module is installed, but missing library stubs or py.typed marker  [import-untyped]
ovos_core/transformers.py:16:9: error: Need type annotation for "loaded_plugins" (hint: "loaded_plugins: dict[<type>, <type>] = ...")  [var-annotated]

🔌 Plugin Detection

I've performed a surgical audit of your plugin's OPM hooks. 🩺

⚠️ Plugin Status: WARNINGS (2)

Plugin Info:

  • Name: ovos-core
  • Description: The spiritual successor to Mycroft AI, OVOS is flexible voice assistant software that can be run almost anywhere!

OPM Detection:

Plugin Type Wheel Editable
pipeline

Entry Point Validation:

Entry Point Type Import Interface
ovos-converse-pipeline-plugin pipeline ✅ 15ms
ovos-fallback-pipeline-plugin pipeline ✅ 1ms
ovos-stop-pipeline-plugin pipeline ✅ 52ms

⊘ No settingsmeta.json
requires-python >=3.10 — running Python 3.11

Issues:

  • ⚠️ No settingsmeta.json found
  • ⚠️ No settingsmeta.json found

Crafting a better voice assistant, one commit at a time 🎙️

Complete the orchestrator's ownership of the OVOS-PIPELINE-1 §6.1
per-utterance terminal sequence, on top of the §8 handler-lifecycle
trio:

- §9.2 ovos.intent.matched — emitted by _dispatch_match on every
  accepted match, before the dispatch goes out (notification, not a
  dispatch). Carries skill_id, intent_name (the full
  <skill_id>:<intent_name> match_type), lang, utterance, slots,
  pipeline_id.
- §9.3 ovos.intent.unmatched — the no-match / all-filtered terminal,
  replacing the legacy complete_intent_failure (the two are bridged by
  ovos-spec-tools' MIGRATION_MAP, so emitting the spec topic re-delivers
  the legacy one to consumers still on it).
- §6.4 cancellation now emits the spec ovos.utterance.cancelled.

Each utterance terminates with exactly one ovos.utterance.handled
(§9.5): core owns it on the no-match, cancel and §8.3-timeout paths;
on the ordinary matched path the skill framework still emits it (moving
that fully into core is gated on the ovos-workshop reduction).

Rename _emit_match_message -> _dispatch_match (it orchestrates the §6.1
post-match steps then dispatches) and correct the IntentDispatcher
docstring to scope it to §7 dispatch + §8 trio (the §9.2 notification
lives in the service).

Verified on a real minicroft: matched path emits matched/start/
complete/handled exactly once each; no-match path emits
ovos.intent.unmatched + ovos.utterance.handled (no complete_intent_
failure). test_no_skills / test_lang_detect conformance suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@JarbasAl JarbasAl changed the title feat: orchestrator emits the PIPELINE-1 §8 handler-lifecycle trio feat: orchestrator owns the PIPELINE-1 §8 trio + §9 utterance-terminal events Jun 28, 2026
@github-actions github-actions Bot added feature and removed feature labels Jun 28, 2026
@JarbasAl JarbasAl marked this pull request as ready for review June 28, 2026 00:40
The converse-deactivate (test_activate) and fallback (test_fallback)
ovoscope scenarios each gain two captured messages now that the
orchestrator emits ovos.intent.matched (§9.2) and ovos.intent.handler.
start (§8.1) natively before every dispatch — previously the spec trio
existed only as uncounted bridge-mirrors of workshop's legacy emit.

Verified on a real minicroft: the two extra messages per scenario are
exactly ovos.intent.matched + ovos.intent.handler.start (the
reserved-name converse:skill / fallback .request dispatches carry no
mycroft.skill.handler.* done-signal, so their §8 terminal resolves via
the §8.3 timeout after the end-marker, not captured). No spec-topic
double-emit: ovos.intent.matched, ovos.intent.handler.start and
ovos.utterance.handled each appear exactly once per utterance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@JarbasAl JarbasAl force-pushed the feat/pipeline1-handler-trio branch from 176a163 to 6a0153c Compare June 28, 2026 03:21
The orchestrator owns the universal end-marker on EVERY terminal path. The
dispatcher already emitted ovos.utterance.handled on the timeout path; emit it
after the complete/error terminals as well, so a matched dispatch also ends with
exactly one core-emitted handled (the _pop/resolved guard fires one terminal per
dispatch -> one handled). Unmatched/cancel keep their service.py handled.

Workshop may still emit its own matched-path handled during the migration window;
that core-vs-workshop duplicate is expected and removed later workshop-side.
@JarbasAl JarbasAl force-pushed the feat/pipeline1-handler-trio branch from 6a0153c to 7bbe86e Compare June 28, 2026 03:22
@github-actions github-actions Bot added feature and removed feature labels Jun 28, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
test/end2end/test_stop.py (1)

52-60: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This ignore list hides the new stop dispatch contract.

Filtering ovos.intent.matched plus the handler lifecycle trio here means the stop suite no longer checks the orchestrator-owned match-path signals at all. That weakens coverage for the exact behavior this PR is introducing on stop flows. Please assert those topics in the matched cases and reserve IGNORE_MESSAGES for unrelated background traffic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/end2end/test_stop.py` around lines 52 - 60, The stop end-to-end ignore
list is overly broad and is hiding the new dispatch contract; in the stop
suite’s IGNORE_MESSAGES setup, remove the orchestrator-owned match-path topics
from the blanket exclusions and update the matched stop scenarios to assert the
expected `ovos.intent.matched` and handler lifecycle signals instead. Keep
`IGNORE_MESSAGES` limited to unrelated background traffic, and use the existing
stop dispatch tests in `test_stop.py` to verify the new contract rather than
filtering it out.
test/end2end/test_stop_refactor.py (1)

64-71: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Don't filter out the new orchestrator lifecycle in the stop-path E2Es.

Adding ovos.intent.matched and the handler trio to _STOP_RESPONSES means this suite no longer verifies the stop-flow ordering this PR introduces. A regression where stop dispatch skips INTENT_MATCHED or never resolves with ovos.intent.handler.complete would still pass here. Please keep only unrelated stop-response noise in this filter and assert the new topics in the matched stop scenarios instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/end2end/test_stop_refactor.py` around lines 64 - 71, The stop-path E2E
filter is too broad because `_STOP_RESPONSES` now excludes the new orchestrator
lifecycle topics, which hides the ordering this suite should validate. Remove
`ovos.intent.matched` and the handler trio (`HANDLER_START`, `HANDLER_COMPLETE`,
`HANDLER_ERROR`) from that filter, keep only unrelated stop-response noise, and
update the matched stop scenarios to explicitly assert the new stop-flow topics
and their ordering. Use the existing `_STOP_RESPONSES` setup and the matched
stop test cases in `test_stop_refactor.py` to locate the changes.
test/unittests/test_dispatcher.py (1)

195-216: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Avoid fixed sleeps in timeout tests.

These assertions depend on threading.Timer firing within a hardcoded sleep window. Hook an event from the observed ERROR/COMPLETE emission and wait on it to reduce CI flakes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unittests/test_dispatcher.py` around lines 195 - 216, The timeout tests
in IntentDispatcher are relying on hardcoded sleep windows, which can make them
flaky under CI timing. Update test_timeout_emits_error_and_handled and
test_timeout_does_not_double_fire_if_skill_reports to wait on an event triggered
by the observed ERROR or COMPLETE emission instead of time.sleep. Use the
existing IntentDispatcher dispatch path and the recorder assertions on
self.rec.by_topic(ERROR), self.rec.by_topic(COMPLETE), and HANDLED to
synchronize deterministically before asserting.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ovos_core/intent_services/dispatcher.py`:
- Around line 27-29: Update the dispatcher docstring in the section describing
matched-path handled ownership so it reflects the current behavior. The comments
in the module docstring still say §9.5 is separate/framework-owned on the
ordinary matched path, but `IntentDispatcher` now emits
`SpecMessage.UTTERANCE_HANDLED` itself on both completion and error; revise the
wording near the `IntentDispatcher` and matched-path documentation to state that
the dispatcher owns the handled notification in this flow.
- Around line 123-128: The shutdown cleanup in dispatcher.py is swallowing bus
handler removal failures and a single exception prevents the second unsubscribe
from running, leaving the dispatcher subscribed. Update the shutdown method on
the dispatcher class so each bus.remove call for mycroft.skill.handler.complete
and mycroft.skill.handler.error is handled independently, ensuring both removals
are attempted even if one fails, and avoid silently ignoring the failure (at
minimum log it with context).

In `@ovos_core/intent_services/service.py`:
- Around line 358-374: Resolve the dispatch identity once in service.py within
the IntentService flow: compute the fallback skill_id and intent_name before
emitting INTENT_MATCHED, then reuse the same values for both the
bus.emit(reply.forward(SpecMessage.INTENT_MATCHED, ...)) payload and
intent_dispatcher.dispatch. Update the local logic around match.skill_id,
reply.msg_type, and IntentDispatcher.dispatch so the matched event and handler
lifecycle events always carry the same resolved identifiers when match.skill_id
is missing.

In `@test/unittests/test_dispatcher.py`:
- Around line 231-235: The helper setup in the test uses multiple statements on
one line, which triggers Ruff E702. Split the chained assignments for the
MagicMock helpers in the dispatcher test setup into separate lines so each of
ut, svc.utterance_plugins, mt, svc.metadata_plugins, it, and svc.input_plugins
is assigned independently, keeping the same transform side_effect behavior.

---

Nitpick comments:
In `@test/end2end/test_stop_refactor.py`:
- Around line 64-71: The stop-path E2E filter is too broad because
`_STOP_RESPONSES` now excludes the new orchestrator lifecycle topics, which
hides the ordering this suite should validate. Remove `ovos.intent.matched` and
the handler trio (`HANDLER_START`, `HANDLER_COMPLETE`, `HANDLER_ERROR`) from
that filter, keep only unrelated stop-response noise, and update the matched
stop scenarios to explicitly assert the new stop-flow topics and their ordering.
Use the existing `_STOP_RESPONSES` setup and the matched stop test cases in
`test_stop_refactor.py` to locate the changes.

In `@test/end2end/test_stop.py`:
- Around line 52-60: The stop end-to-end ignore list is overly broad and is
hiding the new dispatch contract; in the stop suite’s IGNORE_MESSAGES setup,
remove the orchestrator-owned match-path topics from the blanket exclusions and
update the matched stop scenarios to assert the expected `ovos.intent.matched`
and handler lifecycle signals instead. Keep `IGNORE_MESSAGES` limited to
unrelated background traffic, and use the existing stop dispatch tests in
`test_stop.py` to verify the new contract rather than filtering it out.

In `@test/unittests/test_dispatcher.py`:
- Around line 195-216: The timeout tests in IntentDispatcher are relying on
hardcoded sleep windows, which can make them flaky under CI timing. Update
test_timeout_emits_error_and_handled and
test_timeout_does_not_double_fire_if_skill_reports to wait on an event triggered
by the observed ERROR or COMPLETE emission instead of time.sleep. Use the
existing IntentDispatcher dispatch path and the recorder assertions on
self.rec.by_topic(ERROR), self.rec.by_topic(COMPLETE), and HANDLED to
synchronize deterministically before asserting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1f78ab6c-254d-46a1-9f64-7c184f5c20dc

📥 Commits

Reviewing files that changed from the base of the PR and between f1e9f49 and 7bbe86e.

📒 Files selected for processing (15)
  • ovos_core/intent_services/dispatcher.py
  • ovos_core/intent_services/service.py
  • pyproject.toml
  • test/end2end/test_activate.py
  • test/end2end/test_adapt.py
  • test/end2end/test_converse.py
  • test/end2end/test_fallback.py
  • test/end2end/test_intent_pipeline.py
  • test/end2end/test_lang_detect.py
  • test/end2end/test_no_skills.py
  • test/end2end/test_padatious.py
  • test/end2end/test_stop.py
  • test/end2end/test_stop_refactor.py
  • test/unittests/test_dispatcher.py
  • test/unittests/test_intent_service_extended.py

Comment thread ovos_core/intent_services/dispatcher.py Outdated
Comment thread ovos_core/intent_services/dispatcher.py Outdated
Comment on lines +123 to +128
def shutdown(self):
try:
self.bus.remove("mycroft.skill.handler.complete", self._on_skill_complete)
self.bus.remove("mycroft.skill.handler.error", self._on_skill_error)
except Exception:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Don’t silently swallow bus handler removal failures.

If one remove() fails, the second removal is skipped and the dispatcher can remain subscribed after shutdown, causing duplicate lifecycle emissions on later service instances.

Proposed cleanup
     def shutdown(self):
-        try:
-            self.bus.remove("mycroft.skill.handler.complete", self._on_skill_complete)
-            self.bus.remove("mycroft.skill.handler.error", self._on_skill_error)
-        except Exception:
-            pass
+        for event_name, handler in (
+            ("mycroft.skill.handler.complete", self._on_skill_complete),
+            ("mycroft.skill.handler.error", self._on_skill_error),
+        ):
+            try:
+                self.bus.remove(event_name, handler)
+            except Exception as e:
+                LOG.warning(f"Failed to remove dispatcher bus handler {event_name}: {e}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def shutdown(self):
try:
self.bus.remove("mycroft.skill.handler.complete", self._on_skill_complete)
self.bus.remove("mycroft.skill.handler.error", self._on_skill_error)
except Exception:
pass
def shutdown(self):
for event_name, handler in (
("mycroft.skill.handler.complete", self._on_skill_complete),
("mycroft.skill.handler.error", self._on_skill_error),
):
try:
self.bus.remove(event_name, handler)
except Exception as e:
LOG.warning(f"Failed to remove dispatcher bus handler {event_name}: {e}")
🧰 Tools
🪛 Ruff (0.15.18)

[error] 127-128: try-except-pass detected, consider logging the exception

(S110)


[warning] 127-127: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ovos_core/intent_services/dispatcher.py` around lines 123 - 128, The shutdown
cleanup in dispatcher.py is swallowing bus handler removal failures and a single
exception prevents the second unsubscribe from running, leaving the dispatcher
subscribed. Update the shutdown method on the dispatcher class so each
bus.remove call for mycroft.skill.handler.complete and
mycroft.skill.handler.error is handled independently, ensuring both removals are
attempted even if one fails, and avoid silently ignoring the failure (at minimum
log it with context).

Source: Linters/SAST tools

Comment thread ovos_core/intent_services/service.py
Comment thread test/unittests/test_dispatcher.py
JarbasAl and others added 3 commits June 28, 2026 04:33
…ard)

ovos-workshop 9.0.2a1 (#442) guards its matched-path ovos.utterance.handled
emission behind a version check on the installed ovos-core, so once core ships
the §9.5 matched-path emission the framework stops double-emitting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This feat bumps core to the 2.3.x line. ovos-workshop 9.0.2a1 suppresses its
matched-path ovos.utterance.handled only when the installed ovos-core is
>=2.3.0a1; staging the version here lets PR CI install a core that trips that
guard, so the e2e exercises core as the single end-marker emitter rather than
relying on the published 2.2.x. Release automation re-derives the final version
on merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added feature and removed feature labels Jun 28, 2026
JarbasAl and others added 2 commits June 28, 2026 15:04
…ble)

The published m2v 0.0.10a1 caps ovos-workshop<9.0.0; with core's workshop floor
at 9.0.2a1 pip backtracked m2v down to 0.0.10a1 and hit that cap -> ResolutionImpossible.
m2v 0.3.1a1 drops the workshop dependency entirely, so floor-pinning it (the
prerelease-floor-pin pattern) forbids the backtrack and the closure resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ases

core's bus-client>=2.5.1a1 pulls ovos-bus-client 2.5.1a3/2.6.0a1, which require
ovos-spec-tools>=1.1.0a1. pip backtracked the mycroft/plugins/skills-essential
extras down to stale releases that cap ovos-spec-tools<1.0.0 (e.g. ovos-audio
2.0.1a1) -> ResolutionImpossible. Floor-pin each to its latest prerelease (all
spec-tools-1.x-ready) so the resolver can't backtrack into the capped ones, and
make core's own ovos-spec-tools floor explicit at >=1.1.0a1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added feature and removed feature labels Jun 28, 2026
JarbasAl and others added 3 commits June 28, 2026 15:31
Every ovos-adapt-parser release up to 1.4.1a1 caps ovos-spec-tools<1.0.0; the
uncap landed in 1.4.2a1. Floor-pin it so pip can't backtrack into the capped
versions while core requires ovos-spec-tools>=1.1.0a1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- fallback speak meta carries the skill's dialog/data keys, not just skill.
- ovos.utterance.handled (§9.5) is the orchestrator end-marker with EMPTY data;
  the stop count-to-infinity / ping-pong expectations wrongly carried the
  handler name on it (KeyError 'name'). The handler name stays on the framework
  mycroft.skill.handler.complete signal, where it belongs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ignals done

PIPELINE-1 §9.5: ovos.utterance.handled is the orchestrator's universal end-marker,
not the dispatcher's. The IntentDispatcher owned only the §8 handler-lifecycle trio
but was also emitting the §9.5 end-marker on its terminal paths — wrong layer.

- IntentDispatcher no longer emits ovos.utterance.handled. Each in-flight dispatch
  carries a 'done' Event set on its §8 terminal (complete/error/timeout); dispatch()
  returns the entry.
- IntentService._dispatch_match blocks on entry.done (the §8.3 timeout guarantees it
  fires) then emits the single ovos.utterance.handled, uniformly with the no-match
  (send_complete_intent_failure) and cancel (send_cancel_event) paths it already owns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JarbasAl and others added 4 commits June 29, 2026 20:52
…ill_id

The fallback pipeline's match_type (ovos.skills.fallback.<id>.request) carries
no ':', so the orchestrator derived the dispatcher correlation key as the whole
topic. The framework done-signal (re-emitted mycroft.skill.handler.complete) is
stamped with the real skill_id, so IntentDispatcher._pop never matched it and the
§8 ovos.intent.handler.complete terminal — and with it the §9.5
ovos.utterance.handled end-marker — only fired on the 5-minute §8.3 handler
timeout. Every fallback-handled utterance was affected in production.

Derive the correlation key from match_data['skill_id'] when the topic has no ':',
so it equals the done-signal's skill_id. Activation is unchanged (match.skill_id
stays None, no spurious {skill_id}.activate). test_fallback now asserts the §8
terminal, which can only be captured when correlation succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StopService wraps its global/skill stop handlers in HandlerLifecycle (#796), so
the legacy mycroft.skill.handler.{start,complete} done-signal now brackets
mycroft.stop / {skill_id}.stop. Update the stop expectations to assert the trio.

The ping-pong tests built the stop message from a stale, test-local Session that
never saw the count skill's server-side self-activation (the count message,
serialized before activation, folds an empty active_skills back into the
singleton — correct SESSION-1 last-write-wins). Resend the live singleton session
for the stop turn, as a real client tracking responses would, instead of manually
activating — so the running skill is in active_skills and the ping-pong path runs
without a manual activate crutch. Also drop the §8 ovos.intent.handler.complete
from the expected lists where it is filtered via ignore_messages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
handle_converse resolved the §8 lifecycle from two threads (the
skill.converse.response handler and the timeout timer) with a non-atomic
check-then-set on an Event, so both could pass the guard and emit two framework
done-signals (complete + error). Claim the resolution under a Lock so exactly one
terminal fires. Also ignore acks carrying a different session_id, so a concurrent
converse dispatch to the same skill in another session cannot cross-resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…artifacts

- test_deactivate_inside_converse: ConverseService now reports the dispatch via
  the mycroft.skill.handler.* done-signal which the orchestrator translates to the
  §8 ovos.intent.handler.complete terminal; assert the trio (+3 messages).
- The ping-pong stop tests interrupt a running skill; the async stop-pipeline
  cleanup (abort_question / converse.force_timeout / audio.speech.stop) fires or
  not depending on exactly where the stop lands, so it raced the message count in
  CI. Ignore those artifacts (they are not what the tests assert) and drop the
  flaky force_timeout async_messages assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added feature and removed feature labels Jun 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ovos_core/intent_services/dispatcher.py (1)

252-272: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid timeout terminals after the entry has been cleared.

If shutdown clears _in_flight while a timer callback is already queued, this path still emits ovos.intent.handler.error and calls on_terminal. Return when the entry is no longer registered.

Proposed fix
         with self._lock:
             if entry.resolved:
                 return
-            entry.resolved = True
-            entry.timer = None
             stack = self._in_flight.get(sid)
-            if stack and entry in stack:
-                stack.remove(entry)
-                if not stack:
-                    self._in_flight.pop(sid, None)
+            if not stack or entry not in stack:
+                entry.resolved = True
+                return
+            entry.resolved = True
+            entry.timer = None
+            stack.remove(entry)
+            if not stack:
+                self._in_flight.pop(sid, None)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ovos_core/intent_services/dispatcher.py` around lines 252 - 272, The
_on_timeout handler in Dispatcher still emits a timeout terminal even after
shutdown has already cleared the in-flight entry. Update _on_timeout to verify
the sid/entry is still registered in _in_flight before marking resolved or
calling _emit and _notify_terminal; if the entry is no longer present, return
immediately. Use the existing _on_timeout, _in_flight, and _notify_terminal
logic to keep the timeout path from producing a terminal after cleanup.
♻️ Duplicate comments (1)
ovos_core/intent_services/service.py (1)

396-403: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Resolve dispatch identity before emitting INTENT_MATCHED.

Line 377 still publishes match.skill_id/match.match_type, while Lines 396-399 may dispatch with fallback identifiers. For no-colon fallback/converse topics, §9.2 and §8 payloads can disagree.

Proposed fix
+            skill_id = (match.skill_id
+                        or (match.match_data or {}).get("skill_id")
+                        or reply.msg_type.split(":", 1)[0])
+            intent_name = reply.msg_type.split(":", 1)[-1]
+
             # OVOS-PIPELINE-1 §9.2: broadcast ovos.intent.matched BEFORE the
             # dispatch goes out. A notification, not a dispatch: consumers MUST NOT
             # treat receipt as permission to run a handler.
             self.bus.emit(reply.forward(SpecMessage.INTENT_MATCHED, {
-                "skill_id": match.skill_id,
-                "intent_name": match.match_type,
+                "skill_id": skill_id,
+                "intent_name": intent_name,
                 "lang": lang,
                 "utterance": match.utterance,
                 "slots": dict(match.match_data or {}),
                 "pipeline_id": reply.context.get("pipeline_id"),
             }))
@@
-            skill_id = (match.skill_id
-                        or (match.match_data or {}).get("skill_id")
-                        or reply.msg_type.split(":", 1)[0])
-            intent_name = reply.msg_type.split(":", 1)[-1]
             # The §8 terminal (complete/error/timeout) the dispatcher emits drives
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ovos_core/intent_services/service.py` around lines 396 - 403, Resolve the
mismatch between the identity used for INTENT_MATCHED and the identity used for
dispatch in service.py. Update the code around the IntentDispatcher flow so the
same resolved skill_id and intent_name/match_type are computed once from
match.skill_id, match.match_data, and reply.msg_type, then reused both when
publishing INTENT_MATCHED and when calling dispatch. Make sure no-colon
fallback/converse topics do not produce different identifiers between the match
event and the dispatch path.
🧹 Nitpick comments (1)
test/unittests/test_stop_service.py (1)

530-544: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert lifecycle ordering, not just presence.

These tests would pass even if complete emitted before the stop dispatch. Since this PR relies on start → dispatch → complete ordering, assert relative order here.

Proposed fix
         self.assertIn("mycroft.skill.handler.start", types)
         self.assertIn("mycroft.stop", types)
         self.assertIn("mycroft.skill.handler.complete", types)
+        self.assertLess(types.index("mycroft.skill.handler.start"),
+                        types.index("mycroft.stop"))
+        self.assertLess(types.index("mycroft.stop"),
+                        types.index("mycroft.skill.handler.complete"))
@@
         self.assertIn("mycroft.skill.handler.start", types)
         self.assertIn("my_skill.stop", types)
         self.assertIn("mycroft.skill.handler.complete", types)
+        self.assertLess(types.index("mycroft.skill.handler.start"),
+                        types.index("my_skill.stop"))
+        self.assertLess(types.index("my_skill.stop"),
+                        types.index("mycroft.skill.handler.complete"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unittests/test_stop_service.py` around lines 530 - 544, The stop handler
tests only verify that lifecycle messages are emitted, but they do not verify
the required start → dispatch → complete sequence. Update the assertions in
test_handle_stop and test_handle_skill_stop_forwards_to_skill to check the
relative order of the emitted message types from svc.bus.emit, using the
existing mycroft.skill.handler.start, mycroft.stop/my_skill.stop, and
mycroft.skill.handler.complete symbols so the tests fail if complete is
published before the stop dispatch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ovos_core/intent_services/converse_service.py`:
- Around line 78-83: The _resolve_complete handler in converse_service should
only complete when both the originating skill and session match. Update the
logic so skill.converse.response messages without a skill_id are not accepted,
and ensure the session check is always enforced against
SessionManager.get(msg).session_id before resolving. Keep the guard behavior in
_resolve_complete strict for both skill_id and session_id comparisons.

In `@ovos_core/intent_services/dispatcher.py`:
- Line 93: The __slots__ declaration in the Dispatcher-related class is not
sorted, triggering Ruff RUF023. Reorder the entries in the __slots__ tuple in
the class that defines skill_id, intent_name, dispatch_msg, timer, and resolved
so they are alphabetically sorted, keeping the rest of the class unchanged.

In `@test/end2end/test_stop_refactor.py`:
- Around line 297-301: The stop-turn setup in the test still reuses the session
after a fixed sleep, which can race with the daemon’s update to
SessionManager.sessions and make the stop path nondeterministic. Update the test
around the SessionManager.sessions/session.session_id rebinding so it explicitly
waits until the live session reflects the running skill in active_skills before
sending stop, using the existing SessionManager/session flow rather than relying
on timing alone.

In `@test/end2end/test_stop.py`:
- Around line 294-299: The stop end-to-end test is rebinding to
SessionManager.sessions too early, so the background activation may not have
populated active_skills before the stop utterance is sent. Update the test
around the SessionManager.sessions/session.session_id lookup to wait until the
live session contains self.skill_id, or otherwise gate on the activation bus
event, before serializing and sending the stop turn. Keep the existing
SessionManager.sessions/session.session_id flow but add a readiness check so the
stop path reliably sees the active skill.

In `@test/unittests/test_converse_service.py`:
- Around line 723-729: The converse dispatch tests leave the pending lifecycle
active because handle_converse() is called without completing the targeted
response, so add explicit cleanup after the assertions in
test_dispatch_emits_handler_start_with_skill_id and
test_response_from_other_skill_does_not_complete. Use the existing
Message/dispatch flow to emit the matching response for the targeted skill_id so
the converse listener/timer is resolved before the test ends, matching the
cleanup pattern used elsewhere in test_converse_service.py.
- Around line 796-800: The converse service test uses a fixed sleep after
svc.handle_converse, which can still be flaky under load; replace the
time.sleep(0.2) wait with polling on captured until the
mycroft.skill.handler.error topic appears or a short deadline expires. Update
the test in test_converse_service around handle_converse/captured so it waits
deterministically for the timeout terminal instead of relying on elapsed sleep.

---

Outside diff comments:
In `@ovos_core/intent_services/dispatcher.py`:
- Around line 252-272: The _on_timeout handler in Dispatcher still emits a
timeout terminal even after shutdown has already cleared the in-flight entry.
Update _on_timeout to verify the sid/entry is still registered in _in_flight
before marking resolved or calling _emit and _notify_terminal; if the entry is
no longer present, return immediately. Use the existing _on_timeout, _in_flight,
and _notify_terminal logic to keep the timeout path from producing a terminal
after cleanup.

---

Duplicate comments:
In `@ovos_core/intent_services/service.py`:
- Around line 396-403: Resolve the mismatch between the identity used for
INTENT_MATCHED and the identity used for dispatch in service.py. Update the code
around the IntentDispatcher flow so the same resolved skill_id and
intent_name/match_type are computed once from match.skill_id, match.match_data,
and reply.msg_type, then reused both when publishing INTENT_MATCHED and when
calling dispatch. Make sure no-colon fallback/converse topics do not produce
different identifiers between the match event and the dispatch path.

---

Nitpick comments:
In `@test/unittests/test_stop_service.py`:
- Around line 530-544: The stop handler tests only verify that lifecycle
messages are emitted, but they do not verify the required start → dispatch →
complete sequence. Update the assertions in test_handle_stop and
test_handle_skill_stop_forwards_to_skill to check the relative order of the
emitted message types from svc.bus.emit, using the existing
mycroft.skill.handler.start, mycroft.stop/my_skill.stop, and
mycroft.skill.handler.complete symbols so the tests fail if complete is
published before the stop dispatch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1d00ea19-c72e-4084-8ce5-3ffdf0728b1d

📥 Commits

Reviewing files that changed from the base of the PR and between 2bbeda0 and 36f3268.

📒 Files selected for processing (20)
  • CHANGELOG.md
  • ovos_core/intent_services/converse_service.py
  • ovos_core/intent_services/dispatcher.py
  • ovos_core/intent_services/fallback_service.py
  • ovos_core/intent_services/service.py
  • ovos_core/intent_services/stop_service.py
  • pyproject.toml
  • test/end2end/conftest.py
  • test/end2end/test_activate.py
  • test/end2end/test_adapt.py
  • test/end2end/test_converse.py
  • test/end2end/test_fallback.py
  • test/end2end/test_intent_pipeline.py
  • test/end2end/test_padatious.py
  • test/end2end/test_stop.py
  • test/end2end/test_stop_refactor.py
  • test/unittests/test_converse_service.py
  • test/unittests/test_dispatcher.py
  • test/unittests/test_fallback_service.py
  • test/unittests/test_stop_service.py
✅ Files skipped from review due to trivial changes (2)
  • CHANGELOG.md
  • test/end2end/conftest.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • test/end2end/test_activate.py
  • pyproject.toml
  • test/end2end/test_adapt.py
  • test/end2end/test_converse.py
  • test/end2end/test_padatious.py
  • test/unittests/test_dispatcher.py

Comment on lines +78 to +83
def _resolve_complete(msg: Message) -> None:
if msg.data.get("skill_id") and msg.data.get("skill_id") != skill_id:
return # ack from a different skill, ignore
if "session" in msg.context and \
SessionManager.get(msg).session_id != session_id:
return # ack from a different session, ignore

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Require both skill_id and session to match before completing.

Line 79 accepts responses with no skill_id, and Line 81 only checks the session when the response includes one. A malformed or cross-session skill.converse.response can therefore complete the wrong in-flight converse lifecycle.

Proposed fix
         def _resolve_complete(msg: Message) -> None:
-            if msg.data.get("skill_id") and msg.data.get("skill_id") != skill_id:
+            if msg.data.get("skill_id") != skill_id:
                 return  # ack from a different skill, ignore
-            if "session" in msg.context and \
-                    SessionManager.get(msg).session_id != session_id:
+            if SessionManager.get(msg).session_id != session_id:
                 return  # ack from a different session, ignore
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _resolve_complete(msg: Message) -> None:
if msg.data.get("skill_id") and msg.data.get("skill_id") != skill_id:
return # ack from a different skill, ignore
if "session" in msg.context and \
SessionManager.get(msg).session_id != session_id:
return # ack from a different session, ignore
def _resolve_complete(msg: Message) -> None:
if msg.data.get("skill_id") != skill_id:
return # ack from a different skill, ignore
if SessionManager.get(msg).session_id != session_id:
return # ack from a different session, ignore
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ovos_core/intent_services/converse_service.py` around lines 78 - 83, The
_resolve_complete handler in converse_service should only complete when both the
originating skill and session match. Update the logic so skill.converse.response
messages without a skill_id are not accepted, and ensure the session check is
always enforced against SessionManager.get(msg).session_id before resolving.
Keep the guard behavior in _resolve_complete strict for both skill_id and
session_id comparisons.

class _InFlightDispatch:
"""A dispatch awaiting its §8 terminal."""

__slots__ = ("skill_id", "intent_name", "dispatch_msg", "timer", "resolved")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Sort __slots__ to satisfy Ruff RUF023.

Proposed fix
-    __slots__ = ("skill_id", "intent_name", "dispatch_msg", "timer", "resolved")
+    __slots__ = ("dispatch_msg", "intent_name", "resolved", "skill_id", "timer")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
__slots__ = ("skill_id", "intent_name", "dispatch_msg", "timer", "resolved")
__slots__ = ("dispatch_msg", "intent_name", "resolved", "skill_id", "timer")
🧰 Tools
🪛 Ruff (0.15.20)

[warning] 93-93: _InFlightDispatch.__slots__ is not sorted

Apply a natural sort to _InFlightDispatch.__slots__

(RUF023)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ovos_core/intent_services/dispatcher.py` at line 93, The __slots__
declaration in the Dispatcher-related class is not sorted, triggering Ruff
RUF023. Reorder the entries in the __slots__ tuple in the class that defines
skill_id, intent_name, dispatch_msg, timer, and resolved so they are
alphabetically sorted, keeping the rest of the class unchanged.

Source: Linters/SAST tools

Comment on lines +297 to +301
# The count intent self-activates the skill server-side; the Session
# singleton holds the authoritative state (SESSION-1 last-write-wins).
# Resend the live session for the stop turn as a real client would, so
# the running skill is in active_skills — no manual activation required.
session = SessionManager.sessions[session.session_id]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Wait for the live session to show the running skill before sending stop.

This rebinding still depends on the daemon turn having finished mutating SessionManager.sessions[...] by the time the fixed sleep expires. If that update lands late, the stop utterance is built from a session without the active skill and the ping/pong path becomes nondeterministic.

Possible fix
-        # The count intent self-activates the skill server-side; the Session
-        # singleton holds the authoritative state (SESSION-1 last-write-wins).
-        # Resend the live session for the stop turn as a real client would, so
-        # the running skill is in active_skills — no manual activation required.
-        session = SessionManager.sessions[session.session_id]
+        deadline = time.monotonic() + 10
+        while time.monotonic() < deadline:
+            live_session = SessionManager.sessions.get(session.session_id)
+            if live_session and any(skill_id == self.skill_id
+                                    for skill_id, _ in live_session.active_skills):
+                session = deepcopy(live_session)
+                break
+            time.sleep(0.1)
+        else:
+            self.fail("count skill did not become active before issuing stop")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# The count intent self-activates the skill server-side; the Session
# singleton holds the authoritative state (SESSION-1 last-write-wins).
# Resend the live session for the stop turn as a real client would, so
# the running skill is in active_skills — no manual activation required.
session = SessionManager.sessions[session.session_id]
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
live_session = SessionManager.sessions.get(session.session_id)
if live_session and any(skill_id == self.skill_id
for skill_id, _ in live_session.active_skills):
session = deepcopy(live_session)
break
time.sleep(0.1)
else:
self.fail("count skill did not become active before issuing stop")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/end2end/test_stop_refactor.py` around lines 297 - 301, The stop-turn
setup in the test still reuses the session after a fixed sleep, which can race
with the daemon’s update to SessionManager.sessions and make the stop path
nondeterministic. Update the test around the
SessionManager.sessions/session.session_id rebinding so it explicitly waits
until the live session reflects the running skill in active_skills before
sending stop, using the existing SessionManager/session flow rather than relying
on timing alone.

Comment thread test/end2end/test_stop.py
Comment on lines +723 to +729
def test_dispatch_emits_handler_start_with_skill_id(self):
"""handle_converse emits mycroft.skill.handler.start at dispatch,
stamped with the targeted skill_id, then the converse.request."""
svc, captured = self._service_with_capture()
msg = Message("converse:skill", {"skill_id": "skill_a",
"utterances": ["hello"]})
svc.handle_converse(msg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Clean up pending converse lifecycles in non-terminal tests.

These tests call handle_converse() without resolving the targeted dispatch, leaving the 5-minute timer/listener alive after the test. Add cleanup that emits the matching response after assertions.

Proposed fix
         svc.handle_converse(msg)
+        self.addCleanup(
+            svc.bus.emit,
+            Message("skill.converse.response",
+                    {"skill_id": "skill_a", "result": True})
+        )

Apply the same cleanup after svc.handle_converse(msg) in test_response_from_other_skill_does_not_complete.

Also applies to: 759-771

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unittests/test_converse_service.py` around lines 723 - 729, The converse
dispatch tests leave the pending lifecycle active because handle_converse() is
called without completing the targeted response, so add explicit cleanup after
the assertions in test_dispatch_emits_handler_start_with_skill_id and
test_response_from_other_skill_does_not_complete. Use the existing
Message/dispatch flow to emit the matching response for the targeted skill_id so
the converse listener/timer is resolved before the test ends, matching the
cleanup pattern used elsewhere in test_converse_service.py.

Comment on lines +796 to +800
with patch("ovos_core.intent_services.converse_service.CONVERSE_HANDLER_TIMEOUT", 0.05):
svc.handle_converse(msg)
time.sleep(0.2)
topics = [m.msg_type for m in captured]
self.assertIn("mycroft.skill.handler.error", topics)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Poll for the timeout terminal instead of sleeping.

The fixed time.sleep(0.2) can still flake under a busy runner. Poll until mycroft.skill.handler.error appears or a deadline expires.

Proposed fix
         with patch("ovos_core.intent_services.converse_service.CONVERSE_HANDLER_TIMEOUT", 0.05):
             svc.handle_converse(msg)
-            time.sleep(0.2)
+            deadline = time.time() + 2
+            while not any(m.msg_type == "mycroft.skill.handler.error"
+                          for m in captured) and time.time() < deadline:
+                time.sleep(0.01)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
with patch("ovos_core.intent_services.converse_service.CONVERSE_HANDLER_TIMEOUT", 0.05):
svc.handle_converse(msg)
time.sleep(0.2)
topics = [m.msg_type for m in captured]
self.assertIn("mycroft.skill.handler.error", topics)
with patch("ovos_core.intent_services.converse_service.CONVERSE_HANDLER_TIMEOUT", 0.05):
svc.handle_converse(msg)
deadline = time.time() + 2
while not any(m.msg_type == "mycroft.skill.handler.error"
for m in captured) and time.time() < deadline:
time.sleep(0.01)
topics = [m.msg_type for m in captured]
self.assertIn("mycroft.skill.handler.error", topics)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unittests/test_converse_service.py` around lines 796 - 800, The converse
service test uses a fixed sleep after svc.handle_converse, which can still be
flaky under load; replace the time.sleep(0.2) wait with polling on captured
until the mycroft.skill.handler.error topic appears or a short deadline expires.
Update the test in test_converse_service around handle_converse/captured so it
waits deterministically for the timeout terminal instead of relying on elapsed
sleep.

JarbasAl and others added 6 commits June 29, 2026 22:29
The count-to-infinity ping-pong scenarios interrupt a running skill. After the
stop turn's ovos.utterance.handled, the interrupted count daemon exits and races
in its own CountSkill complete + a second ovos.utterance.handled — a tail whose
exact contents/timing depend on where the stop lands relative to the 1s count
loop (it produced a non-reproducible +1 message in CI's parallel workers).

Capture only through the deterministic stop turn (eof_msgs=[ovos.utterance.handled])
and drop the racy daemon-completion tail from the expected sequence. The stop
routing — ping/pong, activate, stop:skill, the StopService HandlerLifecycle trio,
{skill}.stop(.response), and the stop turn's end-marker — is fully asserted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StopService subclassed OVOSAbstractApplication purely for voc_match/voc_list/locale
loading. That base class also registered it as a skill (skill_id=stop.openvoiceos),
so it answered the mycroft.stop broadcast with stop.openvoiceos.stop.response —
StopService 'stopping itself', a leak that polluted the stop lifecycle.

Drop OVOSAbstractApplication and load the stop/global_stop .voc files via
ovos-spec-tools LocaleResources (the plugin-agnostic voc matcher, same role
common-query/OCP use). self.bus and self.config come from ConfidenceMatcherPipeline.
No more skill machinery — no stop.openvoiceos.stop.response.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stopping a running skill produces two concurrent dispatch lifecycles (the stop
dispatch + the interrupted skill's own §8 trio/§9.5 terminal) whose messages
interleave non-deterministically under load — the source of the persistent
count-mismatch flakiness. Assert the stop dispatch lifecycle in isolation via the
new ovoscope End2EndTest skill_id filter (skill_id=stop.openvoiceos) with
eof_count=2 so capture spans both utterances' ovos.utterance.handled. The full
stop §8 trio + §9 terminals are now modelled deterministically; the interrupted
skill's §8 trio is covered (uninterrupted) by test_count.

Also: TestStopServiceAsSkill -> TestStopServiceNotASkill (regression guard that
StopService no longer emits stop.openvoiceos.stop.response), drop the now-dead
stop-response ignores, and floor-pin ovoscope>=1.4.0a1 for the new features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StopService no longer subclasses OVOSAbstractApplication; vocabulary matching is
delegated to self._locale (ovos-spec-tools LocaleResources). Drop the removed
OVOSAbstractApplication.__init__ patch from the service factory and redirect the
voc_match/voc_list patches to svc._locale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…stop .voc

Two issues surfaced by the StopService spec-tools refactor (LocaleResources.voc_match
is strict where OVOSAbstractApplication.voc_match was lenient):

1. A pipeline matcher raising (here: a malformed .voc) propagated out of the
   handle_utterance loop and aborted the WHOLE utterance — no match was tried and
   no §9.3/§9.5 terminal fired. Wrap the match_func call in try/except: log and
   treat as no-match so iteration continues. Any pipeline plugin can misbehave;
   one bad matcher must not break the utterance.

2. ca-es/stop.voc, ca-es/global_stop.voc and de-de/global_stop.voc had single-branch
   groups '(x)' which ovos-spec-tools rejects (a group needs >=2 branches). The old
   lenient parser treated them as the mandatory token x; drop the parens to preserve
   that matching with a valid template.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ssages

The §8 SPEC trio (ovos.intent.matched / ovos.intent.handler.start / .complete) is
not reliably observed in these concurrent-lifecycle stop scenarios under heavy
parallel CI load (the orchestrator's spec-namespace messages drop relative to the
legacy done-signal — reproduced only at full-suite xdist scale, never in isolation).

Scope the assertion to the deterministic, always-present messages: the stop
activation, the stop:skill/stop:global dispatch, the StopService HandlerLifecycle
done-signal trio (mycroft.skill.handler.start/complete — which the orchestrator
translates into the §8 terminal), and the §9.5 ovos.utterance.handled end-marker.
The §8 spec trio is filtered via ignore_messages here and asserted deterministically
in the single-lifecycle adapt/padatious suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@JarbasAl JarbasAl force-pushed the feat/pipeline1-handler-trio branch from db014a8 to 4a98ee2 Compare June 30, 2026 00:30
@github-actions github-actions Bot added feature and removed feature labels Jun 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
ovos_core/intent_services/service.py (1)

331-349: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject invalid matches before breaking the utterance flow.

If a pipeline returns a truthy match with an empty match_type, _dispatch_match returns without dispatching or emitting a terminal event, and handle_utterance still breaks. Raise here so the existing invalid-match path can continue and eventually emit INTENT_UNMATCHED/UTTERANCE_HANDLED.

Proposed fix
         reply = None
         sess = match.updated_session or SessionManager.get(message)
         sess.lang = lang  # ensure it is updated
+        if not match.match_type:
+            raise ValueError(f"Pipeline returned a match without match_type: {match!r}")
 
         # Launch intent handler
         if match.match_type:

Also applies to: 547-550

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ovos_core/intent_services/service.py` around lines 331 - 349, The
_dispatch_match path in service.py accepts a truthy match even when
match.match_type is empty, which causes the utterance flow to stop without
dispatching or emitting a terminal event. Update _dispatch_match to reject this
invalid case by raising so the existing invalid-match handling in
handle_utterance can continue and eventually emit
INTENT_UNMATCHED/UTTERANCE_HANDLED; use the match.match_type check near the
reply/message.reply logic and apply the same fix to the corresponding duplicate
block referenced by the review.
ovos_core/intent_services/stop_service.py (1)

251-258: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve global_stop semantics in the medium-confidence path.

match_medium() sends fuzzy global_stop hits straight into match_low(), but match_low() only scores stop vocabulary and prefers stop:skill whenever any active skill can stop. That means an utterance that matched global_stop with extra context can degrade into stopping a single skill instead of emitting stop:global. Keep the global_stop branch here, or pass that intent down explicitly so match_low() cannot reinterpret it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ovos_core/intent_services/stop_service.py` around lines 251 - 258, The
medium-confidence matcher in stop_service’s match_medium() is losing the
explicit global_stop intent by always delegating to match_low(), which only
re-evaluates stop and may return stop:skill instead of stop:global. Update
match_medium() to preserve the global_stop path explicitly—either return the
global_stop intent directly when the fuzzy match hits, or pass that intent
through so match_low() cannot reinterpret it using active skills.
♻️ Duplicate comments (1)
ovos_core/intent_services/service.py (1)

374-403: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reuse the resolved dispatch identity for INTENT_MATCHED.

INTENT_MATCHED still uses match.skill_id / match.match_type, while the lifecycle uses the resolved skill_id / intent_name below. Move that resolution before the matched event so §9 and §8 correlate to the same handler identity.

Proposed fix
+            skill_id = (match.skill_id
+                        or (match.match_data or {}).get("skill_id")
+                        or reply.msg_type.split(":", 1)[0])
+            intent_name = reply.msg_type.split(":", 1)[-1]
+
             # OVOS-PIPELINE-1 §9.2: broadcast ovos.intent.matched BEFORE the
             # dispatch goes out. A notification, not a dispatch: consumers MUST NOT
             # treat receipt as permission to run a handler.
             self.bus.emit(reply.forward(SpecMessage.INTENT_MATCHED, {
-                "skill_id": match.skill_id,
-                "intent_name": match.match_type,
+                "skill_id": skill_id,
+                "intent_name": intent_name,
                 "lang": lang,
                 "utterance": match.utterance,
                 "slots": dict(match.match_data or {}),
                 "pipeline_id": reply.context.get("pipeline_id"),
             }))
@@
-            skill_id = (match.skill_id
-                        or (match.match_data or {}).get("skill_id")
-                        or reply.msg_type.split(":", 1)[0])
-            intent_name = reply.msg_type.split(":", 1)[-1]
             # The §8 terminal (complete/error/timeout) the dispatcher emits drives
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ovos_core/intent_services/service.py` around lines 374 - 403,
`INTENT_MATCHED` is using `match.skill_id` and `match.match_type` while the
dispatch path below uses the resolved `skill_id` and `intent_name`, so the two
events can disagree on handler identity. In `IntentService`’s matching flow,
resolve `skill_id` and `intent_name` first using the same fallback logic
currently used before `self.intent_dispatcher.dispatch`, then reuse those values
in the `self.bus.emit(reply.forward(SpecMessage.INTENT_MATCHED, ...))` payload
so `INTENT_MATCHED` and the dispatcher lifecycle stay correlated.
🧹 Nitpick comments (1)
test/unittests/test_stop_service.py (1)

531-544: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten these lifecycle assertions to cover the cross-layer contract.

These tests only check event types. The stop path depends on mycroft.skill.handler.{start,complete} being emitted for stop.openvoiceos and on StopService not emitting ovos.utterance.handled itself; otherwise the dispatcher correlation/end-marker behavior can regress while this still passes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unittests/test_stop_service.py` around lines 531 - 544, Tighten the
StopService lifecycle tests to assert the full cross-layer contract, not just
event types. In test_handle_stop and test_handle_skill_stop_forwards_to_skill,
verify that StopService emits mycroft.skill.handler.start and
mycroft.skill.handler.complete around the stop.openvoiceos/skill stop flow, and
explicitly assert that ovos.utterance.handled is not emitted by StopService
itself. Use the existing _make_service(), handle_stop(), and handle_skill_stop()
paths to locate the behavior and keep the dispatcher correlation/end-marker
expectations covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@ovos_core/intent_services/service.py`:
- Around line 331-349: The _dispatch_match path in service.py accepts a truthy
match even when match.match_type is empty, which causes the utterance flow to
stop without dispatching or emitting a terminal event. Update _dispatch_match to
reject this invalid case by raising so the existing invalid-match handling in
handle_utterance can continue and eventually emit
INTENT_UNMATCHED/UTTERANCE_HANDLED; use the match.match_type check near the
reply/message.reply logic and apply the same fix to the corresponding duplicate
block referenced by the review.

In `@ovos_core/intent_services/stop_service.py`:
- Around line 251-258: The medium-confidence matcher in stop_service’s
match_medium() is losing the explicit global_stop intent by always delegating to
match_low(), which only re-evaluates stop and may return stop:skill instead of
stop:global. Update match_medium() to preserve the global_stop path
explicitly—either return the global_stop intent directly when the fuzzy match
hits, or pass that intent through so match_low() cannot reinterpret it using
active skills.

---

Duplicate comments:
In `@ovos_core/intent_services/service.py`:
- Around line 374-403: `INTENT_MATCHED` is using `match.skill_id` and
`match.match_type` while the dispatch path below uses the resolved `skill_id`
and `intent_name`, so the two events can disagree on handler identity. In
`IntentService`’s matching flow, resolve `skill_id` and `intent_name` first
using the same fallback logic currently used before
`self.intent_dispatcher.dispatch`, then reuse those values in the
`self.bus.emit(reply.forward(SpecMessage.INTENT_MATCHED, ...))` payload so
`INTENT_MATCHED` and the dispatcher lifecycle stay correlated.

---

Nitpick comments:
In `@test/unittests/test_stop_service.py`:
- Around line 531-544: Tighten the StopService lifecycle tests to assert the
full cross-layer contract, not just event types. In test_handle_stop and
test_handle_skill_stop_forwards_to_skill, verify that StopService emits
mycroft.skill.handler.start and mycroft.skill.handler.complete around the
stop.openvoiceos/skill stop flow, and explicitly assert that
ovos.utterance.handled is not emitted by StopService itself. Use the existing
_make_service(), handle_stop(), and handle_skill_stop() paths to locate the
behavior and keep the dispatcher correlation/end-marker expectations covered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0e4b8985-a703-4403-b91d-2f381232079b

📥 Commits

Reviewing files that changed from the base of the PR and between 36f3268 and 4a98ee2.

📒 Files selected for processing (10)
  • ovos_core/intent_services/locale/ca-es/global_stop.voc
  • ovos_core/intent_services/locale/ca-es/stop.voc
  • ovos_core/intent_services/locale/de-de/global_stop.voc
  • ovos_core/intent_services/service.py
  • ovos_core/intent_services/stop_service.py
  • pyproject.toml
  • test/end2end/test_intent_pipeline.py
  • test/end2end/test_stop.py
  • test/end2end/test_stop_refactor.py
  • test/unittests/test_stop_service.py
💤 Files with no reviewable changes (1)
  • test/end2end/test_intent_pipeline.py
✅ Files skipped from review due to trivial changes (2)
  • ovos_core/intent_services/locale/de-de/global_stop.voc
  • ovos_core/intent_services/locale/ca-es/stop.voc
🚧 Files skipped from review as they are similar to previous changes (2)
  • pyproject.toml
  • test/end2end/test_stop.py

JarbasAl added 2 commits June 30, 2026 13:55
…tion

- dispatcher: fix stale timer on shutdown (mark unresolved resolved before clear)
- converse_service: fix msg guard ordering before SessionManager.get
- converse_service: fix skill_max=0 treated as disabled
- fallback_service: fix priority=0 being treated as falsy
- service: skip pipeline matchers with empty match_type (CR5)
- service: resolve skill_id consistently in INTENT_MATCHED (CR7)
- all tests: replace hardcoded spec topics with SpecMessage.X
- all tests: add try/finally for MiniCroft cleanup
- pyproject.toml: add <2.0.0 upper bound for ovos-spec-tools
…p e2e tests

Under parallel CI load (xdist 4 workers) the fixed sleep was too short,
causing test_count_infinity_stop_low to get 4 messages instead of 6
(the session hadn't been updated yet, so a global stop fired instead of
a skill-specific stop). Replace with _wait_for_active_skill that polls
SessionManager.active_skills with a 10s timeout.
@JarbasAl JarbasAl marked this pull request as ready for review June 30, 2026 14:05
@JarbasAl JarbasAl merged commit 2178788 into dev Jun 30, 2026
18 checks passed
@JarbasAl JarbasAl deleted the feat/pipeline1-handler-trio branch June 30, 2026 14:21
JarbasAl added a commit that referenced this pull request Jun 30, 2026
…l events (#788)

* feat: orchestrator emits the PIPELINE-1 §8 handler-lifecycle trio

Make ovos-core (the orchestrator) the authoritative emitter of the
OVOS-PIPELINE-1 §8 handler-lifecycle trio
(ovos.intent.handler.{start,complete,error}), wrapping every dispatch:
start immediately before the <skill_id>:<intent_name> dispatch (§7),
then exactly one of complete (on the framework done-signal) / error
(on the framework error signal or the §8.3 timeout). Each trio Message
is forward-derived from the dispatch so context (incl. session) is
preserved unchanged (§8, MSG-1 §5.1).

New IntentDispatcher (ovos_core/intent_services/dispatcher.py) owns the
§7 dispatch + §8 trio. Completion is observed across the distributed bus
via the skill framework's long-standing legacy done-signals
(mycroft.skill.handler.complete/.error) — framework infrastructure, not
the user handler (which emits nothing per §8/§11). A per-dispatch §8.3
timeout guarantees exactly one terminal even if the handler never
reports; on that path the orchestrator also owns ovos.utterance.handled.
Reserved-name dispatches get the trio identically (§7.0/§7.3); the
resolved-guard keeps the terminal count at one regardless of the bus
namespace bridge.

This is additive: the §9.5 end-marker on the ordinary matched path and
the §9.2 ovos.intent.matched notification are left to ovos-workshop /
follow-up changes and are out of scope here.

Dep floors: ovos-bus-client>=2.5.1a1, ovos-spec-tools>=0.17.3a1
(SpecMessage.INTENT_HANDLER_* members).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: orchestrator owns the PIPELINE-1 §9 utterance-terminal events

Complete the orchestrator's ownership of the OVOS-PIPELINE-1 §6.1
per-utterance terminal sequence, on top of the §8 handler-lifecycle
trio:

- §9.2 ovos.intent.matched — emitted by _dispatch_match on every
  accepted match, before the dispatch goes out (notification, not a
  dispatch). Carries skill_id, intent_name (the full
  <skill_id>:<intent_name> match_type), lang, utterance, slots,
  pipeline_id.
- §9.3 ovos.intent.unmatched — the no-match / all-filtered terminal,
  replacing the legacy complete_intent_failure (the two are bridged by
  ovos-spec-tools' MIGRATION_MAP, so emitting the spec topic re-delivers
  the legacy one to consumers still on it).
- §6.4 cancellation now emits the spec ovos.utterance.cancelled.

Each utterance terminates with exactly one ovos.utterance.handled
(§9.5): core owns it on the no-match, cancel and §8.3-timeout paths;
on the ordinary matched path the skill framework still emits it (moving
that fully into core is gated on the ovos-workshop reduction).

Rename _emit_match_message -> _dispatch_match (it orchestrates the §6.1
post-match steps then dispatches) and correct the IntentDispatcher
docstring to scope it to §7 dispatch + §8 trio (the §9.2 notification
lives in the service).

Verified on a real minicroft: matched path emits matched/start/
complete/handled exactly once each; no-match path emits
ovos.intent.unmatched + ovos.utterance.handled (no complete_intent_
failure). test_no_skills / test_lang_detect conformance suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: account for §9.2 matched + §8.1 start in activate/fallback e2e

The converse-deactivate (test_activate) and fallback (test_fallback)
ovoscope scenarios each gain two captured messages now that the
orchestrator emits ovos.intent.matched (§9.2) and ovos.intent.handler.
start (§8.1) natively before every dispatch — previously the spec trio
existed only as uncounted bridge-mirrors of workshop's legacy emit.

Verified on a real minicroft: the two extra messages per scenario are
exactly ovos.intent.matched + ovos.intent.handler.start (the
reserved-name converse:skill / fallback .request dispatches carry no
mycroft.skill.handler.* done-signal, so their §8 terminal resolves via
the §8.3 timeout after the end-marker, not captured). No spec-topic
double-emit: ovos.intent.matched, ovos.intent.handler.start and
ovos.utterance.handled each appear exactly once per utterance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: core emits ovos.utterance.handled on the matched path too (§9.5)

The orchestrator owns the universal end-marker on EVERY terminal path. The
dispatcher already emitted ovos.utterance.handled on the timeout path; emit it
after the complete/error terminals as well, so a matched dispatch also ends with
exactly one core-emitted handled (the _pop/resolved guard fires one terminal per
dispatch -> one handled). Unmatched/cancel keep their service.py handled.

Workshop may still emit its own matched-path handled during the migration window;
that core-vs-workshop duplicate is expected and removed later workshop-side.

* chore: bump ovos-workshop floor to >=9.0.1a5 (HandlerLifecycle delegation merged)

* chore: bump ovos-workshop floor to >=9.0.2a1 (matched-path handled guard)

ovos-workshop 9.0.2a1 (#442) guards its matched-path ovos.utterance.handled
emission behind a version check on the installed ovos-core, so once core ships
the §9.5 matched-path emission the framework stops double-emitting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: stage version 2.3.0a1 so CI exercises the workshop §9.5 guard

This feat bumps core to the 2.3.x line. ovos-workshop 9.0.2a1 suppresses its
matched-path ovos.utterance.handled only when the installed ovos-core is
>=2.3.0a1; staging the version here lets PR CI install a core that trips that
guard, so the e2e exercises core as the single end-marker emitter rather than
relying on the published 2.2.x. Release automation re-derives the final version
on merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(deps): floor-pin ovos-m2v-pipeline>=0.3.1a1 (workshop 9.x compatible)

The published m2v 0.0.10a1 caps ovos-workshop<9.0.0; with core's workshop floor
at 9.0.2a1 pip backtracked m2v down to 0.0.10a1 and hit that cap -> ResolutionImpossible.
m2v 0.3.1a1 drops the workshop dependency entirely, so floor-pinning it (the
prerelease-floor-pin pattern) forbids the backtrack and the closure resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(deps): floor-pin downstream stack to spec-tools-1.x-ready prereleases

core's bus-client>=2.5.1a1 pulls ovos-bus-client 2.5.1a3/2.6.0a1, which require
ovos-spec-tools>=1.1.0a1. pip backtracked the mycroft/plugins/skills-essential
extras down to stale releases that cap ovos-spec-tools<1.0.0 (e.g. ovos-audio
2.0.1a1) -> ResolutionImpossible. Floor-pin each to its latest prerelease (all
spec-tools-1.x-ready) so the resolver can't backtrack into the capped ones, and
make core's own ovos-spec-tools floor explicit at >=1.1.0a1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(deps): floor-pin ovos-adapt-parser>=1.4.2a1 (spec-tools-1.x ready)

Every ovos-adapt-parser release up to 1.4.1a1 caps ovos-spec-tools<1.0.0; the
uncap landed in 1.4.2a1. Floor-pin it so pip can't backtrack into the capped
versions while core requires ovos-spec-tools>=1.1.0a1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: align e2e expectations with §9.5 end-marker payload

- fallback speak meta carries the skill's dialog/data keys, not just skill.
- ovos.utterance.handled (§9.5) is the orchestrator end-marker with EMPTY data;
  the stop count-to-infinity / ping-pong expectations wrongly carried the
  handler name on it (KeyError 'name'). The handler name stays on the framework
  mycroft.skill.handler.complete signal, where it belongs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: orchestrator owns ovos.utterance.handled, dispatcher only signals done

PIPELINE-1 §9.5: ovos.utterance.handled is the orchestrator's universal end-marker,
not the dispatcher's. The IntentDispatcher owned only the §8 handler-lifecycle trio
but was also emitting the §9.5 end-marker on its terminal paths — wrong layer.

- IntentDispatcher no longer emits ovos.utterance.handled. Each in-flight dispatch
  carries a 'done' Event set on its §8 terminal (complete/error/timeout); dispatch()
  returns the entry.
- IntentService._dispatch_match blocks on entry.done (the §8.3 timeout guarantees it
  fires) then emits the single ovos.utterance.handled, uniformly with the no-match
  (send_complete_intent_failure) and cancel (send_cancel_event) paths it already owns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: dispatch entry is a context manager that blocks until the §8 terminal

Reads cleaner at the call site — the event-waiting is hidden behind the context
manager:

    with self.intent_dispatcher.dispatch(reply, skill_id, intent_name):
        pass
    self.bus.emit(reply.forward(SpecMessage.UTTERANCE_HANDLED, {}))

_InFlightDispatch gains __enter__/__exit__ (exit blocks on its done event). Callers
that don't want to block can still wait on entry.done directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: keep explicit entry.done.wait() call site (drop context manager)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: guarantee dispatch waiters are always released (review)

- terminal handlers wrap the §8 emission in try/finally so entry.done is set even
  if the bus emission raises (the §8.3 timer is already cancelled by _pop, so
  nothing else would release a blocked orchestrator).
- IntentDispatcher.shutdown() sets each in-flight entry's done before clearing,
  so a _dispatch_match caller is never left blocked on entry.done.wait() forever.
- test_timeout_emits_error_and_releases waits on entry.done instead of a fixed
  sleep (deterministic); test_orchestrator_emits_handled_after_terminal now
  asserts ovos.intent.handler.complete precedes ovos.utterance.handled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): pin §8.3 handler-timeout to 10s in the end2end suite

The orchestrator blocks on each handler's §8 terminal before emitting §9.5
ovos.utterance.handled; the production backstop is 5min. e2e handlers report in
<1s (or are explicitly stopped), so pin the backstop to 10s — a dropped
done-signal then fails the suite in seconds instead of stalling for minutes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: orchestrator emits utterance.handled by reacting to the §8 terminal (non-blocking)

Blocking handle_utterance on entry.done.wait() deadlocked the synchronous bus:
handle_utterance is itself a bus handler, so blocking it stalled the same path
that must deliver the handler's done-signal — the §8.3 timer then fired
ovos.intent.handler.error instead of the handler completing (8 e2e tests).

Keep the orchestrator as the §9.5 owner, but non-blocking: IntentService now
subscribes to the dispatcher's §8 terminal (ovos.intent.handler.complete/error)
and emits ovos.utterance.handled in reaction, uniformly with the no-match and
cancel paths. The dispatcher reverts to trio-only (no done event, no blocking);
the §8.3 timeout still backstops via the error terminal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): expect the active skill's §8 complete terminal during stop

The count/ping-pong stop tests inject a long-running intent (daemon) then stop it.
When that daemon intent completes (on stop), its dispatch now emits the §8 spec
terminal ovos.intent.handler.complete before the §9.5 ovos.utterance.handled, so
add it to the expected sequence (got 10 messages, expected 9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: emit utterance.handled via dispatcher on_terminal callback (deterministic)

The reactive subscription (IntentService listening to ovos.intent.handler.complete)
raced the ovoscope capture: FakeBus dispatches the terminal to both the capture
recorder and the subscription, and when the subscription fired first it emitted the
EOF ovos.utterance.handled before the terminal was recorded — so the capture stopped
early and dropped the §8 complete (flaky 9-vs-10 message counts).

Make it deterministic: the dispatcher invokes an on_terminal callback right AFTER
the §8 terminal is on the bus; the orchestrator's _emit_utterance_handled emits the
§9.5 end-marker then. Same call stack, so the terminal is always observed before the
end-marker. Orchestrator still owns the emission; dispatcher stays non-blocking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Update ovos-utils version in pyproject.toml

* StopService: wrap handlers in HandlerLifecycle instead of ad-hoc emission (#796)

* Update Changelog

* StopService: wrap handlers in HandlerLifecycle

Replace rudimentary manual emission of both UTTERANCE_HANDLED (which
the orchestrator now owns via IntentDispatcher._notify_terminal) and
mycroft.skill.handler.complete with the canonical HandlerLifecycle
context manager from ovos-bus-client.

HandlerLifecycle consistently emits the full handler-lifecycle trio
(start/complete/error) so that the dispatcher can properly track
in-flight entries and fire §9.5 UTTERANCE_HANDLED at the right moment.

Co-Authored-By: Claude

* ConverseService: wrap handle_converse in HandlerLifecycle

Same pattern as StopService — handle_converse is called via bus event
dispatch after the converse pipeline stage matches, but had no lifecycle
signalling. Without it the dispatcher's in-flight entry for converse
dispatches would only ever resolve on timeout (10s).

Co-Authored-By: Claude

* Update stop_service unit tests for HandlerLifecycle

test_handle_global_stop_emits_mycroft_stop: check for
mycroft.skill.handler.start/complete instead of ovos.utterance.handled.

test_handle_skill_stop_forwards_to_skill: HandlerLifecycle emits
3 messages (start, forward, complete), not 1.

Co-Authored-By: Claude

* Update stop service tests to include additional assertions

Added assertions to check for 'mycroft.stop' and 'ovos.utterance.handled' messages in stop service tests.

* feat: emit handler done-signal for converse + fallback dispatches (PIPELINE-1 §8) (#789)

* feat: emit handler done-signal for converse + fallback dispatches

The orchestrator's converse and fallback dispatches (PIPELINE-1 §7.3
reserved-name/polymorphic dispatches) run in skills WITHOUT ovos-workshop's
handler_info wrapper, so they never produce the framework done-signal
(mycroft.skill.handler.{start,complete,error}). A dispatcher observing that
signal (PIPELINE-1 §8, the IntentDispatcher) therefore never sees a completion
for these dispatches and falls back to its 5-minute handler timeout.

Adopt the shared HandlerLifecycle util (ovos_bus_client.handler, bus-client
2.6.0a1) so core reports the dispatch->outcome span it orchestrates:

- converse: handle_converse emits handler.start at the converse.request
  dispatch, registers a one-shot skill.converse.response listener (filtered by
  the targeted skill_id) -> handler.complete, with a generous timeout backstop
  -> handler.error. The done-signal is stamped with the targeted skill_id so a
  dispatcher correlates it by (session_id, skill_id).

- fallback: the fallback dispatch is owned by the orchestrator; core translates
  each registered skill's own lifecycle markers
  (ovos.skills.fallback.<skill_id>.start/.response) into
  handler.start/handler.complete, stamped with that skill_id. Wired on register,
  removed on deregister/shutdown.

stop_service is intentionally NOT touched here (owned by PR #777 / STOP-1).

Floor ovos-bus-client>=2.6.0a1 (first release shipping ovos_bus_client.handler)
and the coupled ovos-spec-tools>=1.1.0a1 it requires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: drop ovos-spec-tools upper version cap

Keep the >=1.1.0a1 floor (bus-client 2.6.0a1 requires it); remove the
<2.0.0 max cap so spec-tools is free to float forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Update Changelog

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): ignore TTS mock audio signals in all end2end tests

* fix(test): remove ovos.utterance.handled assertion from TestBusHandlers (orchestrator owns it now)

* fix: correlate fallback dispatch to its §8 terminal via match_data skill_id

The fallback pipeline's match_type (ovos.skills.fallback.<id>.request) carries
no ':', so the orchestrator derived the dispatcher correlation key as the whole
topic. The framework done-signal (re-emitted mycroft.skill.handler.complete) is
stamped with the real skill_id, so IntentDispatcher._pop never matched it and the
§8 ovos.intent.handler.complete terminal — and with it the §9.5
ovos.utterance.handled end-marker — only fired on the 5-minute §8.3 handler
timeout. Every fallback-handled utterance was affected in production.

Derive the correlation key from match_data['skill_id'] when the topic has no ':',
so it equals the done-signal's skill_id. Activation is unchanged (match.skill_id
stays None, no spurious {skill_id}.activate). test_fallback now asserts the §8
terminal, which can only be captured when correlation succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): align stop suite with §8 trio + live-session resend

StopService wraps its global/skill stop handlers in HandlerLifecycle (#796), so
the legacy mycroft.skill.handler.{start,complete} done-signal now brackets
mycroft.stop / {skill_id}.stop. Update the stop expectations to assert the trio.

The ping-pong tests built the stop message from a stale, test-local Session that
never saw the count skill's server-side self-activation (the count message,
serialized before activation, folds an empty active_skills back into the
singleton — correct SESSION-1 last-write-wins). Resend the live singleton session
for the stop turn, as a real client tracking responses would, instead of manually
activating — so the running skill is in active_skills and the ping-pong path runs
without a manual activate crutch. Also drop the §8 ovos.intent.handler.complete
from the expected lists where it is filtered via ignore_messages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: make converse dispatch terminal resolution atomic + session-scoped

handle_converse resolved the §8 lifecycle from two threads (the
skill.converse.response handler and the timeout timer) with a non-atomic
check-then-set on an Event, so both could pass the guard and emit two framework
done-signals (complete + error). Claim the resolution under a Lock so exactly one
terminal fires. Also ignore acks carrying a different session_id, so a concurrent
converse dispatch to the same skill in another session cannot cross-resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): converse §8 trio in deactivate + ignore racy stop-cleanup artifacts

- test_deactivate_inside_converse: ConverseService now reports the dispatch via
  the mycroft.skill.handler.* done-signal which the orchestrator translates to the
  §8 ovos.intent.handler.complete terminal; assert the trio (+3 messages).
- The ping-pong stop tests interrupt a running skill; the async stop-pipeline
  cleanup (abort_question / converse.force_timeout / audio.speech.stop) fires or
  not depending on exactly where the stop lands, so it raced the message count in
  CI. Ignore those artifacts (they are not what the tests assert) and drop the
  flaky force_timeout async_messages assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): stop ping-pong tests assert only through the stop terminal

The count-to-infinity ping-pong scenarios interrupt a running skill. After the
stop turn's ovos.utterance.handled, the interrupted count daemon exits and races
in its own CountSkill complete + a second ovos.utterance.handled — a tail whose
exact contents/timing depend on where the stop lands relative to the 1s count
loop (it produced a non-reproducible +1 message in CI's parallel workers).

Capture only through the deterministic stop turn (eof_msgs=[ovos.utterance.handled])
and drop the racy daemon-completion tail from the expected sequence. The stop
routing — ping/pong, activate, stop:skill, the StopService HandlerLifecycle trio,
{skill}.stop(.response), and the stop turn's end-marker — is fully asserted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: StopService is a pipeline plugin, not an ovos-workshop skill

StopService subclassed OVOSAbstractApplication purely for voc_match/voc_list/locale
loading. That base class also registered it as a skill (skill_id=stop.openvoiceos),
so it answered the mycroft.stop broadcast with stop.openvoiceos.stop.response —
StopService 'stopping itself', a leak that polluted the stop lifecycle.

Drop OVOSAbstractApplication and load the stop/global_stop .voc files via
ovos-spec-tools LocaleResources (the plugin-agnostic voc matcher, same role
common-query/OCP use). self.bus and self.config come from ConfidenceMatcherPipeline.
No more skill machinery — no stop.openvoiceos.stop.response.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): assert stop dispatch lifecycle via ovoscope skill_id filter

Stopping a running skill produces two concurrent dispatch lifecycles (the stop
dispatch + the interrupted skill's own §8 trio/§9.5 terminal) whose messages
interleave non-deterministically under load — the source of the persistent
count-mismatch flakiness. Assert the stop dispatch lifecycle in isolation via the
new ovoscope End2EndTest skill_id filter (skill_id=stop.openvoiceos) with
eof_count=2 so capture spans both utterances' ovos.utterance.handled. The full
stop §8 trio + §9 terminals are now modelled deterministically; the interrupted
skill's §8 trio is covered (uninterrupted) by test_count.

Also: TestStopServiceAsSkill -> TestStopServiceNotASkill (regression guard that
StopService no longer emits stop.openvoiceos.stop.response), drop the now-dead
stop-response ignores, and floor-pin ovoscope>=1.4.0a1 for the new features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(unit): update test_stop_service for the pipeline-plugin refactor

StopService no longer subclasses OVOSAbstractApplication; vocabulary matching is
delegated to self._locale (ovos-spec-tools LocaleResources). Drop the removed
OVOSAbstractApplication.__init__ patch from the service factory and redirect the
voc_match/voc_list patches to svc._locale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: orchestrator survives a pipeline matcher raising; fix malformed stop .voc

Two issues surfaced by the StopService spec-tools refactor (LocaleResources.voc_match
is strict where OVOSAbstractApplication.voc_match was lenient):

1. A pipeline matcher raising (here: a malformed .voc) propagated out of the
   handle_utterance loop and aborted the WHOLE utterance — no match was tried and
   no §9.3/§9.5 terminal fired. Wrap the match_func call in try/except: log and
   treat as no-match so iteration continues. Any pipeline plugin can misbehave;
   one bad matcher must not break the utterance.

2. ca-es/stop.voc, ca-es/global_stop.voc and de-de/global_stop.voc had single-branch
   groups '(x)' which ovos-spec-tools rejects (a group needs >=2 branches). The old
   lenient parser treated them as the mandatory token x; drop the parens to preserve
   that matching with a valid template.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): stop ping-pong tests assert only the deterministic stop messages

The §8 SPEC trio (ovos.intent.matched / ovos.intent.handler.start / .complete) is
not reliably observed in these concurrent-lifecycle stop scenarios under heavy
parallel CI load (the orchestrator's spec-namespace messages drop relative to the
legacy done-signal — reproduced only at full-suite xdist scale, never in isolation).

Scope the assertion to the deterministic, always-present messages: the stop
activation, the stop:skill/stop:global dispatch, the StopService HandlerLifecycle
done-signal trio (mycroft.skill.handler.start/complete — which the orchestrator
translates into the §8 terminal), and the §9.5 ovos.utterance.handled end-marker.
The §8 spec trio is filtered via ignore_messages here and asserted deterministically
in the single-lifecycle adapt/padatious suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: CR2/CR4/CR5/CR7 bugs, meta-commentary cleanup, SpecMessage migration

- dispatcher: fix stale timer on shutdown (mark unresolved resolved before clear)
- converse_service: fix msg guard ordering before SessionManager.get
- converse_service: fix skill_max=0 treated as disabled
- fallback_service: fix priority=0 being treated as falsy
- service: skip pipeline matchers with empty match_type (CR5)
- service: resolve skill_id consistently in INTENT_MATCHED (CR7)
- all tests: replace hardcoded spec topics with SpecMessage.X
- all tests: add try/finally for MiniCroft cleanup
- pyproject.toml: add <2.0.0 upper bound for ovos-spec-tools

* fix: replace sleep(2) with deterministic skill-activation poll in stop e2e tests

Under parallel CI load (xdist 4 workers) the fixed sleep was too short,
causing test_count_infinity_stop_low to get 4 messages instead of 6
(the session hadn't been updated yet, so a global stop fired instead of
a skill-specific stop). Replace with _wait_for_active_skill that polls
SessionManager.active_skills with a 10s timeout.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
JarbasAl added a commit that referenced this pull request Jun 30, 2026
…, active_handlers push, updated_session adoption

Rebases the conformance work cleanly onto dev (which already landed §8 trio +
§9 utterance-terminal events via #788, the spec-tools 1.x cap via #790, and the
workshop 9.x floor via #779). The previous branch bumped bus-client to 2.6.1a1
and re-added a stale spec-tools pin, producing an unsatisfiable resolver
(ovos-core==2.2.4a3 cannot be used) — dropped; dev's deps already satisfy the
stack. The PR's revert of #788's IntentDispatcher is also dropped — dev's
dispatcher-based §7/§8 path is kept and the new clauses are layered onto it.

OVOS-PIPELINE-1 conformance added:

- §4.2 ``updated_session`` adoption: ``_dispatch_match`` now returns the working
  session (``Match.updated_session or inbound``) and ``handle_utterance`` adopts
  it for the remainder of the lifecycle, so a plugin's match-phase session
  mutation reaches downstream stages and the default-session sync.

- §6.2 required_slots orchestrator backstop: after the §5.3/§5.4 denylist
  checks, the orchestrator verifies the match's slot map contains every slot in
  ``match_data['__required_slots__']``; if any is *absent* it treats the match as
  if the plugin declined and continues iteration (no bus event, observable only
  as a non-match). A present slot carrying a falsy value (0/False/'') is a
  legitimate, present slot — only a missing key is missing (CodeRabbit fix; the
  prior truthiness check wrongly rejected valid slots). Sourced from the Match
  itself today (no INTENT-4 §10 manifest is plumbed yet); TODO notes the upgrade
  path.

- §7.1 ``session.active_handlers`` push + §7.3 reserved-name suppression: a
  claiming non-reserved dispatch pushes
  ``{skill_id, activated_at}`` head-first via the spec ``Session.add_active_handler``
  helper (dedup-promoting prior entries), applied after ``Match.updated_session``
  is committed so a plugin's wipe (e.g. STOP-1 global stop) sees the stamp land
  on top. The push is SUPPRESSED for reserved intent_names
  (converse/response/stop/fallback/common_query) per §7.1 — keyed *strictly off
  the reserved-name registry (the Match's intent_name)*, not off the producing
  pipeline_id (the prior pipeline_id-based keying was non-conformant; a reserved
  name produced by any pipeline suppresses).

- Ordering fix (CodeRabbit): the working session is serialized into
  ``reply.context['session']`` *before* the ``{skill_id}.activate`` forward is
  emitted, so subscribers receive the latest session snapshot instead of a stale
  one.

Tests:
- test_intent_service_extended: new ``TestActiveHandlersPush`` (push, dedup,
  reserved-name suppression keyed off intent_name, deactivated, pipeline_id
  propagation into §9.2 matched + dispatch context, session-serialize-before-
  activate ordering, §4.2 return) and ``TestRequiredSlotsBackstop`` (unit +
  match-loop behaviour; falsy-present-value coverage).
- test_adapt / test_padatious e2e: assert the §3.1/§9.2 ``pipeline_id`` is
  propagated into the ``ovos.intent.matched`` payload and dispatch context (a
  regression that drops it now fails).
JarbasAl added a commit that referenced this pull request Jul 1, 2026
…, active_handlers push, updated_session adoption

Rebases the conformance work cleanly onto dev (which already landed §8 trio +
§9 utterance-terminal events via #788, the spec-tools 1.x cap via #790, and the
workshop 9.x floor via #779). The previous branch bumped bus-client to 2.6.1a1
and re-added a stale spec-tools pin, producing an unsatisfiable resolver
(ovos-core==2.2.4a3 cannot be used) — dropped; dev's deps already satisfy the
stack. The PR's revert of #788's IntentDispatcher is also dropped — dev's
dispatcher-based §7/§8 path is kept and the new clauses are layered onto it.

OVOS-PIPELINE-1 conformance added:

- §4.2 ``updated_session`` adoption: ``_dispatch_match`` now returns the working
  session (``Match.updated_session or inbound``) and ``handle_utterance`` adopts
  it for the remainder of the lifecycle, so a plugin's match-phase session
  mutation reaches downstream stages and the default-session sync.

- §6.2 required_slots orchestrator backstop: after the §5.3/§5.4 denylist
  checks, the orchestrator verifies the match's slot map contains every slot in
  ``match_data['__required_slots__']``; if any is *absent* it treats the match as
  if the plugin declined and continues iteration (no bus event, observable only
  as a non-match). A present slot carrying a falsy value (0/False/'') is a
  legitimate, present slot — only a missing key is missing (CodeRabbit fix; the
  prior truthiness check wrongly rejected valid slots). Sourced from the Match
  itself today (no INTENT-4 §10 manifest is plumbed yet); TODO notes the upgrade
  path.

- §7.1 ``session.active_handlers`` push + §7.3 reserved-name suppression: a
  claiming non-reserved dispatch pushes
  ``{skill_id, activated_at}`` head-first via the spec ``Session.add_active_handler``
  helper (dedup-promoting prior entries), applied after ``Match.updated_session``
  is committed so a plugin's wipe (e.g. STOP-1 global stop) sees the stamp land
  on top. The push is SUPPRESSED for reserved intent_names
  (converse/response/stop/fallback/common_query) per §7.1 — keyed *strictly off
  the reserved-name registry (the Match's intent_name)*, not off the producing
  pipeline_id (the prior pipeline_id-based keying was non-conformant; a reserved
  name produced by any pipeline suppresses).

- Ordering fix (CodeRabbit): the working session is serialized into
  ``reply.context['session']`` *before* the ``{skill_id}.activate`` forward is
  emitted, so subscribers receive the latest session snapshot instead of a stale
  one.

Tests:
- test_intent_service_extended: new ``TestActiveHandlersPush`` (push, dedup,
  reserved-name suppression keyed off intent_name, deactivated, pipeline_id
  propagation into §9.2 matched + dispatch context, session-serialize-before-
  activate ordering, §4.2 return) and ``TestRequiredSlotsBackstop`` (unit +
  match-loop behaviour; falsy-present-value coverage).
- test_adapt / test_padatious e2e: assert the §3.1/§9.2 ``pipeline_id`` is
  propagated into the ``ovos.intent.matched`` payload and dispatch context (a
  regression that drops it now fails).
JarbasAl added a commit that referenced this pull request Jul 2, 2026
…me suppression

Reworked onto dev's IntentDispatcher (#788) architecture. The matched/unmatched/
handled emission this PR originally carried now lives in #788; the two deltas
that remain unique are re-applied here:

- §7.3: suppress the §7.1 `active_handlers` push for reserved-name dispatches
  (converse/response/stop/fallback/common_query) — a reserved name is a
  continuation/termination of an already-active skill, not a fresh activation.
  `activate_skill` is already a §7.1 shim over `add_active_handler`, so only the
  suppression gate was missing.
- §6.2: orchestrator backstop that drops a match missing any `required_slots`
  (surfaced by the plugin in `match_data['__required_slots__']`) and continues
  iteration — a second line of defense behind engine-side enforcement. No-op and
  fully backward compatible when a plugin does not surface the constraint.

Adds unit coverage for both the backstop and reserved-name suppression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JarbasAl added a commit that referenced this pull request Jul 2, 2026
…me suppression (#778)

* feat: OVOS-PIPELINE-1 §6.2 required_slots backstop + §7.3 reserved-name suppression

Reworked onto dev's IntentDispatcher (#788) architecture. The matched/unmatched/
handled emission this PR originally carried now lives in #788; the two deltas
that remain unique are re-applied here:

- §7.3: suppress the §7.1 `active_handlers` push for reserved-name dispatches
  (converse/response/stop/fallback/common_query) — a reserved name is a
  continuation/termination of an already-active skill, not a fresh activation.
  `activate_skill` is already a §7.1 shim over `add_active_handler`, so only the
  suppression gate was missing.
- §6.2: orchestrator backstop that drops a match missing any `required_slots`
  (surfaced by the plugin in `match_data['__required_slots__']`) and continues
  iteration — a second line of defense behind engine-side enforcement. No-op and
  fully backward compatible when a plugin does not surface the constraint.

Adds unit coverage for both the backstop and reserved-name suppression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: source §6.2 required_slots from the INTENT-4 §10 manifest

Replace the non-canonical `match_data['__required_slots__']` key with a lookup
against the orchestrator's IntentManifest: `get_required_slots` returns the
`required_slots` an intent declared in its `ovos.intent.register.*` payload,
merged across the session's effective pool. Intents absent from the manifest
yield no required slots, so the backstop stays a no-op there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant