meshcode 2.11.120__tar.gz → 2.11.122__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.
- {meshcode-2.11.120 → meshcode-2.11.122}/PKG-INFO +1 -1
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/__init__.py +1 -1
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/hostd.py +141 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/realtime.py +13 -1
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/server.py +30 -19
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/sleep_signals.py +44 -1
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode.egg-info/SOURCES.txt +1 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/pyproject.toml +1 -1
- meshcode-2.11.122/tests/test_swarm_events.py +115 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/README.md +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/__main__.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/_session_handoff_template.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/_stop_hook_template.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/ascii_art.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/atomic_push.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/claude_update.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/cli.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/comms_v4.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/compat.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/daemon.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/date_parse.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/doctor.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/error_hints.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/exceptions.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/hooks/__init__.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/hooks/repo_path_lock.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/invites.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/launcher.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/launcher_install.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/backend.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/preferences.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/protocol_handler.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/quickstart.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/rpc_allowlist.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/run_agent.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/scripts/check_secrets.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/scripts/race_rate_harness.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/secrets.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/self_update.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/setup_clients.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/supervisor.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/up.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode/upload.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/setup.cfg +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_auto_update_hardening.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_autonomous_closegap_1.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_autonomous_closegap_2.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_autonomous_closegap_3.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_autonomous_prompt_inject.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_boot_bug_regression.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_color_truecolor.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_core.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_cross_agent_messaging.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_date_parse.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_doctor.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_epistemic_v1_python_sdk.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_epistemic_v1_stop_conditions.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_esc_deaf_state.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_exceptions.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_file_upload.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_init_device_code.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_install_guard.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_lease_sigterm_release.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_mark_read_batch.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_marketplace_ratings.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_migration_integrity.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_realtime_event_freshness.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_rls_cross_tenant.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_rpc_grants.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_rpc_migrations.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_run_agent_dry_run.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_run_agent_no_server_import.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_security_regressions.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_self_update_user_site.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_sentinel.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_setup_path.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_sleep_signals.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_status_enum_coverage.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_stay_on_loop_hook.py +0 -0
- {meshcode-2.11.120 → meshcode-2.11.122}/tests/test_wait_open_tasks_contradiction.py +0 -0
|
@@ -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
|
|
@@ -1676,6 +1762,52 @@ def _do_version_recycles(api_key: str, host_id: str) -> int:
|
|
|
1676
1762
|
return n
|
|
1677
1763
|
|
|
1678
1764
|
|
|
1765
|
+
# c301be69: hold the singleton lock handle for the daemon's lifetime — module
|
|
1766
|
+
# global so it's never GC'd (GC would close the fd and release the lock).
|
|
1767
|
+
_HOSTD_LOCK_FH = None
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def _acquire_hostd_singleton():
|
|
1771
|
+
"""One hostd per machine (task c301be69 — duplicate-daemon race observed
|
|
1772
|
+
live 2026-06-09: a manual `nohup meshcode hostd run` raced auto-bootstrap,
|
|
1773
|
+
TWO daemons ran ~30s = double respawn/recycle sweeps = storm class).
|
|
1774
|
+
|
|
1775
|
+
Exclusive flock on ~/.meshcode/hostd.lock, held for the process lifetime.
|
|
1776
|
+
The OS releases it on ANY death (crash, kill -9), so there is no
|
|
1777
|
+
stale-pidfile problem; the pid written inside is informational only.
|
|
1778
|
+
Python opens fds CLOEXEC (PEP 446), so the self-reexec-on-version-drift
|
|
1779
|
+
path releases the lock at exec time and the fresh image re-acquires it.
|
|
1780
|
+
|
|
1781
|
+
Returns (fh, status): status in 'acquired' | 'held' | 'error'.
|
|
1782
|
+
'error' means the lock could not be evaluated — callers FAIL OPEN
|
|
1783
|
+
(one possibly-duplicate daemon beats no daemon at all)."""
|
|
1784
|
+
try:
|
|
1785
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
1786
|
+
fh = open(STATE_DIR / "hostd.lock", "a+")
|
|
1787
|
+
try:
|
|
1788
|
+
if sys.platform == "win32":
|
|
1789
|
+
import msvcrt
|
|
1790
|
+
fh.seek(0)
|
|
1791
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
|
1792
|
+
else:
|
|
1793
|
+
import fcntl
|
|
1794
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1795
|
+
except OSError:
|
|
1796
|
+
fh.close()
|
|
1797
|
+
return (None, "held")
|
|
1798
|
+
try:
|
|
1799
|
+
fh.seek(0)
|
|
1800
|
+
fh.truncate()
|
|
1801
|
+
fh.write(str(os.getpid()))
|
|
1802
|
+
fh.flush()
|
|
1803
|
+
except Exception:
|
|
1804
|
+
pass
|
|
1805
|
+
return (fh, "acquired")
|
|
1806
|
+
except Exception as e:
|
|
1807
|
+
_log(f"WARN singleton lock unavailable ({e}) — proceeding unlocked (fail-open)")
|
|
1808
|
+
return (None, "error")
|
|
1809
|
+
|
|
1810
|
+
|
|
1679
1811
|
def cmd_hostd(args: list) -> int:
|
|
1680
1812
|
"""Entry point for `meshcode hostd ...`."""
|
|
1681
1813
|
if not args or args[0] in ("-h", "--help"):
|
|
@@ -1704,6 +1836,15 @@ def cmd_hostd(args: list) -> int:
|
|
|
1704
1836
|
if not api_key:
|
|
1705
1837
|
_log("FATAL: no api key — run `meshcode login` (key is read from the keychain)")
|
|
1706
1838
|
return 1
|
|
1839
|
+
# SINGLETON (task c301be69): second instance exits 0 with one log line.
|
|
1840
|
+
# Covers the manual-run vs auto-bootstrap race; _hostd_bootstrap_if_needed
|
|
1841
|
+
# honors it implicitly — its spawned `hostd run` hits this gate and exits.
|
|
1842
|
+
global _HOSTD_LOCK_FH
|
|
1843
|
+
_lk, _lk_status = _acquire_hostd_singleton()
|
|
1844
|
+
if _lk_status == "held":
|
|
1845
|
+
_log("hostd already running on this machine (hostd.lock held) — exiting (singleton c301be69)")
|
|
1846
|
+
return 0
|
|
1847
|
+
_HOSTD_LOCK_FH = _lk # may be None on 'error' (fail-open)
|
|
1707
1848
|
# P1 hostd_CRASH_LOOP: the detached Windows daemon has NO console, so crashes (incl hard
|
|
1708
1849
|
# faults / uncaught exceptions) went to INVISIBLE stderr — hostd.log showed nothing. Capture
|
|
1709
1850
|
# stderr + faulthandler tracebacks to a file + install an excepthook so the NEXT crash is
|
|
@@ -347,7 +347,19 @@ class RealtimeListener:
|
|
|
347
347
|
self.message_event.set()
|
|
348
348
|
except Exception:
|
|
349
349
|
pass
|
|
350
|
-
|
|
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
|
|
|
@@ -4107,11 +4109,14 @@ def _try_auto_complete_on_ship(payload: Dict[str, Any]) -> Optional[Dict[str, An
|
|
|
4107
4109
|
return None
|
|
4108
4110
|
|
|
4109
4111
|
|
|
4110
|
-
def
|
|
4111
|
-
"""CLOSEGAP-3
|
|
4112
|
-
in_progress
|
|
4113
|
-
|
|
4114
|
-
|
|
4112
|
+
def _try_auto_park_on_done_status(task_hint: str, status: str = "done") -> Optional[Dict[str, Any]]:
|
|
4113
|
+
"""CLOSEGAP-3 v2 (task 53767993): when set_status='done'/'sleeping' with
|
|
4114
|
+
exactly ONE in_progress claim, PARK it — roll back to 'claimed' with a
|
|
4115
|
+
checkpoint note (mc_task_checkpoint_park, mig 476) — NEVER complete it.
|
|
4116
|
+
Live incident 2026-06-09T23:30Z: the old auto-COMPLETE closed 250b7c1e as
|
|
4117
|
+
done with zero work at an ordered exit-for-respawn (board lied to Samuel;
|
|
4118
|
+
re-opened as 7a467894). status='done' may now ONLY come from an explicit
|
|
4119
|
+
meshcode_task_complete. No-op for 0 or >1 active claims."""
|
|
4115
4120
|
try:
|
|
4116
4121
|
api_key = _get_api_key()
|
|
4117
4122
|
if not api_key:
|
|
@@ -4126,24 +4131,30 @@ def _try_auto_complete_on_done_status(task_hint: str) -> Optional[Dict[str, Any]
|
|
|
4126
4131
|
if len(active) != 1:
|
|
4127
4132
|
return None
|
|
4128
4133
|
target = active[0]
|
|
4129
|
-
|
|
4130
|
-
f"
|
|
4131
|
-
f"Task hint: {(task_hint or '(none)')[:200]}. "
|
|
4132
|
-
f"
|
|
4134
|
+
note = (
|
|
4135
|
+
f"CHECKPOINT (CLOSEGAP-3 park): agent flipped set_status='{status}' "
|
|
4136
|
+
f"mid-claim (rotation/sleep). Task hint: {(task_hint or '(none)')[:200]}. "
|
|
4137
|
+
f"Work NOT verified complete — resuming agent must task_complete explicitly."
|
|
4133
4138
|
)
|
|
4134
|
-
result = be.
|
|
4139
|
+
result = be.sb_rpc("mc_task_checkpoint_park", {
|
|
4140
|
+
"p_api_key": api_key,
|
|
4141
|
+
"p_project_id": _PROJECT_ID,
|
|
4142
|
+
"p_task_id": target["id"],
|
|
4143
|
+
"p_agent": AGENT_NAME,
|
|
4144
|
+
"p_note": note,
|
|
4145
|
+
})
|
|
4135
4146
|
if not (isinstance(result, dict) and result.get("ok")):
|
|
4136
4147
|
return None
|
|
4137
4148
|
try:
|
|
4138
4149
|
_log_activity_bg(
|
|
4139
4150
|
"auto_checkpoint",
|
|
4140
|
-
f"{AGENT_NAME}
|
|
4151
|
+
f"{AGENT_NAME} PARKED task {target['id'][:8]} on set_status={status} (claimed again, NOT done)",
|
|
4141
4152
|
)
|
|
4142
4153
|
except Exception:
|
|
4143
4154
|
pass
|
|
4144
|
-
return {"task_id": target["id"][:8], "title": str(target.get("title") or "")[:80]}
|
|
4155
|
+
return {"task_id": target["id"][:8], "title": str(target.get("title") or "")[:80], "parked": True}
|
|
4145
4156
|
except Exception as e:
|
|
4146
|
-
log.debug(f"[meshcode] auto-
|
|
4157
|
+
log.debug(f"[meshcode] auto-park on done-status failed: {e}")
|
|
4147
4158
|
return None
|
|
4148
4159
|
|
|
4149
4160
|
|
|
@@ -5077,15 +5088,15 @@ def meshcode_set_status(status: str, task: str = "") -> Dict[str, Any]:
|
|
|
5077
5088
|
}
|
|
5078
5089
|
except Exception:
|
|
5079
5090
|
pass # best-effort; never block set_status
|
|
5080
|
-
# CLOSEGAP-3 (task
|
|
5081
|
-
#
|
|
5082
|
-
#
|
|
5091
|
+
# CLOSEGAP-3 v2 (task 53767993, was 69dc5a62): when the agent flips
|
|
5092
|
+
# set_status='done'/'sleeping' with exactly one active in_progress claim,
|
|
5093
|
+
# PARK that claim (back to 'claimed' + checkpoint note) — never complete.
|
|
5083
5094
|
# Baseline (NOT autonomous-gated). Best-effort.
|
|
5084
|
-
if status
|
|
5095
|
+
if status in ("done", "sleeping") and isinstance(resp, dict) and resp.get("ok"):
|
|
5085
5096
|
try:
|
|
5086
|
-
_cp =
|
|
5097
|
+
_cp = _try_auto_park_on_done_status(task, status)
|
|
5087
5098
|
if _cp:
|
|
5088
|
-
resp["
|
|
5099
|
+
resp["auto_parked_task"] = _cp
|
|
5089
5100
|
except Exception:
|
|
5090
5101
|
pass
|
|
5091
5102
|
return resp
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|