feat(shift-crew): NATS push model for beats_to_voice (W6-P3 reactive synthesis) - #1402
Conversation
beats_to_voice was CLI-only (pull model). This adds the push model: - publish_nats=False param on run_pipeline() — when True, publishes CGP packet to tokenism.prosodic.bpm.v1 after Stage 3 - listen subcommand: subscribes to voice.agent.response.v1, auto-runs pipeline on each agent response, publishes CGP to NATS - nats-py stays optional (lazy import, graceful fallback on exception) - 5 unit tests, all mocked (no live NATS required) Closes W6-P3 voice binding gap (persona → reactive Flute synthesis). Village Rule: one scope, one commit, one PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe changes add event-driven NATS integration to Changes
Sequence DiagramsequenceDiagram
participant NATS as NATS Service
participant Listen as Listen Loop
participant Pipeline as run_pipeline
participant Publish as _nats_publish_cgp
NATS->>Listen: Trigger message (text, user_id)
activate Listen
Listen->>Listen: Parse JSON message
Listen->>Pipeline: Call with text, agent_id
activate Pipeline
Pipeline->>Pipeline: Process audio/voice
Pipeline-->>Listen: Return CGP data
deactivate Pipeline
Listen->>Publish: Call with CGP JSON
activate Publish
Publish->>NATS: Publish to bpm.v1 subject
deactivate Publish
deactivate Listen
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aed3396d89
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (4)
pmoves/tools/test_beats_to_voice_nats.py (2)
30-42: Simplify: thebuiltins.__import__patch is redundant.Setting
sys.modules["nats"] = Nonealready causesimport natsto raiseImportErrorper CPython's import machinery, so the wrapper aroundbuiltins.__import__(and the__builtins__dict-vs-module contortion on Line 32) adds no behavior. Dropping it shortens the test and removes a global hook that's easy to misuse in future tests.🔧 Proposed fix
async def test_publish_nats_unavailable(self): """_nats_publish_cgp returns False when nats-py raises on import.""" - original_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__ - - def _failing_import(name, *args, **kwargs): - if name == "nats": - raise ImportError("no nats") - return original_import(name, *args, **kwargs) - with patch.dict("sys.modules", {"nats": None}): - with patch("builtins.__import__", side_effect=_failing_import): - result = await beats_to_voice._nats_publish_cgp({"spec": "chit.cgp.v0.2"}) + result = await beats_to_voice._nats_publish_cgp({"spec": "chit.cgp.v0.2"}) assert result is False🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@pmoves/tools/test_beats_to_voice_nats.py` around lines 30 - 42, The test test_publish_nats_unavailable should be simplified by removing the redundant builtins.__import__ patch and its helpers (original_import and _failing_import); keep only the patch.dict("sys.modules", {"nats": None}) context so that importing "nats" fails naturally, then call await beats_to_voice._nats_publish_cgp({"spec":"chit.cgp.v0.2"}) and assert the result is False; remove any code that patches builtins.__import__ or references __builtins__ to avoid unnecessary global import hooks.
106-113: Prefer patching_nats_publish_cgpoverasyncio.run.Patching
asyncio.runglobally is broader than needed and couples the test to the current implementation choice (syncrun_pipelinerunning an async helper). Patch the helper directly so the test states intent and stays robust ifrun_pipelineis later refactored to await directly or to use a runner.🔧 Proposed fix
def test_publish_nats_true_sets_key(self): """run_pipeline with publish_nats=True sets nats_published in cgp stage.""" with patch.object(beats_to_voice, "_check_flute_health", return_value=False): - with patch("asyncio.run", return_value=True): + async def _fake_publish(*_a, **_kw): + return True + with patch.object(beats_to_voice, "_nats_publish_cgp", side_effect=_fake_publish): results = beats_to_voice.run_pipeline( text="test text", bpm=90, publish_nats=True ) assert results["stages"]["cgp"]["nats_published"] is True🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@pmoves/tools/test_beats_to_voice_nats.py` around lines 106 - 113, The test test_publish_nats_true_sets_key should patch the internal helper _nats_publish_cgp instead of asyncio.run; update the test to use patch.object(beats_to_voice, "_nats_publish_cgp", return_value=True) (keep the existing patch for _check_flute_health) so run_pipeline(text="test text", bpm=90, publish_nats=True) exercises the real control flow and the helper returns True, then assert results["stages"]["cgp"]["nats_published"] is True.pmoves/tools/beats_to_voice.py (2)
156-184: Drain infinallyso the connection is cleaned up on any exit, not just cancel/KeyboardInterrupt.If
nc.subscribe(...)raises, or any non-cancel exception bubbles out of the sleep loop, the connection is leaked. Move the drain into afinallyto make shutdown deterministic.🔧 Proposed fix
nc = await natspy.connect(nats_url) - - async def _handler(msg) -> None: - ... - - await nc.subscribe(trigger_subject, cb=_handler) - sys.stderr.write( - f"[beats_to_voice] Listening on {trigger_subject} → publishes to {NATS_SUBJECT}\n" - ) try: - while True: - await asyncio.sleep(1) - except (KeyboardInterrupt, asyncio.CancelledError): + async def _handler(msg) -> None: + ... + await nc.subscribe(trigger_subject, cb=_handler) + sys.stderr.write( + f"[beats_to_voice] Listening on {trigger_subject} → publishes to {NATS_SUBJECT}\n" + ) + while True: + await asyncio.sleep(1) + finally: await nc.drain()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@pmoves/tools/beats_to_voice.py` around lines 156 - 184, The nc NATS connection is only drained on KeyboardInterrupt/CancelledError, leaking the connection if subscribe() or other errors occur; wrap the subscribe + listening loop in a try/finally and move await nc.drain() into the finally block (guarding that nc is truthy/connected) so the connection is always cleaned up on any exit; update the scope around nc, the async _handler, await nc.subscribe(trigger_subject, cb=_handler) and the sleep loop to ensure drain runs even if subscribe or the loop raises.
343-345: Foot-gun:run_pipeline(publish_nats=True)cannot be called from an async context.
asyncio.run()raisesRuntimeErrorwhen invoked inside a running event loop. The current_listen_loophandler avoids this by passingpublish_nats=Falseand publishing manually, but that contract is implicit. Consider either (a) guarding withasyncio.get_running_loop()and falling back to scheduling on the live loop, or (b) documenting in the docstring thatpublish_nats=Trueis only valid whenrun_pipelineis called from sync code.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@pmoves/tools/beats_to_voice.py` around lines 343 - 345, The current call to asyncio.run() in run_pipeline when publish_nats=True will raise if called inside an existing event loop; change the logic around the call to _nats_publish_cgp so that you first check for a running loop (use asyncio.get_running_loop() in a try/except), and if no running loop keep using asyncio.run(_nats_publish_cgp(...)), but if a loop is running submit the coroutine to that loop (use asyncio.run_coroutine_threadsafe(_nats_publish_cgp(cgp_packet, nats_url), loop) and wait on the returned Future.result()) and then set results["stages"]["cgp"]["nats_published"] accordingly; update run_pipeline and any callers (e.g., _listen_loop) to rely on this guarded behavior rather than implicitly passing publish_nats=False.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@pmoves/tools/beats_to_voice.py`:
- Around line 158-174: The handler in _handler currently reads response_text at
the top level and falls back to user_id as agent_id, which causes
envelope-wrapped messages to be ignored and misattributes agent identity; update
_handler to first unwrap the possible envelope/payload shape (reuse the
dual-shape logic from voice_follow_agent.py::_extract_text or import that
helper) so you extract text from either top-level response_text/text or
payload.response_text/text, and stop using user_id as a fallback for
agent_id—keep the CLI-provided agent_id unless an explicit agent field (e.g.,
meta.model_used or meta.platform) is present in the message metadata, then pass
the correct agent_id into run_pipeline and preserve existing publishing/error
behavior.
- Around line 127-137: In _nats_publish_cgp ensure the NATS connection is always
drained on success or error to avoid leaking sockets: after connecting (nc =
await natspy.connect(...)) wrap the publish call in a try/finally so that await
nc.drain() is executed in the finally block if nc was created; do not add a
separate close() after drain() since drain() already closes the connection per
nats.py docs, and preserve the existing exception handling to return False on
error.
In `@pmoves/tools/test_beats_to_voice_nats.py`:
- Around line 64-94: The test uses a fragile sleep to wait for
beats_to_voice._listen_loop to call mock_nc.subscribe; replace this timing
dependency by having fake_subscribe set an asyncio.Event when invoked and
awaiting that event instead of asyncio.sleep; specifically modify fake_subscribe
(which is assigned to mock_nc.subscribe) to accept (subject, cb), append cb to
captured_handlers, call event.set(), and in the test await event.wait() (with a
timeout) before invoking captured_handlers[0](msg) and cancelling the task so
the test is deterministic.
---
Nitpick comments:
In `@pmoves/tools/beats_to_voice.py`:
- Around line 156-184: The nc NATS connection is only drained on
KeyboardInterrupt/CancelledError, leaking the connection if subscribe() or other
errors occur; wrap the subscribe + listening loop in a try/finally and move
await nc.drain() into the finally block (guarding that nc is truthy/connected)
so the connection is always cleaned up on any exit; update the scope around nc,
the async _handler, await nc.subscribe(trigger_subject, cb=_handler) and the
sleep loop to ensure drain runs even if subscribe or the loop raises.
- Around line 343-345: The current call to asyncio.run() in run_pipeline when
publish_nats=True will raise if called inside an existing event loop; change the
logic around the call to _nats_publish_cgp so that you first check for a running
loop (use asyncio.get_running_loop() in a try/except), and if no running loop
keep using asyncio.run(_nats_publish_cgp(...)), but if a loop is running submit
the coroutine to that loop (use
asyncio.run_coroutine_threadsafe(_nats_publish_cgp(cgp_packet, nats_url), loop)
and wait on the returned Future.result()) and then set
results["stages"]["cgp"]["nats_published"] accordingly; update run_pipeline and
any callers (e.g., _listen_loop) to rely on this guarded behavior rather than
implicitly passing publish_nats=False.
In `@pmoves/tools/test_beats_to_voice_nats.py`:
- Around line 30-42: The test test_publish_nats_unavailable should be simplified
by removing the redundant builtins.__import__ patch and its helpers
(original_import and _failing_import); keep only the patch.dict("sys.modules",
{"nats": None}) context so that importing "nats" fails naturally, then call
await beats_to_voice._nats_publish_cgp({"spec":"chit.cgp.v0.2"}) and assert the
result is False; remove any code that patches builtins.__import__ or references
__builtins__ to avoid unnecessary global import hooks.
- Around line 106-113: The test test_publish_nats_true_sets_key should patch the
internal helper _nats_publish_cgp instead of asyncio.run; update the test to use
patch.object(beats_to_voice, "_nats_publish_cgp", return_value=True) (keep the
existing patch for _check_flute_health) so run_pipeline(text="test text",
bpm=90, publish_nats=True) exercises the real control flow and the helper
returns True, then assert results["stages"]["cgp"]["nats_published"] is True.
🪄 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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 5cf21817-16bb-4ff8-8df1-a84f0fbed451
📒 Files selected for processing (2)
pmoves/tools/beats_to_voice.pypmoves/tools/test_beats_to_voice_nats.py
|
Threads |
Four services were logging raw NATS URLs containing credentials (nats:pmoves@) on connect/fail. Added _redact_url() helper to each service that strips userinfo via urllib.parse before the log call; connection calls remain unredacted. Also resolves unresolved review threads from PR #1402 (beats_to_voice): - _listen_loop: move nc.drain() into finally so connection closes on any exit, not only CancelledError/KeyboardInterrupt - test_publish_nats_unavailable: drop redundant __builtins__.__import__ patch; sys.modules["nats"]=None already causes ImportError - test_publish_nats_true_sets_key: patch _nats_publish_cgp directly instead of asyncio.run — exercises real control flow; use AsyncMock(return_value=True) Closes unresolved threads on PR #1381 (supaserch, agent-zero bus, gateway-agent, vllm-orchestrator) and PR #1402 (beats_to_voice listen loop + tests). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Resolved P1 ( |
…d cleanup Resolves remaining open review threads from PR #1381 and #1402: beats_to_voice.py: - Use configured agent_id instead of data["user_id"] in listen handler. Per NATS catalog, user_id in voice.agent.response.v1 is the request originator (end-user), not the processing agent. Mixing the two corrupted CGP agent attribution in live mode. test_beats_to_voice_nats.py: - Replace asyncio.sleep(0.05) with asyncio.Event.wait() for deterministic handler capture — eliminates timing-dependent CI failures. - Update assertion: agent_id is now "4090-claude" (configured), not "z890-claude" (originator from user_id). AGNOTE4482.md: - Add blank lines around Key Findings table (MD058 markdownlint fix). PR_TRIAGE_2026-04-23.md: - Replace machine-local ~/.claude/... path with inline portable guidance. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…date Appends session ACK block to AGNOTE4482.md covering 2026-04-27→05-02: - PRs #1402 (NATS push model), #1404 (geometry bus), #1405 (cred redact), #1406 (agent_id fix), #1407 (§9 SPARK rescue) — all merged - GitHub issues #1410 (W6-P1/z890), #1411 (W6-P2/5090), #1412 (W6-P5/opus) created with full TAC-grounded handoff + signoff checklist references Updates ROADMAP Active Claim Register: W6-P3 NATS row added (SHIPPED), W6-P1/P2/P5 rows updated with issue numbers and ANNOUNCED status. Village Rule: one scope, one commit, one PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…date (#1417) * docs(agnote): W6 convergence wave ACK + TAC lane announce register update Appends session ACK block to AGNOTE4482.md covering 2026-04-27→05-02: - PRs #1402 (NATS push model), #1404 (geometry bus), #1405 (cred redact), #1406 (agent_id fix), #1407 (§9 SPARK rescue) — all merged - GitHub issues #1410 (W6-P1/z890), #1411 (W6-P2/5090), #1412 (W6-P5/opus) created with full TAC-grounded handoff + signoff checklist references Updates ROADMAP Active Claim Register: W6-P3 NATS row added (SHIPPED), W6-P1/P2/P5 rows updated with issue numbers and ANNOUNCED status. Village Rule: one scope, one commit, one PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(vision): Cinco de Mayo launch vision + KiloCode W6-P2 handoff brief Extends AGNOTE4482 launch vision with: - CLI-as-score / CGP-as-mood / proof-of-resonance architectural framing - Character persona system (Dr. Bean, Mr. Clean, PowerPuff Girls) as FlOO$ W6-P5 suit archetypes powered by MiniMax - Three-Body split: 4090-claude analysis → KiloCode GLM implementation - Node deployment plan: Jetson edge + 5090 MiniMax+KiloCode runtime Adds .kilo/command/w6-bpm-nats.md — executable KiloCode brief for bpm_encoder NATS publish (W6-P2): exact file, line refs, code blocks, test file template, verify commands. GLM-5.1 blueprint-first pickup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Data flow (new)
```
voice.agent.response.v1 → beats_to_voice listen → run_pipeline() → tokenism.prosodic.bpm.v1
```
Emperor-CHIT-Humility Disclosure
Have: `beats_to_voice.py` full read, `beats_to_cgp.py` NATS lazy-import pattern, NATS subjects catalog (`voice.agent.response.v1` confirmed), 5/5 tests passing
Missing: Live NATS broker not verified this session; Flute-Gateway at :8055 not health-checked; nats-py availability on target nodes not confirmed (optional dep — graceful fallback)
Files Changed
Village Rule
One scope, one commit, one PR. W6-P3 reactive voice binding gap closed.
Summary by CodeRabbit