meshcode 2.11.135__tar.gz → 2.11.136__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.135 → meshcode-2.11.136}/PKG-INFO +1 -1
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/__init__.py +1 -1
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/hostd.py +106 -20
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/protocol_handler.py +19 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-2.11.135 → meshcode-2.11.136}/pyproject.toml +1 -1
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_hostd_zombie_sessions.py +141 -3
- {meshcode-2.11.135 → meshcode-2.11.136}/README.md +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/__main__.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/_session_handoff_template.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/_stop_hook_template.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/ascii_art.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/atomic_push.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/claude_update.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/cli.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/comms_v4.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/compat.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/daemon.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/date_parse.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/doctor.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/error_hints.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/exceptions.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/hooks/__init__.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/hooks/repo_path_lock.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/invites.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/launcher.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/launcher_install.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/backend.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/realtime.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/server.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/swarm.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/meshcode_mcp/test_swarm.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/preferences.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/quickstart.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/rpc_allowlist.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/run_agent.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/scripts/check_secrets.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/scripts/race_rate_harness.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/secrets.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/self_update.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/setup_clients.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/supervisor.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/up.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode/upload.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode.egg-info/SOURCES.txt +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/setup.cfg +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_auto_update_hardening.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_autonomous_closegap_1.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_autonomous_closegap_2.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_autonomous_closegap_3.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_autonomous_prompt_inject.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_boot_bug_regression.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_color_truecolor.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_core.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_cross_agent_messaging.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_date_parse.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_doctor.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_epistemic_v1_python_sdk.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_epistemic_v1_stop_conditions.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_esc_deaf_state.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_exceptions.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_file_upload.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_init_device_code.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_install_guard.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_lease_sigterm_release.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_live_mesh_guard.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_mark_read_batch.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_marketplace_ratings.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_migration_integrity.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_realtime_event_freshness.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_rls_cross_tenant.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_rpc_grants.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_rpc_migrations.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_run_agent_dry_run.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_run_agent_no_server_import.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_security_regressions.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_self_update_user_site.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_sentinel.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_session_replay_gate.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_setup_path.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_sleep_signals.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_status_enum_coverage.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_stay_on_loop_hook.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_stop_ghost_terminal.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_swarm_events.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_task_progress.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_terminal_lifecycle.py +0 -0
- {meshcode-2.11.135 → meshcode-2.11.136}/tests/test_wait_open_tasks_contradiction.py +0 -0
|
@@ -1330,6 +1330,46 @@ def _own_ancestry_pids() -> set:
|
|
|
1330
1330
|
return anc
|
|
1331
1331
|
|
|
1332
1332
|
|
|
1333
|
+
def _run_token_rx(target: str):
|
|
1334
|
+
"""`meshcode run <target>` token regex accepting BOTH spawn forms (task 3ed8781d).
|
|
1335
|
+
|
|
1336
|
+
BARE-NAME GAP (the Windows stop-por-RPC zombie, Samuel 2026-06-11): hostd spawns
|
|
1337
|
+
`run "project/agent"` but the FE Start click goes through protocol_handler's
|
|
1338
|
+
cmd_launch_batch, which spawns `run "agent"` (bare). The old per-call-site token
|
|
1339
|
+
(`run <project/agent>`) could NEVER match a bare-spawned session, so stop/force-kill/
|
|
1340
|
+
reap/reconcile discovery silently found nothing for protocol-launched agents — the
|
|
1341
|
+
session survived every Stop, the relaunch opened a NEW wt tab on top, and Samuel's
|
|
1342
|
+
box accumulated up to 3 sessions/tabs per agent.
|
|
1343
|
+
|
|
1344
|
+
- target 'proj/agent': matches `run [\"']?(proj/)?agent` — both qualified and bare.
|
|
1345
|
+
- target 'agent' (bare, the protocol reconcile caller): matches `run [\"']?(<any>/)?agent`
|
|
1346
|
+
— both forms again, since a survivor may have been hostd-spawned.
|
|
1347
|
+
All forms keep the optional opening quote (QUOTED-TARGET FIX 14e0760c) and the
|
|
1348
|
+
trailing lookahead, so 'qa' can never prefix-match 'qa-2'."""
|
|
1349
|
+
proj, _, agent = target.rpartition("/")
|
|
1350
|
+
if proj:
|
|
1351
|
+
pfx = r"(?:" + re.escape(proj) + r"/)?"
|
|
1352
|
+
else:
|
|
1353
|
+
pfx = r"(?:[^\s\"']*/)?"
|
|
1354
|
+
return re.compile(r"run\s+[\"']?" + pfx + re.escape(agent) + r"(?=\s|$|[\"'])")
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def _bare_match_is_foreign(cl: str, cwd: str, target: str) -> bool:
|
|
1358
|
+
"""Cross-project guard for BARE-token matches (task 3ed8781d). When `target` is
|
|
1359
|
+
project-qualified but the candidate cmdline only carries the bare agent name,
|
|
1360
|
+
a same-named agent of ANOTHER mesh on this box could collide. The workspace
|
|
1361
|
+
cwd (`~/meshcode/<project>-<agent>`) disambiguates: if the cwd basename looks
|
|
1362
|
+
like a workspace of this agent but a DIFFERENT project, skip it. Unreadable
|
|
1363
|
+
cwd or qualified-token matches are never rejected (best-effort guard only)."""
|
|
1364
|
+
proj, _, agent = target.rpartition("/")
|
|
1365
|
+
if not proj or f"{proj}/{agent}" in (cl or ""):
|
|
1366
|
+
return False # qualified match — unambiguous
|
|
1367
|
+
# separator-agnostic last path component (a Windows cwd seen from tests/tools
|
|
1368
|
+
# running under POSIX must split the same way)
|
|
1369
|
+
base = re.split(r"[\\/]", (cwd or "").rstrip("/\\"))[-1]
|
|
1370
|
+
return base.endswith(f"-{agent}") and base != f"{proj}-{agent}"
|
|
1371
|
+
|
|
1372
|
+
|
|
1333
1373
|
def _discover_agent_pids(target: str) -> list:
|
|
1334
1374
|
"""Fallback PID discovery by command line, for agents spawned before this hostd
|
|
1335
1375
|
(no recorded PID) or after a state-file loss. Matches `meshcode run <target>`.
|
|
@@ -1353,9 +1393,16 @@ def _discover_agent_pids(target: str) -> list:
|
|
|
1353
1393
|
(`run\\s+<target>`) NEVER matched on Windows — stop/force-kill discovery silently
|
|
1354
1394
|
found nothing, which is how stopped sessions survived as zombies. The token now
|
|
1355
1395
|
accepts one optional opening quote; the trailing lookahead still forbids any
|
|
1356
|
-
'qa' vs 'qa-2' prefix collision.
|
|
1396
|
+
'qa' vs 'qa-2' prefix collision.
|
|
1397
|
+
|
|
1398
|
+
BARE-NAME FIX (task 3ed8781d): the token now comes from _run_token_rx, which ALSO
|
|
1399
|
+
matches protocol-handler bare-name spawns (`run "agent"`) — the form every FE Start
|
|
1400
|
+
click produces. Bare matches are cwd-guarded against same-named agents of another
|
|
1401
|
+
mesh (psutil path only; the native fallbacks accept the regex as-is, documented
|
|
1402
|
+
tradeoff: a zombie that survives every stop is the live bug, the cross-mesh
|
|
1403
|
+
same-name collision is the rare one)."""
|
|
1357
1404
|
pids = []
|
|
1358
|
-
tok =
|
|
1405
|
+
tok = _run_token_rx(target)
|
|
1359
1406
|
ps = _psutil()
|
|
1360
1407
|
if ps is not None:
|
|
1361
1408
|
try:
|
|
@@ -1366,10 +1413,23 @@ def _discover_agent_pids(target: str) -> list:
|
|
|
1366
1413
|
continue
|
|
1367
1414
|
cl = " ".join(p.cmdline() or [])
|
|
1368
1415
|
if "meshcode" in cl and tok.search(cl) and "python" in (p.name() or "").lower():
|
|
1416
|
+
try:
|
|
1417
|
+
cwd = p.cwd() or ""
|
|
1418
|
+
except Exception:
|
|
1419
|
+
cwd = ""
|
|
1420
|
+
if _bare_match_is_foreign(cl, cwd, target):
|
|
1421
|
+
_log(f"DISCOVER {target}: pid {p.pid} bare-name match belongs to "
|
|
1422
|
+
f"another project (cwd {cwd!r}) — skip")
|
|
1423
|
+
continue
|
|
1369
1424
|
pids.append(p.pid)
|
|
1370
1425
|
except Exception:
|
|
1371
1426
|
continue
|
|
1372
|
-
|
|
1427
|
+
# GHOST-TERMINAL fix (91201315) APPLIES HERE TOO (task 3ed8781d): the old
|
|
1428
|
+
# `return pids` skipped the POSIX launcher-children fallback whenever
|
|
1429
|
+
# psutil was importable — re-defeating the '0 stopped forever' hole on
|
|
1430
|
+
# every box with psutil. Fall through to the shared tail instead.
|
|
1431
|
+
return pids + ([p for p in _discover_launcher_child_pids(target)
|
|
1432
|
+
if p not in pids] if sys.platform != "win32" else [])
|
|
1373
1433
|
except Exception:
|
|
1374
1434
|
pids = [] # psutil enumeration itself broke — fall through to native
|
|
1375
1435
|
try:
|
|
@@ -1389,8 +1449,11 @@ def _discover_agent_pids(target: str) -> list:
|
|
|
1389
1449
|
pass
|
|
1390
1450
|
block_pid = None
|
|
1391
1451
|
else:
|
|
1452
|
+
# task 3ed8781d: pattern WITHOUT the target so bare-name spawns
|
|
1453
|
+
# (`run "agent"`) are candidates too; the exact tok filter below
|
|
1454
|
+
# keeps the kill set tight.
|
|
1392
1455
|
out = subprocess.run(
|
|
1393
|
-
["pgrep", "-f",
|
|
1456
|
+
["pgrep", "-f", "meshcode.* run "],
|
|
1394
1457
|
capture_output=True, text=True, timeout=8).stdout
|
|
1395
1458
|
for ln in out.split():
|
|
1396
1459
|
try:
|
|
@@ -1474,12 +1537,17 @@ def _discover_serve_pids(target: str) -> list:
|
|
|
1474
1537
|
sibling agent's serve (same project, different agent) can never match. psutil-only
|
|
1475
1538
|
(environ() needs a real process handle); without psutil returns [] — degraded but
|
|
1476
1539
|
safe, and the session-root tree kill still takes any serve that is STILL parented
|
|
1477
|
-
under the session.
|
|
1540
|
+
under the session.
|
|
1541
|
+
|
|
1542
|
+
BARE-TARGET support (task 3ed8781d): a bare `agent` target (the protocol-handler
|
|
1543
|
+
relaunch reconcile) matches on MESHCODE_AGENT alone — the relaunch click means
|
|
1544
|
+
'fresh session of THIS agent on this box', so any project's orphan of that name
|
|
1545
|
+
is a survivor to clear. Qualified targets keep the exact two-field match."""
|
|
1478
1546
|
ps = _psutil()
|
|
1479
1547
|
if ps is None:
|
|
1480
1548
|
return []
|
|
1481
|
-
proj, _, agent = target.
|
|
1482
|
-
if not
|
|
1549
|
+
proj, _, agent = target.rpartition("/")
|
|
1550
|
+
if not agent:
|
|
1483
1551
|
return []
|
|
1484
1552
|
own = _own_pid()
|
|
1485
1553
|
out = []
|
|
@@ -1492,7 +1560,8 @@ def _discover_serve_pids(target: str) -> list:
|
|
|
1492
1560
|
if "meshcode" not in cl or "serve" not in cl or "meshcode_mcp" not in cl:
|
|
1493
1561
|
continue
|
|
1494
1562
|
env = p.environ() or {}
|
|
1495
|
-
if env.get("
|
|
1563
|
+
if env.get("MESHCODE_AGENT") == agent and \
|
|
1564
|
+
(not proj or env.get("MESHCODE_PROJECT") == proj):
|
|
1496
1565
|
out.append(p.pid)
|
|
1497
1566
|
except Exception:
|
|
1498
1567
|
continue # process vanished / access denied — never block the sweep
|
|
@@ -1507,10 +1576,16 @@ def _kill_heartbeat_fork(target: str) -> None:
|
|
|
1507
1576
|
would keep POSTing and show a stopped agent 'online'. Stop it by its pidfile
|
|
1508
1577
|
heartbeat_<proj>_<name>.pid. Best-effort."""
|
|
1509
1578
|
try:
|
|
1510
|
-
proj, _, agent = target.
|
|
1579
|
+
proj, _, agent = target.rpartition("/")
|
|
1511
1580
|
if not agent:
|
|
1512
1581
|
return
|
|
1513
|
-
|
|
1582
|
+
if proj:
|
|
1583
|
+
pidf = STATE_DIR / f"heartbeat_{proj}_{agent}.pid"
|
|
1584
|
+
else:
|
|
1585
|
+
# bare target (task 3ed8781d, protocol-handler reconcile): project
|
|
1586
|
+
# unknown — glob the agent's pidfile across projects (first hit).
|
|
1587
|
+
cands = sorted(STATE_DIR.glob(f"heartbeat_*_{agent}.pid"))
|
|
1588
|
+
pidf = cands[0] if cands else STATE_DIR / f"heartbeat__{agent}.pid"
|
|
1514
1589
|
if not pidf.exists():
|
|
1515
1590
|
return
|
|
1516
1591
|
try:
|
|
@@ -1542,15 +1617,25 @@ def _write_stop_marker(target: str) -> None:
|
|
|
1542
1617
|
(6/6 needed SIGKILL), so the wrapper sees rc=137 — indistinguishable from
|
|
1543
1618
|
a crash by exit code alone. The wrapper treats a non-zero rc as a CLEAN
|
|
1544
1619
|
stop (closes its own tab) only when this marker exists and is fresh;
|
|
1545
|
-
real crashes have no marker and keep their scrollback open. Best-effort.
|
|
1620
|
+
real crashes have no marker and keep their scrollback open. Best-effort.
|
|
1621
|
+
|
|
1622
|
+
DUAL-LABEL FIX (task 3ed8781d, Windows tab accumulation): the launcher script
|
|
1623
|
+
computes its marker name from ITS OWN spawn cmd — hostd spawns carry
|
|
1624
|
+
'project/agent' (label 'project_agent') but protocol-handler launch-batch spawns
|
|
1625
|
+
carry the BARE agent name (label 'agent'). A stop that only stamped the
|
|
1626
|
+
target-form marker left a bare-spawned wrapper seeing rc!=0 WITHOUT its marker
|
|
1627
|
+
-> `pause` -> dead wt tab held open forever; relaunch then added a fresh tab
|
|
1628
|
+
(Samuel: 'demasiadas tabs'). Stamp BOTH labels — both are consumed/GC'd."""
|
|
1546
1629
|
try:
|
|
1547
|
-
# EXACT mirror of protocol_handler._launcher_label's sanitization —
|
|
1548
|
-
# the wrapper computes the same name at generation time.
|
|
1549
|
-
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", target.strip()).strip("_")[:80]
|
|
1550
|
-
if not safe:
|
|
1551
|
-
return
|
|
1552
1630
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
1553
|
-
|
|
1631
|
+
labels = {target.strip(), target.rpartition("/")[2].strip()}
|
|
1632
|
+
for raw in labels:
|
|
1633
|
+
# EXACT mirror of protocol_handler._launcher_label's sanitization —
|
|
1634
|
+
# the wrapper computes the same name at generation time.
|
|
1635
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", raw).strip("_")[:80]
|
|
1636
|
+
if not safe:
|
|
1637
|
+
continue
|
|
1638
|
+
(STATE_DIR / f"stopmark_{safe}").write_text(str(time.time()), encoding="utf-8")
|
|
1554
1639
|
except Exception:
|
|
1555
1640
|
pass
|
|
1556
1641
|
|
|
@@ -1569,9 +1654,10 @@ def _session_root_pid(target: str, pid: int) -> int:
|
|
|
1569
1654
|
ps = _psutil()
|
|
1570
1655
|
if ps is None:
|
|
1571
1656
|
return pid
|
|
1572
|
-
#
|
|
1573
|
-
#
|
|
1574
|
-
|
|
1657
|
+
# Same token everywhere (task 3ed8781d): _run_token_rx accepts the quoted AND the
|
|
1658
|
+
# bare-name spawn forms, so the climb also reaches the wrapper of a protocol-
|
|
1659
|
+
# handler-launched session (`run "agent"`).
|
|
1660
|
+
tok = _run_token_rx(target)
|
|
1575
1661
|
anc = _own_ancestry_pids()
|
|
1576
1662
|
root = pid
|
|
1577
1663
|
try:
|
|
@@ -1047,6 +1047,7 @@ def cmd_launch_batch(agent_names: Iterable[str], repo_path: Optional[str] = None
|
|
|
1047
1047
|
|
|
1048
1048
|
launched: list[str] = []
|
|
1049
1049
|
skipped: list[dict] = []
|
|
1050
|
+
reconciled: dict = {} # agent -> surviving session trees killed pre-spawn (3ed8781d)
|
|
1050
1051
|
|
|
1051
1052
|
# Resolve `meshcode` binary path (CLI wrapper installed by pip).
|
|
1052
1053
|
mc_bin = shutil.which("meshcode") or "meshcode"
|
|
@@ -1094,6 +1095,22 @@ def cmd_launch_batch(agent_names: Iterable[str], repo_path: Optional[str] = None
|
|
|
1094
1095
|
skipped.append({"agent": name,
|
|
1095
1096
|
"reason": f"cooldown {_LAUNCH_COOLDOWN_S}s (recently launched)"})
|
|
1096
1097
|
continue
|
|
1098
|
+
# RELAUNCH RECONCILIATION (task 3ed8781d — the hostd respawn path has had this
|
|
1099
|
+
# since 14e0760c, but THIS path — every FE Start click — never did): a
|
|
1100
|
+
# stopped-but-surviving session (stale heartbeat, so DEDUP 1 doesn't see it)
|
|
1101
|
+
# would get a fresh session spawned ON TOP, splitting the inbox and stacking
|
|
1102
|
+
# wt tabs (Samuel's Windows box: up to 3 sessions/tabs per agent). Kill any
|
|
1103
|
+
# surviving session tree of this agent BEFORE opening the new one — strictly
|
|
1104
|
+
# pre-spawn, so the fresh session can never be in the kill set. hostd's
|
|
1105
|
+
# machinery (token discovery + tree kill + stop-marker so the old tab
|
|
1106
|
+
# self-closes) accepts bare names since task 3ed8781d. Best-effort.
|
|
1107
|
+
try:
|
|
1108
|
+
from meshcode import hostd as _hostd
|
|
1109
|
+
_rec = _hostd._reconcile_target(name)
|
|
1110
|
+
if _rec:
|
|
1111
|
+
reconciled[name] = _rec
|
|
1112
|
+
except Exception:
|
|
1113
|
+
pass
|
|
1097
1114
|
# PER-PLATFORM quoting (mesh-core FIX2): cmd.exe wants double-quotes, not POSIX shlex
|
|
1098
1115
|
# single-quotes (cmd.exe passes single-quotes through literally -> file-not-found).
|
|
1099
1116
|
if sys.platform == "win32":
|
|
@@ -1113,6 +1130,8 @@ def cmd_launch_batch(agent_names: Iterable[str], repo_path: Optional[str] = None
|
|
|
1113
1130
|
"agents": launched,
|
|
1114
1131
|
"skipped": skipped,
|
|
1115
1132
|
}
|
|
1133
|
+
if reconciled:
|
|
1134
|
+
out["reconciled"] = reconciled
|
|
1116
1135
|
print(json.dumps(out))
|
|
1117
1136
|
return 0 if not skipped else 1
|
|
1118
1137
|
|
|
@@ -34,13 +34,17 @@ class FakeNoSuchProcess(Exception):
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
class FakeProc:
|
|
37
|
-
def __init__(self, table, pid, ppid, cmdline, name="", env=None):
|
|
37
|
+
def __init__(self, table, pid, ppid, cmdline, name="", env=None, cwd=""):
|
|
38
38
|
self._table = table
|
|
39
39
|
self.pid = pid
|
|
40
40
|
self._ppid = ppid
|
|
41
41
|
self._cmdline = cmdline
|
|
42
42
|
self._name = name or (cmdline.split()[0] if cmdline else "")
|
|
43
43
|
self._env = env or {}
|
|
44
|
+
self._cwd = cwd
|
|
45
|
+
|
|
46
|
+
def cwd(self):
|
|
47
|
+
return self._cwd
|
|
44
48
|
|
|
45
49
|
# --- psutil.Process API surface used by hostd ---
|
|
46
50
|
def cmdline(self):
|
|
@@ -81,8 +85,8 @@ class FakePsutil:
|
|
|
81
85
|
self.live = set()
|
|
82
86
|
self.killed = [] # kill order, for pre-spawn ordering assertions
|
|
83
87
|
|
|
84
|
-
def add(self, pid, ppid, cmdline, name="", env=None):
|
|
85
|
-
self.procs[pid] = FakeProc(self, pid, ppid, cmdline, name=name, env=env)
|
|
88
|
+
def add(self, pid, ppid, cmdline, name="", env=None, cwd=""):
|
|
89
|
+
self.procs[pid] = FakeProc(self, pid, ppid, cmdline, name=name, env=env, cwd=cwd)
|
|
86
90
|
self.live.add(pid)
|
|
87
91
|
return self.procs[pid]
|
|
88
92
|
|
|
@@ -427,5 +431,139 @@ class DiscoveryTests(ZombieFixBase):
|
|
|
427
431
|
self.assertEqual(hostd._discover_agent_pids(TARGET), [qa["run"]])
|
|
428
432
|
|
|
429
433
|
|
|
434
|
+
class BareNameSpawnTests(ZombieFixBase):
|
|
435
|
+
"""task 3ed8781d — the Windows stop-por-RPC + relaunch zombie. protocol_handler's
|
|
436
|
+
cmd_launch_batch (every FE Start click) spawns `meshcode run "agent"` (BARE name);
|
|
437
|
+
the old per-site token (`run <project/agent>`) never matched it, so stop/reap/
|
|
438
|
+
reconcile found nothing -> sessions + wt tabs stacked up to 3 per agent."""
|
|
439
|
+
|
|
440
|
+
def add_bare_session(self, base, agent="qa", cwd=""):
|
|
441
|
+
"""Protocol-handler-shaped tree: cmd wrapper runs the launcher SCRIPT (no
|
|
442
|
+
token in its cmdline post-a4001d59) -> python `run "<agent>"` (bare)."""
|
|
443
|
+
self.ps.add(50, 1, "WindowsTerminal.exe", name="WindowsTerminal.exe")
|
|
444
|
+
self.ps.add(base, 50,
|
|
445
|
+
f'cmd /c C:\\u\\.meshcode\\launchers\\{agent}.cmd',
|
|
446
|
+
name="cmd.exe")
|
|
447
|
+
self.ps.add(base + 1, base, f'C:\\py\\python.exe -m meshcode run "{agent}"',
|
|
448
|
+
name="python.exe", cwd=cwd)
|
|
449
|
+
self.ps.add(base + 2, base + 1, "C:\\claude\\claude.exe --session x",
|
|
450
|
+
name="claude.exe")
|
|
451
|
+
return {"cmd": base, "run": base + 1, "claude": base + 2}
|
|
452
|
+
|
|
453
|
+
def test_qualified_target_discovers_bare_spawn(self):
|
|
454
|
+
"""LOAD-BEARING: hostd stop sweeps (target 'proj/agent') must find a
|
|
455
|
+
protocol-launched session whose cmdline is `run "agent"`."""
|
|
456
|
+
self.add_hostd()
|
|
457
|
+
s = self.add_bare_session(100)
|
|
458
|
+
self.assertEqual(hostd._discover_agent_pids(TARGET), [s["run"]])
|
|
459
|
+
|
|
460
|
+
def test_bare_target_discovers_qualified_spawn(self):
|
|
461
|
+
"""Reverse direction: the protocol-handler relaunch reconcile (bare 'qa')
|
|
462
|
+
must find a hostd-spawned session (`run "mesh-core/qa"`)."""
|
|
463
|
+
self.add_hostd()
|
|
464
|
+
qa = self.add_session(100)
|
|
465
|
+
self.assertEqual(hostd._discover_agent_pids("qa"), [qa["run"]])
|
|
466
|
+
|
|
467
|
+
def test_bare_forms_keep_prefix_safety(self):
|
|
468
|
+
"""'qa' must never match 'qa-2' in either form."""
|
|
469
|
+
self.add_hostd()
|
|
470
|
+
self.ps.add(601, 1, 'python.exe -m meshcode run "qa-2"', name="python.exe")
|
|
471
|
+
self.ps.add(602, 1, 'python.exe -m meshcode run "mesh-core/qa-2"', name="python.exe")
|
|
472
|
+
self.assertEqual(hostd._discover_agent_pids(TARGET), [])
|
|
473
|
+
self.assertEqual(hostd._discover_agent_pids("qa"), [])
|
|
474
|
+
|
|
475
|
+
def test_bare_match_foreign_project_cwd_is_skipped(self):
|
|
476
|
+
"""Cross-mesh guard: a bare-spawned 'qa' whose cwd is ANOTHER project's
|
|
477
|
+
workspace (~/meshcode/<other>-qa) is not killable via 'mesh-core/qa'."""
|
|
478
|
+
self.add_hostd()
|
|
479
|
+
self.add_bare_session(100, cwd="C:\\u\\meshcode\\other-mesh-qa")
|
|
480
|
+
self.assertEqual(hostd._discover_agent_pids(TARGET), [])
|
|
481
|
+
|
|
482
|
+
def test_bare_match_own_project_cwd_is_accepted(self):
|
|
483
|
+
self.add_hostd()
|
|
484
|
+
s = self.add_bare_session(100, cwd="C:\\u\\meshcode\\mesh-core-qa")
|
|
485
|
+
self.assertEqual(hostd._discover_agent_pids(TARGET), [s["run"]])
|
|
486
|
+
|
|
487
|
+
def test_stop_kills_bare_spawned_session_tree(self):
|
|
488
|
+
"""End-to-end: desired_state='stopped' takes down the protocol-launched
|
|
489
|
+
tree (python run + claude); the launcher cmd wrapper SURVIVES by design
|
|
490
|
+
(no token in its cmdline) to run the stopmark epilogue -> tab self-closes."""
|
|
491
|
+
self.add_hostd()
|
|
492
|
+
s = self.add_bare_session(100)
|
|
493
|
+
self.rpc.script("mc_agents_to_stop",
|
|
494
|
+
{"ok": True, "agents": [{"project_name": "mesh-core", "agent": "qa"}]})
|
|
495
|
+
n = hostd._do_stops("k", "h")
|
|
496
|
+
self.assertEqual(n, 1)
|
|
497
|
+
self.assertNotIn(s["run"], self.ps.live, "bare-spawned run survived the stop")
|
|
498
|
+
self.assertNotIn(s["claude"], self.ps.live, "bare-spawned claude survived the stop")
|
|
499
|
+
self.assertIn(50, self.ps.live, "terminal host was killed")
|
|
500
|
+
|
|
501
|
+
def test_serve_discovery_bare_target_matches_agent_env(self):
|
|
502
|
+
self.ps.add(200, 1, "python.exe -m meshcode.meshcode_mcp serve", name="python.exe",
|
|
503
|
+
env={"MESHCODE_PROJECT": "mesh-core", "MESHCODE_AGENT": "qa"})
|
|
504
|
+
self.assertEqual(hostd._discover_serve_pids("qa"), [200])
|
|
505
|
+
self.assertEqual(hostd._discover_serve_pids("backend"), [])
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class StopMarkerDualLabelTests(unittest.TestCase):
|
|
509
|
+
"""task 3ed8781d — _write_stop_marker must stamp BOTH the target-form label
|
|
510
|
+
(hostd spawns) and the bare-agent label (protocol-handler spawns): the launcher
|
|
511
|
+
script checks the label derived from ITS OWN spawn cmd, and a missing marker
|
|
512
|
+
flips an intentional stop into a `pause`-held dead wt tab."""
|
|
513
|
+
|
|
514
|
+
def test_writes_both_labels(self):
|
|
515
|
+
import tempfile
|
|
516
|
+
from pathlib import Path
|
|
517
|
+
with tempfile.TemporaryDirectory() as td:
|
|
518
|
+
with mock.patch.object(hostd, "STATE_DIR", Path(td)):
|
|
519
|
+
hostd._write_stop_marker("mesh-core/qa")
|
|
520
|
+
names = sorted(p.name for p in Path(td).glob("stopmark_*"))
|
|
521
|
+
self.assertEqual(names, ["stopmark_mesh-core_qa", "stopmark_qa"])
|
|
522
|
+
|
|
523
|
+
def test_bare_target_writes_single_label(self):
|
|
524
|
+
import tempfile
|
|
525
|
+
from pathlib import Path
|
|
526
|
+
with tempfile.TemporaryDirectory() as td:
|
|
527
|
+
with mock.patch.object(hostd, "STATE_DIR", Path(td)):
|
|
528
|
+
hostd._write_stop_marker("qa")
|
|
529
|
+
names = sorted(p.name for p in Path(td).glob("stopmark_*"))
|
|
530
|
+
self.assertEqual(names, ["stopmark_qa"])
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
class LaunchBatchReconcileTests(unittest.TestCase):
|
|
534
|
+
"""task 3ed8781d — cmd_launch_batch (every FE Start click / launch-url) must
|
|
535
|
+
reconcile surviving sessions pre-spawn, like the hostd respawn path has since
|
|
536
|
+
14e0760c."""
|
|
537
|
+
|
|
538
|
+
def _run(self, reconcile_side_effect):
|
|
539
|
+
from meshcode import protocol_handler as ph
|
|
540
|
+
import json as _json
|
|
541
|
+
printed = []
|
|
542
|
+
with mock.patch.object(ph, "live_agent_names", lambda names, project=None: set()), \
|
|
543
|
+
mock.patch.object(ph, "_read_cooldowns", lambda: {}), \
|
|
544
|
+
mock.patch.object(ph, "_record_spawn", lambda n, now=None: None), \
|
|
545
|
+
mock.patch.object(ph, "_spawn_terminal", lambda cmd: (True, "wt(fleet-tab)")), \
|
|
546
|
+
mock.patch.object(hostd, "_reconcile_target", reconcile_side_effect), \
|
|
547
|
+
mock.patch("builtins.print", lambda *a, **k: printed.append(a[0] if a else "")):
|
|
548
|
+
rc = ph.cmd_launch_batch(["qa"])
|
|
549
|
+
return rc, _json.loads(printed[-1])
|
|
550
|
+
|
|
551
|
+
def test_reconcile_called_before_spawn_and_reported(self):
|
|
552
|
+
calls = []
|
|
553
|
+
rc, out = self._run(lambda name: calls.append(name) or 2)
|
|
554
|
+
self.assertEqual(rc, 0)
|
|
555
|
+
self.assertEqual(calls, ["qa"])
|
|
556
|
+
self.assertEqual(out.get("reconciled"), {"qa": 2})
|
|
557
|
+
self.assertEqual(out["agents"], ["qa"])
|
|
558
|
+
|
|
559
|
+
def test_reconcile_failure_never_blocks_launch(self):
|
|
560
|
+
def boom(name):
|
|
561
|
+
raise RuntimeError("psutil exploded")
|
|
562
|
+
rc, out = self._run(boom)
|
|
563
|
+
self.assertEqual(rc, 0)
|
|
564
|
+
self.assertEqual(out["agents"], ["qa"])
|
|
565
|
+
self.assertNotIn("reconciled", out)
|
|
566
|
+
|
|
567
|
+
|
|
430
568
|
if __name__ == "__main__":
|
|
431
569
|
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
|
|
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
|