meshcode 2.11.131__tar.gz → 2.11.132__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 (101) hide show
  1. {meshcode-2.11.131 → meshcode-2.11.132}/PKG-INFO +1 -1
  2. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/__init__.py +1 -1
  3. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/hostd.py +26 -2
  4. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/backend.py +77 -15
  5. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/server.py +125 -48
  6. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode.egg-info/PKG-INFO +1 -1
  7. {meshcode-2.11.131 → meshcode-2.11.132}/pyproject.toml +1 -1
  8. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_stop_ghost_terminal.py +4 -1
  9. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_wait_open_tasks_contradiction.py +5 -1
  10. {meshcode-2.11.131 → meshcode-2.11.132}/README.md +0 -0
  11. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/__main__.py +0 -0
  12. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/_session_handoff_template.py +0 -0
  13. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/_stop_hook_template.py +0 -0
  14. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/ascii_art.py +0 -0
  15. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/atomic_push.py +0 -0
  16. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/claude_update.py +0 -0
  17. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/cli.py +0 -0
  18. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/comms_v4.py +0 -0
  19. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/compat.py +0 -0
  20. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/daemon.py +0 -0
  21. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/date_parse.py +0 -0
  22. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/doctor.py +0 -0
  23. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/error_hints.py +0 -0
  24. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/exceptions.py +0 -0
  25. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/hooks/__init__.py +0 -0
  26. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/hooks/repo_path_lock.py +0 -0
  27. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/invites.py +0 -0
  28. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/launcher.py +0 -0
  29. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/launcher_install.py +0 -0
  30. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/__init__.py +0 -0
  31. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/__main__.py +0 -0
  32. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/realtime.py +0 -0
  33. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
  34. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/swarm.py +0 -0
  35. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/test_backend.py +0 -0
  36. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  37. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  38. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  39. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  40. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  41. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/meshcode_mcp/test_swarm.py +0 -0
  42. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/preferences.py +0 -0
  43. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/protocol_handler.py +0 -0
  44. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/protocol_v2.py +0 -0
  45. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/quickstart.py +0 -0
  46. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/rpc_allowlist.py +0 -0
  47. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/run_agent.py +0 -0
  48. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/scripts/check_secrets.py +0 -0
  49. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/scripts/race_rate_harness.py +0 -0
  50. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/secrets.py +0 -0
  51. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/self_update.py +0 -0
  52. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/setup_clients.py +0 -0
  53. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/supervisor.py +0 -0
  54. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/up.py +0 -0
  55. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode/upload.py +0 -0
  56. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode.egg-info/SOURCES.txt +0 -0
  57. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode.egg-info/dependency_links.txt +0 -0
  58. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode.egg-info/entry_points.txt +0 -0
  59. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode.egg-info/requires.txt +0 -0
  60. {meshcode-2.11.131 → meshcode-2.11.132}/meshcode.egg-info/top_level.txt +0 -0
  61. {meshcode-2.11.131 → meshcode-2.11.132}/setup.cfg +0 -0
  62. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_auto_update_hardening.py +0 -0
  63. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_autonomous_closegap_1.py +0 -0
  64. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_autonomous_closegap_2.py +0 -0
  65. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_autonomous_closegap_3.py +0 -0
  66. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_autonomous_prompt_inject.py +0 -0
  67. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_boot_bug_regression.py +0 -0
  68. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_color_truecolor.py +0 -0
  69. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_core.py +0 -0
  70. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_cross_agent_messaging.py +0 -0
  71. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_date_parse.py +0 -0
  72. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_doctor.py +0 -0
  73. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_epistemic_v1_python_sdk.py +0 -0
  74. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_epistemic_v1_stop_conditions.py +0 -0
  75. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_esc_deaf_state.py +0 -0
  76. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_exceptions.py +0 -0
  77. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_file_upload.py +0 -0
  78. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_hostd_zombie_sessions.py +0 -0
  79. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_init_device_code.py +0 -0
  80. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_install_guard.py +0 -0
  81. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_lease_sigterm_release.py +0 -0
  82. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_live_mesh_guard.py +0 -0
  83. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_mark_read_batch.py +0 -0
  84. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_marketplace_ratings.py +0 -0
  85. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_migration_integrity.py +0 -0
  86. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_realtime_event_freshness.py +0 -0
  87. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_rls_cross_tenant.py +0 -0
  88. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_rpc_grants.py +0 -0
  89. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_rpc_migrations.py +0 -0
  90. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_run_agent_dry_run.py +0 -0
  91. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_run_agent_no_server_import.py +0 -0
  92. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_security_regressions.py +0 -0
  93. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_self_update_user_site.py +0 -0
  94. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_sentinel.py +0 -0
  95. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_session_replay_gate.py +0 -0
  96. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_setup_path.py +0 -0
  97. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_sleep_signals.py +0 -0
  98. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_status_enum_coverage.py +0 -0
  99. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_stay_on_loop_hook.py +0 -0
  100. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_swarm_events.py +0 -0
  101. {meshcode-2.11.131 → meshcode-2.11.132}/tests/test_terminal_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.131
3
+ Version: 2.11.132
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.131"
2
+ __version__ = "2.11.132"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -473,6 +473,30 @@ def _rpc(fn: str, payload: dict) -> Optional[dict]:
473
473
  return None
474
474
 
475
475
 
476
+ # SDK-EFF (task ab1f9f5a): mc_host_config_get was ~75k calls in the prod audit
477
+ # because every sweep helper fetched the full roster independently. One fetch
478
+ # now serves the whole ~10s sweep via a short TTL cache. TTL stays under
479
+ # POLL_INTERVAL_SEC so each sweep still sees a fresh roster — an etag scheme
480
+ # would never hit anyway: the payload embeds live heartbeat_age_s, so the
481
+ # server-side hash changes on every call by construction.
482
+ _HOST_CFG_CACHE: dict = {"key": None, "at": 0.0, "cfg": None}
483
+
484
+
485
+ def _host_cfg(api_key: str, host_id: str) -> Optional[dict]:
486
+ """mc_host_config_get with a per-sweep TTL cache. Failures are NOT cached."""
487
+ ttl = max(3.0, min(float(POLL_INTERVAL_SEC) - 1.0, 8.0))
488
+ now = time.monotonic()
489
+ c = _HOST_CFG_CACHE
490
+ if c["cfg"] is not None and c["key"] == (api_key, host_id) and now - c["at"] < ttl:
491
+ return c["cfg"]
492
+ cfg = _rpc("mc_host_config_get", {"p_api_key": api_key, "p_host_id": host_id})
493
+ if cfg and cfg.get("ok"):
494
+ c["key"] = (api_key, host_id)
495
+ c["at"] = now
496
+ c["cfg"] = cfg
497
+ return cfg
498
+
499
+
476
500
  # ------------------------------------------------------------------
477
501
  # Respawn
478
502
  # ------------------------------------------------------------------
@@ -1808,7 +1832,7 @@ def _do_stopped_ghost_sweep(api_key: str, host_id: str) -> int:
1808
1832
  <target>' cmdlines (token-safe) or direct children of the target's own
1809
1833
  hostd launcher .command — a human `claude --resume` in a plain tab can't
1810
1834
  match either. Returns number of ghosts killed."""
1811
- cfg = _rpc("mc_host_config_get", {"p_api_key": api_key, "p_host_id": host_id})
1835
+ cfg = _host_cfg(api_key, host_id) # SDK-EFF ab1f9f5a: per-sweep TTL cache
1812
1836
  if not cfg or not cfg.get("ok"):
1813
1837
  return 0
1814
1838
  st = _load_state()
@@ -1932,7 +1956,7 @@ def _do_reap(api_key: str, host_id: str) -> int:
1932
1956
  NEVER touches: a VISIBLE agent (user owns the window), or a desired_state='running' agent (workers are
1933
1957
  sacred — only the dup-EXTRA of a running agent is reapable, never its live survivor). Returns count
1934
1958
  actually killed (0 while DRY-RUN)."""
1935
- cfg = _rpc("mc_host_config_get", {"p_api_key": api_key, "p_host_id": host_id})
1959
+ cfg = _host_cfg(api_key, host_id) # SDK-EFF ab1f9f5a: per-sweep TTL cache
1936
1960
  if not cfg or not cfg.get("ok"):
1937
1961
  return 0
1938
1962
  agents = cfg.get("agents") or []
@@ -444,7 +444,13 @@ _SKIP_RECORDING = {"mc_heartbeat", "mc_record_event", "mc_agent_set_status_by_ap
444
444
  # ~18k err/12h log pollution. The two call sites already
445
445
  # treat it as soft-fail. Silence telemetry until Samuel
446
446
  # approves the public-wrapper DDL.
447
- "mc_tasks_due_soon"}
447
+ "mc_tasks_due_soon",
448
+ # SDK-EFF (task ab1f9f5a): the consolidated wait-loop poll and
449
+ # the two consume RPCs it superseded are pure infrastructure —
450
+ # recording them re-creates the exact null-value noise the
451
+ # task_list/count_pending skip above was added to kill
452
+ # (1 mc_record_event per idle cycle, fleet-wide).
453
+ "mc_wait_poll", "mc_consume_stop", "mc_consume_recycle"}
448
454
 
449
455
 
450
456
  def _get_api_key() -> Optional[str]:
@@ -511,22 +517,78 @@ def sb_rpc(fn_name: str, params: Dict, *, _max_retries: int = 3) -> Any:
511
517
  code="0", source="network")
512
518
 
513
519
 
514
- def _bg_record(event_type: str, payload: dict):
515
- """Background-thread recording never blocks the caller."""
516
- import threading
517
- def _do():
520
+ # SDK-EFF batching (task ab1f9f5a, audit c81c3b20): mc_record_event was the #1
521
+ # RPC in prod (~353k calls/wk) because _bg_record fired one RPC AND one thread
522
+ # per recorded event. Events now buffer in-process and a single daemon flusher
523
+ # ships them as ONE mc_record_events_batch call (live in prod, server caps 50
524
+ # events/batch) every _EVENT_FLUSH_SECS or as soon as _EVENT_FLUSH_MAX pile up.
525
+ # Recording stays best-effort: a failed flush drops that slice (the per-event
526
+ # path dropped on failure too) — a dead DB must never grow memory unbounded.
527
+ _EVENT_BUF: List[Dict] = []
528
+ _EVENT_BUF_LOCK = _threading.Lock()
529
+ _EVENT_FLUSH_SECS = 5.0
530
+ _EVENT_FLUSH_MAX = 40 # flush-early threshold, under the 50 server cap
531
+ _EVENT_BUF_HARD_CAP = 400 # runaway guard: beyond this, oldest events drop
532
+ _EVENT_FLUSH_WAKE = _threading.Event()
533
+ _event_flusher_started = False
534
+
535
+
536
+ def _flush_events_now():
537
+ """Drain the buffer in <=50-event slices (server batch cap). Best-effort."""
538
+ while True:
539
+ with _EVENT_BUF_LOCK:
540
+ if not _EVENT_BUF:
541
+ return
542
+ batch = _EVENT_BUF[:50]
543
+ del _EVENT_BUF[:50]
544
+ resp = sb_rpc_raw("mc_record_events_batch", {
545
+ "p_api_key": _recording_api_key,
546
+ "p_project_id": _recording_project_id,
547
+ "p_agent_name": _recording_agent_name,
548
+ "p_events": batch,
549
+ })
550
+ if resp is None or not (isinstance(resp, dict) and resp.get("ok")):
551
+ log.debug("event batch flush failed; %d events dropped", len(batch))
552
+
553
+
554
+ def _event_flusher_loop():
555
+ while True:
556
+ _EVENT_FLUSH_WAKE.wait(timeout=_EVENT_FLUSH_SECS)
557
+ _EVENT_FLUSH_WAKE.clear()
518
558
  try:
519
- sb_rpc_raw("mc_record_event", {
520
- "p_api_key": _recording_api_key,
521
- "p_project_id": _recording_project_id,
522
- "p_agent_name": _recording_agent_name,
523
- "p_session_id": _recording_session_id,
524
- "p_event_type": event_type,
525
- "p_payload": payload,
526
- })
559
+ _flush_events_now()
527
560
  except Exception as e:
528
- log.debug(f"bg_record failed ({event_type}): {e}")
529
- threading.Thread(target=_do, daemon=True).start()
561
+ log.debug(f"event flusher error: {e}")
562
+
563
+
564
+ def _ensure_event_flusher():
565
+ global _event_flusher_started
566
+ if _event_flusher_started:
567
+ return
568
+ with _EVENT_BUF_LOCK:
569
+ if _event_flusher_started:
570
+ return
571
+ _event_flusher_started = True
572
+ _threading.Thread(target=_event_flusher_loop, daemon=True,
573
+ name="mc-event-flusher").start()
574
+ import atexit
575
+ atexit.register(_flush_events_now)
576
+
577
+
578
+ def _bg_record(event_type: str, payload: dict):
579
+ """Buffer a session event for batched recording — never blocks the caller."""
580
+ with _EVENT_BUF_LOCK:
581
+ if len(_EVENT_BUF) >= _EVENT_BUF_HARD_CAP:
582
+ del _EVENT_BUF[:_EVENT_FLUSH_MAX]
583
+ _EVENT_BUF.append({
584
+ "session_id": _recording_session_id,
585
+ "event_type": event_type,
586
+ "payload": payload,
587
+ })
588
+ buffered = len(_EVENT_BUF)
589
+ _ensure_event_flusher()
590
+ if buffered >= _EVENT_FLUSH_MAX:
591
+ _EVENT_FLUSH_WAKE.set()
530
592
 
531
593
 
532
594
  def sb_rpc_raw(fn_name: str, params: Dict) -> Any:
@@ -3671,6 +3671,12 @@ def _wait_poll_or_legacy() -> Dict[str, Any]:
3671
3671
  "recycle": bool(resp.get("recycle")),
3672
3672
  "stop": bool(resp.get("stop")),
3673
3673
  "tasks": _pending_filter(resp.get("tasks") or []),
3674
+ # SDK-EFF (task ab1f9f5a): mc_wait_poll has returned the
3675
+ # unread count since mig 457, but the SDK ignored it and
3676
+ # kept its own count_pending in the inner-loop tail.
3677
+ # Surface it so the caller folds that RPC away. None on
3678
+ # the legacy path = caller must count_pending itself.
3679
+ "pending_count": resp.get("pending_count"),
3674
3680
  "_via": "mc_wait_poll",
3675
3681
  }
3676
3682
  except Exception as e:
@@ -3680,10 +3686,48 @@ def _wait_poll_or_legacy() -> Dict[str, Any]:
3680
3686
  "recycle": _check_recycle_request(),
3681
3687
  "stop": _check_stop_request(),
3682
3688
  "tasks": _get_pending_tasks_summary(),
3689
+ "pending_count": None,
3683
3690
  "_via": "legacy",
3684
3691
  }
3685
3692
 
3686
3693
 
3694
+ def _drain_unread_response(include_acks: bool) -> Optional[Dict[str, Any]]:
3695
+ """SDK-EFF (task ab1f9f5a): read+mark unread DB messages and shape them as
3696
+ a wait response. Extracted from the inner-loop's old final-fallback tail so
3697
+ the outer loop can invoke it only when mc_wait_poll reports pending_count>0
3698
+ (was: an unconditional count_pending every idle cycle). Returns None when
3699
+ nothing real (acks only / already seen) was found."""
3700
+ try:
3701
+ api_key = _get_api_key()
3702
+ if not api_key:
3703
+ return None
3704
+ raw = be.read_inbox(_PROJECT_ID, AGENT_NAME, mark_read=True, api_key=api_key)
3705
+ if not raw:
3706
+ return None
3707
+ msgs = [
3708
+ {"from": m["from_agent"], "type": m.get("type", "msg"),
3709
+ "ts": m.get("created_at"), "payload": m.get("payload", {}),
3710
+ "id": m.get("id"), "parent_id": m.get("parent_msg_id")}
3711
+ for m in raw
3712
+ ]
3713
+ deduped = _filter_and_mark(msgs)
3714
+ if not deduped:
3715
+ return None
3716
+ split = _split_messages(deduped)
3717
+ if not include_acks:
3718
+ split["acks"] = []
3719
+ if split["messages"] or split["done_signals"]:
3720
+ return {
3721
+ "got_message": True,
3722
+ **split,
3723
+ "auto_marked": len(deduped),
3724
+ "types_seen": sorted({m.get("type", "msg") for m in deduped}),
3725
+ }
3726
+ except Exception as e:
3727
+ log.debug(f"drain_unread error: {e}")
3728
+ return None
3729
+
3730
+
3687
3731
  def _check_recycle_request() -> bool:
3688
3732
  """Server-authorized recycle (task 548c863e, mig 364).
3689
3733
 
@@ -3735,7 +3779,8 @@ def _check_stop_request() -> bool:
3735
3779
  return False
3736
3780
 
3737
3781
 
3738
- def _try_auto_claim_self_assigned_tasks(max_claims: int = 20) -> List[Dict[str, str]]:
3782
+ def _try_auto_claim_self_assigned_tasks(max_claims: int = 20,
3783
+ tasks: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, str]]:
3739
3784
  """URGENT_RUNTIME_FIX_TASK_PULL (Samuel directive 2026-05-26T04:20Z msg
3740
3785
  a15d7ed4 + commander msg 0cfca3f3 AUTOCLAIM_ON_RECONNECT): claim every
3741
3786
  OPEN task already ASSIGNED to me but not yet claimed by anyone. Closes
@@ -3745,6 +3790,10 @@ def _try_auto_claim_self_assigned_tasks(max_claims: int = 20) -> List[Dict[str,
3745
3790
  "antes siempre que habia task assigned el agente sabia que tenia un
3746
3791
  task assigned pendiente y no paraba hasta completar todos").
3747
3792
 
3793
+ SDK-EFF (task ab1f9f5a): pass `tasks` (full task rows already fetched by
3794
+ the caller) to skip this function's own mc_task_list round-trip — the
3795
+ wait-entry path was fetching the identical list twice back-to-back.
3796
+
3748
3797
  Returns the list of claimed task summaries (may be empty).
3749
3798
  """
3750
3799
  claimed: List[Dict[str, str]] = []
@@ -3752,10 +3801,11 @@ def _try_auto_claim_self_assigned_tasks(max_claims: int = 20) -> List[Dict[str,
3752
3801
  api_key = _get_api_key()
3753
3802
  if not api_key:
3754
3803
  return claimed
3755
- result = be.task_list(api_key, _PROJECT_ID, AGENT_NAME, status_filter="open")
3756
- if not isinstance(result, dict) or not result.get("ok"):
3757
- return claimed
3758
- tasks = result.get("tasks", [])
3804
+ if tasks is None:
3805
+ result = be.task_list(api_key, _PROJECT_ID, AGENT_NAME, status_filter="open")
3806
+ if not isinstance(result, dict) or not result.get("ok"):
3807
+ return claimed
3808
+ tasks = result.get("tasks", [])
3759
3809
  candidates = [
3760
3810
  t for t in tasks
3761
3811
  if t.get("assignee") == AGENT_NAME
@@ -4302,11 +4352,23 @@ async def meshcode_wait(timeout_seconds: int = 20, include_acks: bool = False) -
4302
4352
  # offline at create time" gap. Always-on, not gated on
4303
4353
  # autonomous_mode. Best-effort.
4304
4354
  try:
4305
- _wait_entry_auto_claimed = _try_auto_claim_self_assigned_tasks()
4355
+ # SDK-EFF (task ab1f9f5a): ONE mc_task_list serves both the auto-claim
4356
+ # sweep and the pending summary below — they were identical back-to-back
4357
+ # fetches. The post-claim snapshot stays valid for _pending_filter:
4358
+ # a just-claimed task still matches on assignee==me with status 'open'.
4359
+ _entry_tasks_raw = None
4360
+ try:
4361
+ _tl = be.task_list(_get_api_key(), _PROJECT_ID, AGENT_NAME, status_filter=None)
4362
+ if isinstance(_tl, dict) and _tl.get("ok"):
4363
+ _entry_tasks_raw = _tl.get("tasks", [])
4364
+ except Exception:
4365
+ _entry_tasks_raw = None
4366
+ _wait_entry_auto_claimed = _try_auto_claim_self_assigned_tasks(tasks=_entry_tasks_raw)
4306
4367
  except Exception:
4307
4368
  _wait_entry_auto_claimed = []
4308
4369
 
4309
- pending_tasks = _get_pending_tasks_summary()
4370
+ pending_tasks = (_pending_filter(_entry_tasks_raw) if _entry_tasks_raw is not None
4371
+ else _get_pending_tasks_summary())
4310
4372
  if pending_tasks and os.environ.get("MESHCODE_WAIT_BLOCKS_ON_TASKS", "").lower() in ("1", "true", "yes"):
4311
4373
  if not _is_leader_agent():
4312
4374
  return {
@@ -4341,11 +4403,14 @@ async def meshcode_wait(timeout_seconds: int = 20, include_acks: bool = False) -
4341
4403
  # ("te escribo y no sale palomita azul en nadie"). If there are unread messages,
4342
4404
  # skip task_pull and fall through to PRODUCT RULE 2 so they get delivered + marked
4343
4405
  # read first. Task-pull still fires normally when the inbox is empty.
4344
- _has_unread = False
4406
+ # SDK-EFF (task ab1f9f5a): count once, share with PRODUCT RULE 2 below —
4407
+ # the entry path was issuing the identical count_pending twice in a row.
4408
+ _entry_db_pending = 0
4345
4409
  try:
4346
- _has_unread = bool(be.count_pending(_PROJECT_ID, AGENT_NAME, api_key=_get_api_key()))
4410
+ _entry_db_pending = be.count_pending(_PROJECT_ID, AGENT_NAME, api_key=_get_api_key()) or 0
4347
4411
  except Exception:
4348
- _has_unread = False
4412
+ _entry_db_pending = 0
4413
+ _has_unread = bool(_entry_db_pending)
4349
4414
  if _tasks_to_start and not _is_leader_agent() and not _has_unread:
4350
4415
  # Auto-start the highest priority OPEN task so the agent sees it as
4351
4416
  # in_progress and works it immediately. Samuel directive: "agents
@@ -4392,7 +4457,10 @@ async def meshcode_wait(timeout_seconds: int = 20, include_acks: bool = False) -
4392
4457
 
4393
4458
  # PRODUCT RULE 2: If agent has unread messages in DB, refuse to wait.
4394
4459
  try:
4395
- db_pending = be.count_pending(_PROJECT_ID, AGENT_NAME, api_key=_get_api_key())
4460
+ # SDK-EFF (task ab1f9f5a): reuse the entry count from above. read_inbox
4461
+ # is the authoritative drain; a message landing in the microseconds
4462
+ # between the two counts was equally invisible to the old double-count.
4463
+ db_pending = _entry_db_pending
4396
4464
  if db_pending and db_pending > 0:
4397
4465
  raw = be.read_inbox(_PROJECT_ID, AGENT_NAME, mark_read=True, api_key=_get_api_key())
4398
4466
  msgs = [
@@ -4507,6 +4575,32 @@ async def meshcode_wait(timeout_seconds: int = 20, include_acks: bool = False) -
4507
4575
  result["stopped"] = True
4508
4576
  break
4509
4577
 
4578
+ # SDK-EFF (task ab1f9f5a): realtime-missed messages. This drain
4579
+ # used to live in the inner-loop tail as its own count_pending;
4580
+ # mc_wait_poll already reports the unread count, so only the
4581
+ # actual read_inbox costs an RPC — and only when there IS mail.
4582
+ # Ordering note: recycle/stop now win over a missed message
4583
+ # (mc_wait_poll consumed the recycle flag, so it must be
4584
+ # honored); the message stays unread and redelivers after the
4585
+ # respawn / power-on. Legacy path (pending_count=None) keeps
4586
+ # the explicit count so old-RPC fleets lose no coverage.
4587
+ _db_pending = _wp.get("pending_count")
4588
+ if _db_pending is None:
4589
+ try:
4590
+ _db_pending = be.count_pending(_PROJECT_ID, AGENT_NAME, api_key=_get_api_key()) or 0
4591
+ except Exception:
4592
+ _db_pending = 0
4593
+ if _db_pending > 0:
4594
+ _drained = _drain_unread_response(include_acks)
4595
+ if _drained:
4596
+ result = _drained
4597
+ _set_state("online", "")
4598
+ _CONSECUTIVE_IDLE_SECONDS = 0
4599
+ _msg_count = len(result.get("messages", []))
4600
+ if _msg_count:
4601
+ _log_activity_bg("message_delivered", f"{AGENT_NAME} received {_msg_count} message(s)")
4602
+ break
4603
+
4510
4604
  # New tasks that appeared while we waited (from the same poll)
4511
4605
  pending_tasks = _wp["tasks"]
4512
4606
  if pending_tasks:
@@ -4562,6 +4656,17 @@ async def meshcode_wait(timeout_seconds: int = 20, include_acks: bool = False) -
4562
4656
  continue
4563
4657
  # ── END INTERNAL LOOP ──────────────────────────────────────
4564
4658
 
4659
+ # CAL-3 calendar context — SDK-EFF (task ab1f9f5a) moved it here from
4660
+ # the inner-loop tail: once per RETURNED wait instead of once per idle
4661
+ # cycle (it was a guaranteed RPC every ~20s that the outer loop usually
4662
+ # threw away on `continue`).
4663
+ if isinstance(result, dict) and result.get("timed_out"):
4664
+ try:
4665
+ _cal_ctx = _calendar_context_for_self()
4666
+ if _cal_ctx is not None:
4667
+ result["calendar_context"] = _cal_ctx
4668
+ except Exception:
4669
+ pass
4565
4670
  # _LAST_SEEN_TS persist removed (2026-05-28) — UUID dedup cache is sufficient
4566
4671
  # Surface memory_hints from mig 220 if a read_inbox happened during
4567
4672
  # this wait cycle (PRODUCT RULE 2 path) or inside _meshcode_wait_inner.
@@ -4829,43 +4934,15 @@ async def _meshcode_wait_inner(actual_timeout: int, include_acks: bool) -> Dict[
4829
4934
  except Exception as e:
4830
4935
  log.debug(f"DB poll fallback error: {e}")
4831
4936
 
4832
- # Final fallback: one last DB check (covers realtime path missing msgs)
4833
- try:
4834
- api_key = _get_api_key()
4835
- if api_key:
4836
- db_pending = be.count_pending(_PROJECT_ID, AGENT_NAME, api_key=api_key)
4837
- if db_pending and db_pending > 0:
4838
- raw = be.read_inbox(_PROJECT_ID, AGENT_NAME, mark_read=True, api_key=api_key)
4839
- if raw:
4840
- msgs = [
4841
- {"from": m["from_agent"], "type": m.get("type", "msg"),
4842
- "ts": m.get("created_at"), "payload": m.get("payload", {}),
4843
- "id": m.get("id"), "parent_id": m.get("parent_msg_id")}
4844
- for m in raw
4845
- ]
4846
- deduped = _filter_and_mark(msgs)
4847
- if deduped:
4848
- split = _split_messages(deduped)
4849
- if not include_acks:
4850
- split["acks"] = []
4851
- if split["messages"] or split["done_signals"]:
4852
- return {"got_message": True, **split}
4853
- except Exception as e:
4854
- log.debug(f"final DB fallback error: {e}")
4855
-
4856
- # Check if there's any pending work before returning timeout
4857
- pending_tasks = _get_pending_tasks_summary()
4858
- out: Dict[str, Any] = {"timed_out": True}
4859
- if pending_tasks:
4860
- out["pending_tasks"] = pending_tasks
4861
- else:
4862
- out["no_work"] = True
4863
- # CAL-3 calendar context — surface due/overdue/next_due on timeouts so the
4864
- # agent knows whether to nudge work even when no new msg arrived.
4865
- _ctx = _calendar_context_for_self()
4866
- if _ctx is not None:
4867
- out["calendar_context"] = _ctx
4868
- return out
4937
+ # SDK-EFF (task ab1f9f5a): the tail here used to fire 3 more RPCs per idle
4938
+ # cycle — count_pending (realtime-missed-message fallback), task_list (via
4939
+ # _get_pending_tasks_summary) and mc_tasks_due_soon (calendar) — right
4940
+ # before the OUTER loop issued mc_wait_poll, which already answers the
4941
+ # first two. The missed-message drain now lives in the outer loop (driven
4942
+ # by mc_wait_poll's pending_count), tasks come from the same poll, and the
4943
+ # calendar snapshot is attached once per RETURNED wait instead of once per
4944
+ # cycle. Net: 4 RPCs/idle-cycle -> 1.
4945
+ return {"timed_out": True}
4869
4946
 
4870
4947
 
4871
4948
  @mcp.tool()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.131
3
+ Version: 2.11.132
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.131"
7
+ version = "2.11.132"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -178,7 +178,10 @@ class TestGhostSweep:
178
178
  assert re.search(r"heartbeat_age_s", self.SRC) and "90" in self.SRC
179
179
 
180
180
  def test_uses_roster_not_heartbeat_gated_rpc(self):
181
- assert "mc_host_config_get" in self.SRC
181
+ # SDK-EFF (task ab1f9f5a): the sweep now reads the roster through
182
+ # _host_cfg, the per-sweep TTL cache over mc_host_config_get — same
183
+ # no-heartbeat-gate source, one RPC per sweep instead of one per helper.
184
+ assert "mc_host_config_get" in self.SRC or "_host_cfg(" in self.SRC
182
185
  assert "mc_agents_to_stop" not in self.SRC
183
186
 
184
187
  def test_wired_into_poll_loop(self):
@@ -63,7 +63,11 @@ class TestWaitContradictionFix(unittest.TestCase):
63
63
  def test_pending_tasks_hint_path_unchanged(self):
64
64
  # Existing hint surface in the timeout path must remain — that's
65
65
  # how the agent now sees open tasks without being blocked.
66
- idx = self.source.find('out["pending_tasks"] = pending_tasks')
66
+ # SDK-EFF (task ab1f9f5a): the hint moved from the inner-loop tail
67
+ # (out[...]) to the outer loop's mc_wait_poll branch (result[...]) —
68
+ # same surface, fed by one consolidated RPC instead of a task_list
69
+ # per idle cycle.
70
+ idx = self.source.find('result["pending_tasks"] = pending_tasks')
67
71
  self.assertGreater(idx, 0,
68
72
  "fallback timeout path must still surface "
69
73
  "pending_tasks as a hint so the agent sees "
File without changes
File without changes
File without changes
File without changes