meshcode 2.11.120__tar.gz → 2.11.121__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {meshcode-2.11.120 → meshcode-2.11.121}/PKG-INFO +1 -1
  2. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/__init__.py +1 -1
  3. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/hostd.py +86 -0
  4. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/realtime.py +13 -1
  5. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/server.py +2 -0
  6. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/sleep_signals.py +44 -1
  7. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode.egg-info/PKG-INFO +1 -1
  8. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode.egg-info/SOURCES.txt +1 -0
  9. {meshcode-2.11.120 → meshcode-2.11.121}/pyproject.toml +1 -1
  10. meshcode-2.11.121/tests/test_swarm_events.py +115 -0
  11. {meshcode-2.11.120 → meshcode-2.11.121}/README.md +0 -0
  12. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/__main__.py +0 -0
  13. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/_session_handoff_template.py +0 -0
  14. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/_stop_hook_template.py +0 -0
  15. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/ascii_art.py +0 -0
  16. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/atomic_push.py +0 -0
  17. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/claude_update.py +0 -0
  18. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/cli.py +0 -0
  19. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/comms_v4.py +0 -0
  20. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/compat.py +0 -0
  21. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/daemon.py +0 -0
  22. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/date_parse.py +0 -0
  23. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/doctor.py +0 -0
  24. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/error_hints.py +0 -0
  25. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/exceptions.py +0 -0
  26. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/hooks/__init__.py +0 -0
  27. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/hooks/repo_path_lock.py +0 -0
  28. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/invites.py +0 -0
  29. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/launcher.py +0 -0
  30. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/launcher_install.py +0 -0
  31. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/__init__.py +0 -0
  32. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/__main__.py +0 -0
  33. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/backend.py +0 -0
  34. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/test_backend.py +0 -0
  35. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  36. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  37. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  38. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  39. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  40. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/preferences.py +0 -0
  41. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/protocol_handler.py +0 -0
  42. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/protocol_v2.py +0 -0
  43. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/quickstart.py +0 -0
  44. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/rpc_allowlist.py +0 -0
  45. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/run_agent.py +0 -0
  46. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/scripts/check_secrets.py +0 -0
  47. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/scripts/race_rate_harness.py +0 -0
  48. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/secrets.py +0 -0
  49. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/self_update.py +0 -0
  50. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/setup_clients.py +0 -0
  51. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/supervisor.py +0 -0
  52. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/up.py +0 -0
  53. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode/upload.py +0 -0
  54. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode.egg-info/dependency_links.txt +0 -0
  55. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode.egg-info/entry_points.txt +0 -0
  56. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode.egg-info/requires.txt +0 -0
  57. {meshcode-2.11.120 → meshcode-2.11.121}/meshcode.egg-info/top_level.txt +0 -0
  58. {meshcode-2.11.120 → meshcode-2.11.121}/setup.cfg +0 -0
  59. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_auto_update_hardening.py +0 -0
  60. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_autonomous_closegap_1.py +0 -0
  61. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_autonomous_closegap_2.py +0 -0
  62. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_autonomous_closegap_3.py +0 -0
  63. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_autonomous_prompt_inject.py +0 -0
  64. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_boot_bug_regression.py +0 -0
  65. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_color_truecolor.py +0 -0
  66. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_core.py +0 -0
  67. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_cross_agent_messaging.py +0 -0
  68. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_date_parse.py +0 -0
  69. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_doctor.py +0 -0
  70. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_epistemic_v1_python_sdk.py +0 -0
  71. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_epistemic_v1_stop_conditions.py +0 -0
  72. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_esc_deaf_state.py +0 -0
  73. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_exceptions.py +0 -0
  74. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_file_upload.py +0 -0
  75. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_init_device_code.py +0 -0
  76. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_install_guard.py +0 -0
  77. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_lease_sigterm_release.py +0 -0
  78. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_mark_read_batch.py +0 -0
  79. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_marketplace_ratings.py +0 -0
  80. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_migration_integrity.py +0 -0
  81. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_realtime_event_freshness.py +0 -0
  82. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_rls_cross_tenant.py +0 -0
  83. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_rpc_grants.py +0 -0
  84. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_rpc_migrations.py +0 -0
  85. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_run_agent_dry_run.py +0 -0
  86. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_run_agent_no_server_import.py +0 -0
  87. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_security_regressions.py +0 -0
  88. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_self_update_user_site.py +0 -0
  89. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_sentinel.py +0 -0
  90. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_setup_path.py +0 -0
  91. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_sleep_signals.py +0 -0
  92. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_status_enum_coverage.py +0 -0
  93. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_stay_on_loop_hook.py +0 -0
  94. {meshcode-2.11.120 → meshcode-2.11.121}/tests/test_wait_open_tasks_contradiction.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.120
3
+ Version: 2.11.121
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -1,5 +1,5 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "2.11.120"
2
+ __version__ = "2.11.121"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -548,10 +548,21 @@ def _do_respawns(api_key: str, host_id: str) -> int:
548
548
  # q1: never touch the fresh terminal). Every recycle is visible now (headless
549
549
  # removed, task cba01de5), so this always runs for a recycle.
550
550
  _old_vis_pids = _discover_agent_pids(_target) if _is_recycle else []
551
+ # DEAD-WINDOW REAP (task 70ac6d25, Samuel hit it twice 2026-06-09): an
552
+ # exit-for-respawn rotation takes THIS plain-respawn path, and the old
553
+ # window — whose launch script predates the .119 in-script self-closer,
554
+ # or whose agent died without rc=0 — lingers as "[Process completed]".
555
+ # Snapshot the target's ALREADY-DEAD launcher windows BEFORE spawning;
556
+ # close them only after the fresh spawn succeeds. The new window can
557
+ # never be in the set: it's busy AND post-snapshot. Live windows
558
+ # (busy=true) are never matched, so a healthy agent is untouchable.
559
+ _dead_wins = _list_dead_terminal_windows(_target)
551
560
  _log(f"{'RECYCLE-RESPAWN' if _is_recycle else 'RESPAWN'} {proj}/{agent} "
552
561
  f"(stale {c.get('heartbeat_age_s')}s, count={c.get('respawn_count')})")
553
562
  if _spawn_agent(proj, agent, repo_path=c.get("repo_path")):
554
563
  _record_spawn(_target) # count the terminal we just opened, against the breaker
564
+ if _dead_wins:
565
+ _close_dead_terminal_windows(_target, _dead_wins)
555
566
  if _is_recycle:
556
567
  if _old_vis_pids:
557
568
  _close_old_visible_recycle(_target, _old_vis_pids) # close old window (DRY-RUN first)
@@ -719,6 +730,71 @@ def _close_old_visible_recycle(target: str, old_pids) -> int:
719
730
  return n
720
731
 
721
732
 
733
+ def _terminal_window_marker(target: str) -> str:
734
+ """Title marker of `target`'s launcher windows. protocol_handler spawns via
735
+ ~/.meshcode/launchers/<label>.command (label = _launcher_label's sanitized
736
+ 'proj/agent'), and macOS Terminal keeps that filename in the window title
737
+ for the window's whole life — verified live 2026-06-10 (running:
738
+ '… ◂ meshcode-self-improve_backend.command — 80×24'; dead windows keep it
739
+ too). Mirrors _launcher_label's sanitization so the marker can't drift."""
740
+ safe = re.sub(r"[^A-Za-z0-9_.-]", "_", target.strip()).strip("_")
741
+ return (safe[:80] + ".command") if safe else ""
742
+
743
+
744
+ def _list_dead_terminal_windows(target: str) -> list:
745
+ """macOS: ids of Terminal windows that belonged to `target`'s launcher and
746
+ whose tab is NO LONGER BUSY — the '[Process completed]' orphans a rotation
747
+ leaves behind (task 70ac6d25). busy=false means no process runs in the tab,
748
+ so a live agent can never match; the marker filter keeps every non-meshcode
749
+ window (user shells, other apps' tabs) out of reach. The marker is built
750
+ from a sanitized charset ([A-Za-z0-9_.-]) so it can't inject AppleScript.
751
+ Returns [] on non-macOS, no Automation permission, or any error."""
752
+ if sys.platform != "darwin":
753
+ return []
754
+ marker = _terminal_window_marker(target)
755
+ if not marker:
756
+ return []
757
+ script = (
758
+ 'set out to ""\n'
759
+ 'tell application "Terminal"\n'
760
+ ' repeat with w in (every window)\n'
761
+ ' try\n'
762
+ f' if (busy of selected tab of w) is false and (name of w contains "{marker}") then\n'
763
+ ' set out to out & (id of w) & linefeed\n'
764
+ ' end if\n'
765
+ ' end try\n'
766
+ ' end repeat\n'
767
+ 'end tell\n'
768
+ 'return out')
769
+ try:
770
+ out = subprocess.run(["/usr/bin/osascript", "-e", script],
771
+ capture_output=True, text=True, timeout=10).stdout or ""
772
+ return [int(x) for x in out.split() if x.strip().isdigit()]
773
+ except Exception:
774
+ return []
775
+
776
+
777
+ def _close_dead_terminal_windows(target: str, window_ids) -> int:
778
+ """Close (saving no) a pre-collected set of dead launcher windows. The ids
779
+ come from _list_dead_terminal_windows BEFORE the fresh spawn, so the new
780
+ window is excluded by construction (same snapshot discipline as
781
+ _close_old_visible_recycle). 'every window whose id is N' no-ops harmlessly
782
+ if the user already closed it. Best-effort; returns count closed."""
783
+ n = 0
784
+ for wid in window_ids:
785
+ try:
786
+ r = subprocess.run(
787
+ ["/usr/bin/osascript", "-e",
788
+ f'tell application "Terminal" to close (every window whose id is {int(wid)}) saving no'],
789
+ capture_output=True, text=True, timeout=10)
790
+ if r.returncode == 0:
791
+ _log(f"DEAD-WINDOW-REAP {target}: closed dead terminal window id {wid} (rotation orphan)")
792
+ n += 1
793
+ except Exception:
794
+ pass
795
+ return n
796
+
797
+
722
798
  def _spawn_rate_ok(target: str):
723
799
  """Anti-spam circuit breaker. Returns (ok, tripped_burst, reason).
724
800
 
@@ -1016,6 +1092,16 @@ def _do_force_kills(api_key: str, host_id: str) -> int:
1016
1092
  pids.pop(target, None)
1017
1093
  n += 1
1018
1094
  _log(f"FORCE-KILL {target}: killed visible pid {pid} (explicit human stop)")
1095
+ # DEAD-WINDOW REAP (task 70ac6d25): the kill leaves the window as
1096
+ # "[Process completed]" — the human pressed Stop, so finish the job.
1097
+ # Brief settle so the process tree exits and busy flips false.
1098
+ try:
1099
+ time.sleep(1.5)
1100
+ _wins = _list_dead_terminal_windows(target)
1101
+ if _wins:
1102
+ _close_dead_terminal_windows(target, _wins)
1103
+ except Exception:
1104
+ pass
1019
1105
  st["headless_pids"] = pids
1020
1106
  _save_state(st)
1021
1107
  return n
@@ -347,7 +347,19 @@ class RealtimeListener:
347
347
  self.message_event.set()
348
348
  except Exception:
349
349
  pass
350
- log.info(f"new message from {enriched['from']}")
350
+ # ENJAMBRE G3 (task 3b0e4129): the swarm barrier trigger
351
+ # (mig 471) delivers via this same mc_messages INSERT path
352
+ # (the runtime has no PG conn for LISTEN mc_swarm_events).
353
+ # Log it explicitly so "did the parent wake instantly on
354
+ # drain?" is answerable from the agent log alone.
355
+ _pl = enriched.get("payload")
356
+ if isinstance(_pl, dict) and _pl.get("type") == "swarm_barrier_complete":
357
+ log.info(
358
+ f"swarm_barrier_complete (swarm={_pl.get('swarm_id')}, "
359
+ f"parent_task={_pl.get('parent_task_id')}) — instant wake for {self.agent_name}"
360
+ )
361
+ else:
362
+ log.info(f"new message from {enriched['from']}")
351
363
  if self.notify_callback:
352
364
  try:
353
365
  await self.notify_callback(enriched)
@@ -590,9 +590,11 @@ def _filter_and_mark(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
590
590
  from .sleep_signals import ( # noqa: F401
591
591
  _SLEEP_PAYLOAD_TYPES,
592
592
  _SLEEP_TEXT_MARKERS,
593
+ _SWARM_EVENT_TYPES,
593
594
  _KNOWN_HUMAN_HANDLES,
594
595
  _is_human_authored,
595
596
  _looks_like_sleep_signal,
597
+ _looks_like_swarm_event,
596
598
  _split_messages,
597
599
  )
598
600
 
@@ -56,6 +56,38 @@ _SLEEP_TEXT_MARKERS = (
56
56
  # server + hook share authority on who counts as a human user.
57
57
  _KNOWN_HUMAN_HANDLES = frozenset({"sammybenu", "samuel", "sam"})
58
58
 
59
+ # ── ENJAMBRE G3 (task 3b0e4129) — swarm barrier events ──────────────────────
60
+ # The mc_swarm_barrier_check trigger (mig 471) fires when a swarm's tray
61
+ # drains. The runtime has NO raw Postgres connection (PostgREST + Realtime
62
+ # websocket only), so a literal LISTEN on the 'mc_swarm_events' pg_notify
63
+ # channel is impossible — which is why the emitter ALSO inserts an
64
+ # mc_messages row to the parent (mig 471 R4). That row already rides the
65
+ # existing wait transport: Realtime postgres_changes INSERT → message_event
66
+ # wake → instant delivery (DB-poll fallback covers the no-websocket case).
67
+ #
68
+ # This layer adds the SEMANTIC half: classify those payloads into a
69
+ # first-class `swarm_events` field (plus an actionable hint) so the parent
70
+ # agent unambiguously sees "barrier complete → aggregate + close parent"
71
+ # instead of a generic system message. Events stay in `messages` too —
72
+ # every existing wake/refuse/count consumer keeps identical behavior.
73
+ _SWARM_EVENT_TYPES = frozenset({"swarm_barrier_complete"})
74
+
75
+ _SWARM_HINT = (
76
+ "swarm barrier complete — the tray is drained. Read each child task's "
77
+ "completion_summary/deliverables (+ scratchpad rollup if used), aggregate, "
78
+ "then meshcode_task_complete the parent task."
79
+ )
80
+
81
+
82
+ def _looks_like_swarm_event(m: Dict[str, Any]) -> bool:
83
+ """True when a message is a structured swarm lifecycle event (server-emitted
84
+ by the barrier trigger). Matches payload.type only — deliberately shaped,
85
+ sender-independent (the emitter writes from_agent='system')."""
86
+ pl = m.get("payload") or {}
87
+ if isinstance(pl, dict):
88
+ return str(pl.get("type", "")).lower() in _SWARM_EVENT_TYPES
89
+ return False
90
+
59
91
 
60
92
  def _is_human_authored(m: Dict[str, Any]) -> bool:
61
93
  """True when the message carries explicit human-user authorship.
@@ -128,6 +160,7 @@ def _split_messages(messages: List[Dict[str, Any]]) -> Dict[str, Any]:
128
160
  real: List[Dict[str, Any]] = []
129
161
  acks: List[Dict[str, Any]] = []
130
162
  dones: List[Dict[str, Any]] = []
163
+ swarm: List[Dict[str, Any]] = []
131
164
  for m in messages:
132
165
  t = m.get("type", "msg")
133
166
  if t == "ack":
@@ -136,9 +169,19 @@ def _split_messages(messages: List[Dict[str, Any]]) -> Dict[str, Any]:
136
169
  dones.append(m)
137
170
  else:
138
171
  real.append(m)
139
- return {
172
+ # ENJAMBRE G3: ALSO surface barrier events first-class. Additive —
173
+ # the event stays in `messages` so wake/refuse/count semantics are
174
+ # byte-identical for every existing consumer; `swarm_events` rides
175
+ # the **split spreads in server.py into every wait/check envelope.
176
+ if _looks_like_swarm_event(m):
177
+ swarm.append(m)
178
+ out = {
140
179
  "messages": real,
141
180
  "acks": acks,
142
181
  "done_signals": dones,
143
182
  "count": len(real),
144
183
  }
184
+ if swarm:
185
+ out["swarm_events"] = swarm
186
+ out["swarm_hint"] = _SWARM_HINT
187
+ return out
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.120
3
+ Version: 2.11.121
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -88,4 +88,5 @@ tests/test_setup_path.py
88
88
  tests/test_sleep_signals.py
89
89
  tests/test_status_enum_coverage.py
90
90
  tests/test_stay_on_loop_hook.py
91
+ tests/test_swarm_events.py
91
92
  tests/test_wait_open_tasks_contradiction.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "2.11.120"
7
+ version = "2.11.121"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -0,0 +1,115 @@
1
+ """ENJAMBRE G3 runtime (task 3b0e4129) — swarm_barrier_complete delivery.
2
+
3
+ The mc_swarm_barrier_check trigger (mig 471) emits pg_notify('mc_swarm_events')
4
+ AND inserts an mc_messages row to the parent agent (R4 transport — the runtime
5
+ has no raw PG connection, so the inbox row IS the LISTEN-equivalent; Realtime
6
+ postgres_changes on mc_messages wakes meshcode_wait instantly).
7
+
8
+ This suite locks in the semantic layer: _split_messages classifies those
9
+ payloads into a first-class `swarm_events` field + `swarm_hint`, WITHOUT
10
+ changing any existing wake/refuse/count semantics (events stay in `messages`).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import unittest
15
+
16
+ from meshcode.meshcode_mcp.sleep_signals import (
17
+ _looks_like_swarm_event,
18
+ _split_messages,
19
+ )
20
+
21
+
22
+ def _barrier_msg(swarm_id="11111111-2222-3333-4444-555555555555",
23
+ parent_task_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
24
+ sender="system", mtype="system"):
25
+ return {
26
+ "from": sender,
27
+ "type": mtype,
28
+ "payload": {
29
+ "type": "swarm_barrier_complete",
30
+ "swarm_id": swarm_id,
31
+ "parent_task_id": parent_task_id,
32
+ },
33
+ }
34
+
35
+
36
+ def _msg(sender="ai-agent", payload=None, mtype="msg"):
37
+ return {"from": sender, "type": mtype, "payload": payload or {}}
38
+
39
+
40
+ class TestLooksLikeSwarmEvent(unittest.TestCase):
41
+
42
+ def test_barrier_payload_detected(self):
43
+ self.assertTrue(_looks_like_swarm_event(_barrier_msg()))
44
+
45
+ def test_sender_independent(self):
46
+ # The emitter writes from_agent='system', but classification is
47
+ # payload-shaped, not sender-shaped.
48
+ self.assertTrue(_looks_like_swarm_event(_barrier_msg(sender="anything")))
49
+
50
+ def test_plain_system_message_is_not_swarm(self):
51
+ m = _msg(payload={"type": "task_assigned", "task_id": "x"}, mtype="system")
52
+ self.assertFalse(_looks_like_swarm_event(m))
53
+
54
+ def test_text_mention_is_not_swarm(self):
55
+ # Prose ABOUT the barrier is not the event.
56
+ m = _msg(payload={"text": "the swarm_barrier_complete fired earlier"})
57
+ self.assertFalse(_looks_like_swarm_event(m))
58
+
59
+ def test_payload_not_dict(self):
60
+ self.assertFalse(_looks_like_swarm_event({"from": "x", "payload": "swarm_barrier_complete"}))
61
+
62
+ def test_empty_payload(self):
63
+ self.assertFalse(_looks_like_swarm_event(_msg(payload={})))
64
+
65
+
66
+ class TestSplitMessagesSwarmField(unittest.TestCase):
67
+
68
+ def test_barrier_lands_in_swarm_events_and_messages(self):
69
+ out = _split_messages([_barrier_msg()])
70
+ self.assertEqual(len(out["swarm_events"]), 1)
71
+ # Back-compat invariant: the event ALSO stays a real message so every
72
+ # existing wake/refuse path (which checks split["messages"]) still fires.
73
+ self.assertEqual(len(out["messages"]), 1)
74
+ self.assertEqual(out["count"], 1)
75
+ self.assertEqual(len(out["done_signals"]), 0)
76
+
77
+ def test_hint_present_only_with_events(self):
78
+ out = _split_messages([_barrier_msg()])
79
+ self.assertIn("swarm_hint", out)
80
+ self.assertIn("task_complete the parent task", out["swarm_hint"])
81
+
82
+ def test_no_swarm_keys_on_plain_batch(self):
83
+ # Additive contract: envelopes without barrier events are byte-identical
84
+ # to the pre-G3 shape (no empty keys polluting every wait response).
85
+ out = _split_messages([_msg(payload={"text": "normal chatter"})])
86
+ self.assertNotIn("swarm_events", out)
87
+ self.assertNotIn("swarm_hint", out)
88
+
89
+ def test_mixed_batch_classification(self):
90
+ msgs = [
91
+ _msg(sender="other", payload={"text": "normal chatter"}),
92
+ _barrier_msg(),
93
+ _msg(mtype="ack", payload={}),
94
+ _msg(sender="commander", mtype="broadcast", payload={"type": "got_done"}),
95
+ ]
96
+ out = _split_messages(msgs)
97
+ self.assertEqual(len(out["swarm_events"]), 1)
98
+ self.assertEqual(len(out["messages"]), 2) # chatter + barrier
99
+ self.assertEqual(len(out["acks"]), 1)
100
+ self.assertEqual(len(out["done_signals"]), 1)
101
+
102
+ def test_barrier_does_not_trip_must_exit_path(self):
103
+ # A barrier completing must NEVER be confused with a sleep signal.
104
+ out = _split_messages([_barrier_msg()])
105
+ self.assertEqual(len(out["done_signals"]), 0)
106
+
107
+ def test_payload_fields_preserved(self):
108
+ out = _split_messages([_barrier_msg(swarm_id="s-1", parent_task_id="p-1")])
109
+ ev = out["swarm_events"][0]["payload"]
110
+ self.assertEqual(ev["swarm_id"], "s-1")
111
+ self.assertEqual(ev["parent_task_id"], "p-1")
112
+
113
+
114
+ if __name__ == "__main__":
115
+ unittest.main()
File without changes
File without changes
File without changes
File without changes