meshcode 2.11.124__tar.gz → 2.11.126__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 (96) hide show
  1. {meshcode-2.11.124 → meshcode-2.11.126}/PKG-INFO +1 -1
  2. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/__init__.py +1 -1
  3. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/hostd.py +110 -0
  4. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/sleep_signals.py +22 -6
  5. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode.egg-info/PKG-INFO +1 -1
  6. {meshcode-2.11.124 → meshcode-2.11.126}/pyproject.toml +1 -1
  7. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_sleep_signals.py +52 -6
  8. {meshcode-2.11.124 → meshcode-2.11.126}/README.md +0 -0
  9. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/__main__.py +0 -0
  10. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/_session_handoff_template.py +0 -0
  11. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/_stop_hook_template.py +0 -0
  12. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/ascii_art.py +0 -0
  13. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/atomic_push.py +0 -0
  14. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/claude_update.py +0 -0
  15. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/cli.py +0 -0
  16. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/comms_v4.py +0 -0
  17. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/compat.py +0 -0
  18. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/daemon.py +0 -0
  19. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/date_parse.py +0 -0
  20. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/doctor.py +0 -0
  21. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/error_hints.py +0 -0
  22. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/exceptions.py +0 -0
  23. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/hooks/__init__.py +0 -0
  24. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/hooks/repo_path_lock.py +0 -0
  25. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/invites.py +0 -0
  26. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/launcher.py +0 -0
  27. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/launcher_install.py +0 -0
  28. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/__init__.py +0 -0
  29. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/__main__.py +0 -0
  30. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/backend.py +0 -0
  31. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/realtime.py +0 -0
  32. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/server.py +0 -0
  33. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/swarm.py +0 -0
  34. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/test_backend.py +0 -0
  35. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  36. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  37. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  38. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  39. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  40. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/meshcode_mcp/test_swarm.py +0 -0
  41. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/preferences.py +0 -0
  42. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/protocol_handler.py +0 -0
  43. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/protocol_v2.py +0 -0
  44. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/quickstart.py +0 -0
  45. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/rpc_allowlist.py +0 -0
  46. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/run_agent.py +0 -0
  47. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/scripts/check_secrets.py +0 -0
  48. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/scripts/race_rate_harness.py +0 -0
  49. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/secrets.py +0 -0
  50. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/self_update.py +0 -0
  51. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/setup_clients.py +0 -0
  52. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/supervisor.py +0 -0
  53. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/up.py +0 -0
  54. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode/upload.py +0 -0
  55. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode.egg-info/SOURCES.txt +0 -0
  56. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode.egg-info/dependency_links.txt +0 -0
  57. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode.egg-info/entry_points.txt +0 -0
  58. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode.egg-info/requires.txt +0 -0
  59. {meshcode-2.11.124 → meshcode-2.11.126}/meshcode.egg-info/top_level.txt +0 -0
  60. {meshcode-2.11.124 → meshcode-2.11.126}/setup.cfg +0 -0
  61. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_auto_update_hardening.py +0 -0
  62. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_autonomous_closegap_1.py +0 -0
  63. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_autonomous_closegap_2.py +0 -0
  64. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_autonomous_closegap_3.py +0 -0
  65. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_autonomous_prompt_inject.py +0 -0
  66. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_boot_bug_regression.py +0 -0
  67. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_color_truecolor.py +0 -0
  68. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_core.py +0 -0
  69. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_cross_agent_messaging.py +0 -0
  70. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_date_parse.py +0 -0
  71. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_doctor.py +0 -0
  72. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_epistemic_v1_python_sdk.py +0 -0
  73. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_epistemic_v1_stop_conditions.py +0 -0
  74. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_esc_deaf_state.py +0 -0
  75. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_exceptions.py +0 -0
  76. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_file_upload.py +0 -0
  77. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_init_device_code.py +0 -0
  78. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_install_guard.py +0 -0
  79. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_lease_sigterm_release.py +0 -0
  80. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_mark_read_batch.py +0 -0
  81. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_marketplace_ratings.py +0 -0
  82. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_migration_integrity.py +0 -0
  83. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_realtime_event_freshness.py +0 -0
  84. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_rls_cross_tenant.py +0 -0
  85. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_rpc_grants.py +0 -0
  86. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_rpc_migrations.py +0 -0
  87. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_run_agent_dry_run.py +0 -0
  88. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_run_agent_no_server_import.py +0 -0
  89. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_security_regressions.py +0 -0
  90. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_self_update_user_site.py +0 -0
  91. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_sentinel.py +0 -0
  92. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_setup_path.py +0 -0
  93. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_status_enum_coverage.py +0 -0
  94. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_stay_on_loop_hook.py +0 -0
  95. {meshcode-2.11.124 → meshcode-2.11.126}/tests/test_swarm_events.py +0 -0
  96. {meshcode-2.11.124 → meshcode-2.11.126}/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.124
3
+ Version: 2.11.126
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.124"
2
+ __version__ = "2.11.126"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -209,6 +209,34 @@ SPAWN_MIN_INTERVAL_SEC = _env_int("MESHCODE_SPAWN_MIN_INTERVAL_SEC", 45, 5) #
209
209
  SPAWN_BURST_CAP = _env_int("MESHCODE_SPAWN_BURST_CAP", 5, 2) # max spawns ...
210
210
  SPAWN_BURST_WINDOW_SEC = _env_int("MESHCODE_SPAWN_BURST_WINDOW_SEC", 600, 60) # ... within this rolling window before tripping
211
211
 
212
+ # ------------------------------------------------------------------
213
+ # Plain-respawn guards (Samuel P0 2026-06-10, terminal storm): the burst
214
+ # breaker above NEVER saw the overnight storm — hostd respawned zylo-bemate
215
+ # every ~16 min for 13 h (125 terminals): slow boots under load kept resetting
216
+ # the server-side respawn cap (the agent heartbeats briefly, count -> 0), the
217
+ # ~16 min cadence stays under 5 spawns/10 min, and every respawn STACKED a new
218
+ # window on top of the still-alive previous session, adding load that made the
219
+ # next boot even slower. Two guards on the plain-respawn path in _do_respawns:
220
+ #
221
+ # LIVE-SESSION GUARD — never open a second terminal while the previous
222
+ # session's process is still alive: HOLD (spawn nothing), hard-kill the stuck
223
+ # session after _RESPAWN_STUCK_KILL_S, and relaunch only once it is fully
224
+ # gone. An explicit Start (fresh spawned_at) kills the old session at once —
225
+ # a restart the human asked for must first fully close the old terminal.
226
+ #
227
+ # CONVERGENCE GUARD — mirror of the recycle guard (451d33a0) for plain
228
+ # respawns, keyed on time BETWEEN consecutive respawns so it catches ANY
229
+ # cadence: _RESPAWN_CONVERGE_MAX consecutive respawns each started less than
230
+ # _RESPAWN_CONVERGE_LIFETIME_S after the previous one -> BLOCK further
231
+ # respawns for _RESPAWN_CONVERGE_BLOCK_TTL_S + resolve the pending launch as
232
+ # failed (FE toast). An explicit Start clears the block.
233
+ # ------------------------------------------------------------------
234
+ _RESPAWN_STUCK_KILL_S = _env_int("MESHCODE_RESPAWN_STUCK_KILL_SEC", 600, 60) # kill a stale-but-alive session after this hold
235
+ _RESPAWN_CONVERGE_MAX = _env_int("MESHCODE_RESPAWN_CONVERGE_MAX", 3, 2) # consecutive short-lived respawns before blocking
236
+ _RESPAWN_CONVERGE_LIFETIME_S = _env_int("MESHCODE_RESPAWN_CONVERGE_LIFETIME_SEC", 1800, 120) # "short-lived" = next respawn needed within this
237
+ _RESPAWN_CONVERGE_BLOCK_TTL_S = _env_int("MESHCODE_RESPAWN_CONVERGE_BLOCK_TTL_SEC", 21600, 600) # block duration before one clean retry
238
+ _RESPAWN_FRESH_CLICK_S = _env_int("MESHCODE_RESPAWN_FRESH_CLICK_SEC", 90, 30) # spawned_age_s under this = explicit Start click
239
+
212
240
 
213
241
  def _log(msg: str) -> None:
214
242
  line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
@@ -535,6 +563,88 @@ def _do_respawns(api_key: str, host_id: str) -> int:
535
563
  _rrall[_target] = _rr
536
564
  _rst["recyrespawn"] = _rrall
537
565
  _save_state(_rst)
566
+ # ---- plain-respawn guards (Samuel P0 2026-06-10, terminal storm; see
567
+ # ---- constants block above for the full incident rationale) ----
568
+ if not _is_recycle:
569
+ _now2 = time.time()
570
+ _st2 = _load_state()
571
+ try:
572
+ _fresh_click = float(c.get("spawned_age_s")) < _RESPAWN_FRESH_CLICK_S
573
+ except (TypeError, ValueError):
574
+ _fresh_click = False
575
+ _cv_all = dict(_st2.get("respawn_conv") or {})
576
+ _cv = dict(_cv_all.get(_target) or {})
577
+ if _fresh_click and (_cv or _target in (_st2.get("respawn_hold") or {})):
578
+ # explicit Start (fresh spawned_at) = human permission: clear guard state
579
+ _cv = {}
580
+ _cv_all.pop(_target, None)
581
+ _hh = dict(_st2.get("respawn_hold") or {})
582
+ _hh.pop(_target, None)
583
+ _st2["respawn_conv"] = _cv_all
584
+ _st2["respawn_hold"] = _hh
585
+ _save_state(_st2)
586
+ _log(f"RESPAWN-GUARD {_target}: explicit Start — cleared hold/convergence state")
587
+ elif _cv.get("blocked_ts"):
588
+ if (_now2 - float(_cv["blocked_ts"])) < _RESPAWN_CONVERGE_BLOCK_TTL_S:
589
+ _log(f"SKIP respawn {_target}: BLOCKED (respawn_no_converge, "
590
+ f"{_cv.get('count')} short-lived respawns) — holding until TTL or a manual Start")
591
+ continue
592
+ _cv.pop("blocked_ts", None)
593
+ _cv["count"] = 0 # TTL elapsed -> one clean retry; re-blocks if it storms again
594
+ # LIVE-SESSION GUARD: never stack a second terminal on a still-alive session.
595
+ _live = [p for p in _discover_agent_pids(_target) if _pid_alive(p)]
596
+ if _live:
597
+ _hold_all = dict(_st2.get("respawn_hold") or {})
598
+ _hold = dict(_hold_all.get(_target) or {})
599
+ _first = float(_hold.get("first_ts") or _now2)
600
+ if _fresh_click or (_now2 - _first) >= _RESPAWN_STUCK_KILL_S:
601
+ _killed = sum(1 for _p in _live if _kill_headless_pid(_target, _p))
602
+ _hold_all.pop(_target, None)
603
+ _log(f"RESPAWN-STUCK-KILL {_target}: closed {_killed}/{len(_live)} stale-but-alive "
604
+ f"session pid(s) {_live} "
605
+ f"({'explicit Start' if _fresh_click else 'stuck > ' + str(_RESPAWN_STUCK_KILL_S) + 's'}) "
606
+ f"— relaunch on next sweep once fully gone")
607
+ else:
608
+ _hold["first_ts"] = _first
609
+ _hold_all[_target] = _hold
610
+ _log(f"RESPAWN-HOLD {_target}: previous session still ALIVE (pids {_live}) — "
611
+ f"NOT opening another terminal; stuck-kill in "
612
+ f"{int(_RESPAWN_STUCK_KILL_S - (_now2 - _first))}s unless it heartbeats")
613
+ _st2["respawn_hold"] = _hold_all
614
+ _save_state(_st2)
615
+ continue
616
+ # previous session fully gone — count this relaunch against convergence
617
+ _hold_all = dict(_st2.get("respawn_hold") or {})
618
+ if _target in _hold_all:
619
+ _hold_all.pop(_target, None)
620
+ _st2["respawn_hold"] = _hold_all
621
+ if _cv.get("last_ts") and (_now2 - float(_cv["last_ts"])) <= _RESPAWN_CONVERGE_LIFETIME_S:
622
+ _cv["count"] = int(_cv.get("count", 0)) + 1
623
+ else:
624
+ _cv = {"count": 1} # converged/idle long enough -> fresh count
625
+ _cv["last_ts"] = _now2
626
+ if _cv["count"] >= _RESPAWN_CONVERGE_MAX:
627
+ _cv["blocked_ts"] = _now2
628
+ _cv_all[_target] = _cv
629
+ _st2["respawn_conv"] = _cv_all
630
+ _save_state(_st2)
631
+ _log(f"ALERT {_target}: respawn NOT CONVERGING — {_cv['count']} consecutive respawns "
632
+ f"each <{_RESPAWN_CONVERGE_LIFETIME_S}s apart; BLOCKING respawns for "
633
+ f"{_RESPAWN_CONVERGE_BLOCK_TTL_S}s (a manual Start clears the block). "
634
+ f"[respawn_blocked_reason=respawn_no_converge]")
635
+ try: # stop the dashboard's eternal 'launching…' spinner + actionable toast
636
+ _rpc("mc_resolve_launch", {
637
+ "p_api_key": api_key, "p_project_id": c.get("project_id"), "p_agent": agent,
638
+ "p_status": "failed", "p_reason": "respawn_no_converge",
639
+ "p_detail": f"agent kept dying or booting too slowly {_cv['count']}x in a row — "
640
+ f"respawns paused to protect your machine and tokens; press Start "
641
+ f"to retry when ready"})
642
+ except Exception:
643
+ pass
644
+ continue
645
+ _cv_all[_target] = _cv
646
+ _st2["respawn_conv"] = _cv_all
647
+ _save_state(_st2)
538
648
  _ok, _burst, _why = _spawn_rate_ok(_target)
539
649
  if not _ok:
540
650
  _log(f"SKIP {'recycle-' if _is_recycle else ''}respawn {_target}: rate-limited ({_why})")
@@ -136,18 +136,32 @@ def _is_human_authored(m: Dict[str, Any]) -> bool:
136
136
  return False
137
137
 
138
138
 
139
+ def _sender_may_order_sleep(m: Dict[str, Any]) -> bool:
140
+ """Samuel directive 2026-06-10 (msg 54eec209): a RUNNING agent is only
141
+ slept by an order from the USER or the COMMANDER. True when the message
142
+ is human-authored OR carries the SERVER-VERIFIED commander stamp
143
+ (payload.sender_is_commander, mig 483 — stamped by mc_send_message, never
144
+ client-claimed). A plain ai peer can no longer must_exit a sibling."""
145
+ if _is_human_authored(m):
146
+ return True
147
+ pl = m.get("payload") or {}
148
+ return isinstance(pl, dict) and pl.get("sender_is_commander") is True
149
+
150
+
139
151
  def _looks_like_sleep_signal(m: Dict[str, Any]) -> bool:
140
152
  """Detect mesh messages that authorize the wait-loop exit.
141
153
 
142
154
  See module docstring for the two valid encodings and the rationale
143
- for ignoring idiom matches from AI-role senders.
155
+ for ignoring idiom matches from AI-role senders. Since mig 483 + .125,
156
+ structured directives additionally require an authorized sender
157
+ (human or server-stamped commander) — see _sender_may_order_sleep.
144
158
  """
145
159
  pl = m.get("payload") or {}
146
160
  if isinstance(pl, dict):
147
- if str(pl.get("type", "")).lower() in _SLEEP_PAYLOAD_TYPES:
148
- return True
149
- if str(pl.get("directive", "")).lower() in _SLEEP_PAYLOAD_TYPES:
150
- return True
161
+ structured = (str(pl.get("type", "")).lower() in _SLEEP_PAYLOAD_TYPES
162
+ or str(pl.get("directive", "")).lower() in _SLEEP_PAYLOAD_TYPES)
163
+ if structured:
164
+ return _sender_may_order_sleep(m)
151
165
  text = str(pl.get("text", "")).lower()
152
166
  if text and any(marker in text for marker in _SLEEP_TEXT_MARKERS):
153
167
  if _is_human_authored(m) and _human_text_is_directive(text):
@@ -194,7 +208,9 @@ def _split_messages(messages: List[Dict[str, Any]]) -> Dict[str, Any]:
194
208
  t = m.get("type", "msg")
195
209
  if t == "ack":
196
210
  acks.append(m)
197
- elif (t == "done" or _looks_like_sleep_signal(m)) and _recent_enough(m):
211
+ elif ((t == "done" and _sender_may_order_sleep(m)) or _looks_like_sleep_signal(m)) \
212
+ and _recent_enough(m):
213
+ # type='done' rows are sleep-class too — same sender gate applies
198
214
  dones.append(m)
199
215
  else:
200
216
  real.append(m)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.124
3
+ Version: 2.11.126
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "2.11.124"
7
+ version = "2.11.126"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -77,17 +77,23 @@ class TestStructuredDirective(unittest.TestCase):
77
77
  of sender — these are deliberately-shaped directives."""
78
78
 
79
79
  def test_got_done_broadcast_from_commander(self):
80
+ # post-mig-483 reality: the server stamps sender_is_commander
80
81
  m = _msg(sender="mesh-commander", mtype="broadcast",
81
- payload={"type": "got_done"})
82
+ payload={"type": "got_done", "sender_is_commander": True})
82
83
  self.assertTrue(_looks_like_sleep_signal(m))
83
84
 
84
85
  def test_payload_type_sleep_from_ai(self):
86
+ # POLICY CHANGE (54eec209): bare ai sleep directive is ignored
85
87
  m = _msg(sender="any-agent", payload={"type": "sleep"})
86
- self.assertTrue(_looks_like_sleep_signal(m))
88
+ self.assertFalse(_looks_like_sleep_signal(m))
87
89
 
88
90
  def test_payload_directive_shutdown(self):
91
+ # POLICY CHANGE (54eec209): a plain ai peer cannot order shutdown
89
92
  m = _msg(sender="any-agent", payload={"directive": "shutdown"})
90
- self.assertTrue(_looks_like_sleep_signal(m))
93
+ self.assertFalse(_looks_like_sleep_signal(m))
94
+ m2 = _msg(sender="any-agent",
95
+ payload={"directive": "shutdown", "sender_is_commander": True})
96
+ self.assertTrue(_looks_like_sleep_signal(m2))
91
97
 
92
98
  def test_payload_type_unknown_is_not_sleep(self):
93
99
  m = _msg(sender="any-agent", payload={"type": "msg"})
@@ -132,7 +138,7 @@ class TestSplitMessages(unittest.TestCase):
132
138
 
133
139
  def test_got_done_broadcast_lands_in_dones(self):
134
140
  msgs = [_msg(sender="mesh-commander", mtype="broadcast",
135
- payload={"type": "got_done"})]
141
+ payload={"type": "got_done", "sender_is_commander": True})]
136
142
  out = _split_messages(msgs)
137
143
  self.assertEqual(len(out["done_signals"]), 1)
138
144
 
@@ -146,7 +152,8 @@ class TestSplitMessages(unittest.TestCase):
146
152
  msgs = [
147
153
  _msg(sender="ai", payload={"text": "a dormir is what Samuel said"}),
148
154
  _msg(sender="sammybenu", payload={"text": "a dormir"}),
149
- _msg(sender="commander", mtype="broadcast", payload={"type": "got_done"}),
155
+ _msg(sender="commander", mtype="broadcast",
156
+ payload={"type": "got_done", "sender_is_commander": True}),
150
157
  _msg(sender="other", payload={"text": "normal chatter"}),
151
158
  _msg(mtype="ack", payload={}),
152
159
  ]
@@ -204,5 +211,44 @@ class TestCasualSpanishComplaintGuards(unittest.TestCase):
204
211
  def test_structured_directive_untouched_by_guards(self):
205
212
  # dashboard-button path: payload.type fires regardless of text shape
206
213
  m = _msg(sender="mesh-commander",
207
- payload={"type": "got_done", "text": self.REPRO})
214
+ payload={"type": "got_done", "text": self.REPRO,
215
+ "sender_is_commander": True})
216
+ self.assertTrue(_looks_like_sleep_signal(m))
217
+
218
+
219
+ class TestOnlyCommanderOrHumanMayOrderSleep(unittest.TestCase):
220
+ """Samuel directive 2026-06-10 (msg 54eec209) + mig 483: structured sleep
221
+ directives and type='done' messages from a plain AI peer must NOT
222
+ must_exit; only humans or the server-stamped commander may order sleep."""
223
+
224
+ def test_ai_peer_structured_sleep_ignored(self):
225
+ m = _msg(sender="backend2", payload={"type": "got_done", "sender_role": "ai"})
226
+ self.assertFalse(_looks_like_sleep_signal(m))
227
+
228
+ def test_commander_stamp_authorizes(self):
229
+ m = _msg(sender="mesh-commander",
230
+ payload={"type": "got_done", "sender_role": "ai",
231
+ "sender_is_commander": True})
208
232
  self.assertTrue(_looks_like_sleep_signal(m))
233
+
234
+ def test_client_claimed_stamp_string_rejected(self):
235
+ # only boolean True (server-stamped) counts
236
+ m = _msg(sender="rogue", payload={"type": "stop", "sender_is_commander": "true"})
237
+ self.assertFalse(_looks_like_sleep_signal(m))
238
+
239
+ def test_human_structured_still_works(self):
240
+ m = _msg(sender="sammybenu", payload={"type": "sleep"})
241
+ self.assertTrue(_looks_like_sleep_signal(m))
242
+
243
+ def test_done_type_from_ai_peer_not_sleep(self):
244
+ m = _msg(sender="backend2", mtype="done",
245
+ payload={"text": "task xyz done", "sender_role": "ai"})
246
+ split = _split_messages([m])
247
+ self.assertEqual(len(split["done_signals"]), 0)
248
+ self.assertEqual(len(split["messages"]), 1)
249
+
250
+ def test_done_type_from_commander_is_sleep(self):
251
+ m = _msg(sender="mesh-commander", mtype="done",
252
+ payload={"text": "got_done", "sender_is_commander": True})
253
+ split = _split_messages([m])
254
+ self.assertEqual(len(split["done_signals"]), 1)
File without changes
File without changes
File without changes
File without changes