swarph-cli 0.13.1__tar.gz → 0.13.3__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.
- {swarph_cli-0.13.1/src/swarph_cli.egg-info → swarph_cli-0.13.3}/PKG-INFO +1 -1
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/pyproject.toml +1 -1
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/__init__.py +1 -1
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/chat.py +9 -5
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/spawn.py +57 -17
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/compress/levers.py +7 -1
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/compress/verify.py +6 -1
- {swarph_cli-0.13.1 → swarph_cli-0.13.3/src/swarph_cli.egg-info}/PKG-INFO +1 -1
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli.egg-info/SOURCES.txt +3 -0
- swarph_cli-0.13.3/tests/test_caller_meta_guard.py +117 -0
- swarph_cli-0.13.3/tests/test_compress_caller_convention.py +21 -0
- swarph_cli-0.13.3/tests/test_repl_caller_convention.py +43 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_spawn_command.py +7 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_spawn_tmux_session.py +51 -14
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/LICENSE +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/README.md +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/setup.cfg +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/__init__.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/harden.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/lineage.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/liveness.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/manifest.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/paths.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/verify.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/cell.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/add.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/cell.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/compress.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/hook_output.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/hooks.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/init.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/install_hook.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/mcp_server.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/memory_sync.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/mesh.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/protocol_handler.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/security.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/watchdog.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/compress/__init__.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/compress/marker.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/main.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli.egg-info/requires.txt +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_add_security_gate.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_add.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_hash.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_lib.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_mcp.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_skill.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_tool.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_uri.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_capture_lineage.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_capture_liveness.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_capture_manifest.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_capture_paths.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_capture_security.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_cell_command_dispatch.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_cell_harden.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_cell_loader.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_cell_verify.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_claude_tmux_template.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_compress_command.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_compress_levers.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_compress_marker.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_compress_verify.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_feature_to_uri.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hook_output.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hooks_add.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hooks_bundle.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hooks_init.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hooks_lifecycle.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hooks_merge.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_import_command.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_init_command.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_install_hook.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_main.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_mcp_server.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_memory_sync.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_mesh_command.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_mesh_sidecar.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_protocol_handler.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_security.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_session_path_role_gate.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_smoke_one_shot.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_smoke_phase_5_5.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_spawn_live_pin.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_spawn_mitosis_lineage.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_spawn_windows_relaunch.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_watchdog.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_watchdog_dm_wake.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_watchdog_dm_wake_wiring.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_watchdog_model_rung.py +0 -0
- {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_watchdog_stale_peers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swarph-cli
|
|
3
|
-
Version: 0.13.
|
|
3
|
+
Version: 0.13.3
|
|
4
4
|
Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider `swarph spawn` (claude/codex/antigravity per cell.provider via a ProviderMembrane + subprocess billing-scrub), interactive `swarph init`, `swarph mesh` (send/inbox/register) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.5: foolproof Windows auto-relaunch (genuine-WT detection via process ancestry, not WT_SESSION).
|
|
5
5
|
Author: Pierre Samson, Claude Opus
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "swarph-cli"
|
|
7
|
-
version = "0.13.
|
|
7
|
+
version = "0.13.3"
|
|
8
8
|
description = "The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider `swarph spawn` (claude/codex/antigravity per cell.provider via a ProviderMembrane + subprocess billing-scrub), interactive `swarph init`, `swarph mesh` (send/inbox/register) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.5: foolproof Windows auto-relaunch (genuine-WT detection via process ancestry, not WT_SESSION)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -35,7 +35,7 @@ import sys
|
|
|
35
35
|
from dataclasses import dataclass, field
|
|
36
36
|
from typing import Optional
|
|
37
37
|
|
|
38
|
-
from swarph_cli.caller import default_caller
|
|
38
|
+
from swarph_cli.caller import _sanitize_username, default_caller
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
_BANNER = """\
|
|
@@ -123,13 +123,17 @@ def _default_repl_caller() -> str:
|
|
|
123
123
|
"""``cli.repl.<user>`` — distinct from ``cli.oneshot.<user>`` so
|
|
124
124
|
attribution rows distinguish REPL turns from one-shot calls.
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
Reuses ``caller._sanitize_username`` (the SAME sanitizer ``default_caller``
|
|
127
|
+
uses) so the slug is guaranteed convention-valid: leading letter (``u_``
|
|
128
|
+
prefix if not), collapsed underscores, ``unknown`` on empty. The previous
|
|
129
|
+
inline slug omitted those guards, so a leading-digit username produced a
|
|
130
|
+
non-conforming ``cli.repl.3bob`` that crashed SwarphCall at runtime (#83).
|
|
131
|
+
Falls back to the caller-convention default if env-derived user extraction
|
|
132
|
+
fails."""
|
|
128
133
|
user = os.environ.get("USER") or os.environ.get("LOGNAME")
|
|
129
134
|
if not user:
|
|
130
135
|
return default_caller()
|
|
131
|
-
|
|
132
|
-
return f"cli.repl.{user_slug}"
|
|
136
|
+
return f"cli.repl.{_sanitize_username(user)}"
|
|
133
137
|
|
|
134
138
|
|
|
135
139
|
def _print(msg: str = "", *, file=None) -> None:
|
|
@@ -28,6 +28,7 @@ import os
|
|
|
28
28
|
import shutil
|
|
29
29
|
import subprocess
|
|
30
30
|
import sys
|
|
31
|
+
import time
|
|
31
32
|
from pathlib import Path
|
|
32
33
|
from typing import Optional
|
|
33
34
|
from urllib.parse import quote
|
|
@@ -914,6 +915,54 @@ def _tmux_has_session(tmux: str, name: str) -> bool:
|
|
|
914
915
|
return result.returncode == 0
|
|
915
916
|
|
|
916
917
|
|
|
918
|
+
def _tmux_create_session(tmux: str, name: str, cwd: Path) -> bool:
|
|
919
|
+
"""Create the durable detached session that runs ``swarph spawn <name>``.
|
|
920
|
+
|
|
921
|
+
Robust against a STALE session registration. When a multiplexer server dies
|
|
922
|
+
uncleanly (operator closed the owning console, ``kill``, a crash) it can leave
|
|
923
|
+
the session NAME registered with no live server behind it. On the native-Windows
|
|
924
|
+
psmux build (``marlocarlo.psmux``) this poisons re-creation: ``new-session``
|
|
925
|
+
becomes a SILENT no-op — it returns rc=0 but creates nothing — so the name can
|
|
926
|
+
never come back. That is the exact failure that strands a peer's cell ("the
|
|
927
|
+
session got killed and won't return"; verified on workstation-lc 2026-06-18).
|
|
928
|
+
Real tmux on Linux/mac does not hit this, and the extra steps are a harmless
|
|
929
|
+
no-op there (the create succeeds first try and the loop breaks immediately).
|
|
930
|
+
|
|
931
|
+
So: clear any stale registration first (``kill-session`` — a no-op if the name
|
|
932
|
+
is truly absent), then create. Because psmux clears the stale lock
|
|
933
|
+
ASYNCHRONOUSLY (~1s observed), do NOT trust the create's exit code (rc=0 even on
|
|
934
|
+
the silent no-op): VERIFY with ``has-session`` and retry until it actually
|
|
935
|
+
materialises. Returns True once the session exists, False if it never appears.
|
|
936
|
+
"""
|
|
937
|
+
create_cmd = [
|
|
938
|
+
tmux, "new-session", "-d", "-s", name,
|
|
939
|
+
"-c", str(cwd), "-e", "SWARPH_SPAWN=1",
|
|
940
|
+
"swarph", "spawn", name,
|
|
941
|
+
]
|
|
942
|
+
# Clear a stale (server-less) registration; harmless if the name is truly absent.
|
|
943
|
+
try:
|
|
944
|
+
subprocess.run(
|
|
945
|
+
[tmux, "kill-session", "-t", f"={name}"],
|
|
946
|
+
stdout=subprocess.DEVNULL,
|
|
947
|
+
stderr=subprocess.DEVNULL,
|
|
948
|
+
)
|
|
949
|
+
except OSError:
|
|
950
|
+
return False
|
|
951
|
+
for attempt in range(6):
|
|
952
|
+
try:
|
|
953
|
+
subprocess.run(
|
|
954
|
+
create_cmd,
|
|
955
|
+
stdout=subprocess.DEVNULL,
|
|
956
|
+
stderr=subprocess.PIPE,
|
|
957
|
+
)
|
|
958
|
+
except OSError:
|
|
959
|
+
return False
|
|
960
|
+
if _tmux_has_session(tmux, name):
|
|
961
|
+
return True
|
|
962
|
+
time.sleep(0.4) # let psmux's async stale-lock clear catch up, then retry
|
|
963
|
+
return False
|
|
964
|
+
|
|
965
|
+
|
|
917
966
|
def _launch_via_tmux(
|
|
918
967
|
binary: str, argv: list[str], cwd: Path, session_name: str,
|
|
919
968
|
) -> bool:
|
|
@@ -978,24 +1027,15 @@ def _launch_via_tmux(
|
|
|
978
1027
|
interactive = sys.stdout.isatty()
|
|
979
1028
|
|
|
980
1029
|
if not _tmux_has_session(tmux, session_name):
|
|
981
|
-
# Create the durable session detached. The command re-enters spawn so
|
|
982
|
-
#
|
|
983
|
-
#
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
tmux, "new-session", "-d", "-s", session_name,
|
|
988
|
-
"-c", str(cwd), "-e", "SWARPH_SPAWN=1",
|
|
989
|
-
"swarph", "spawn", session_name,
|
|
990
|
-
],
|
|
991
|
-
check=True,
|
|
992
|
-
stdout=subprocess.DEVNULL,
|
|
993
|
-
stderr=subprocess.PIPE,
|
|
994
|
-
)
|
|
995
|
-
except (OSError, subprocess.CalledProcessError) as exc:
|
|
1030
|
+
# Create the durable session detached. The command re-enters spawn so the
|
|
1031
|
+
# membrane env-scrub + starter injection apply inside the pane; the inherited
|
|
1032
|
+
# $TMUX (and SWARPH_SPAWN) stop it from re-deciding. _tmux_create_session
|
|
1033
|
+
# also clears a stale (server-less) registration that would otherwise make
|
|
1034
|
+
# psmux's new-session a silent no-op and strand the cell.
|
|
1035
|
+
if not _tmux_create_session(tmux, session_name, cwd):
|
|
996
1036
|
print(
|
|
997
|
-
|
|
998
|
-
"back to the standard launch.",
|
|
1037
|
+
"swarph spawn: tmux session create failed (stale registration "
|
|
1038
|
+
"could not be cleared?); falling back to the standard launch.",
|
|
999
1039
|
file=sys.stderr,
|
|
1000
1040
|
)
|
|
1001
1041
|
return False
|
|
@@ -4,6 +4,12 @@ from __future__ import annotations
|
|
|
4
4
|
import re
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
|
|
7
|
+
# Caller tag for the shorthand model call. MUST satisfy swarph_shared's
|
|
8
|
+
# caller convention (^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$ — dotted, not
|
|
9
|
+
# hyphenated), or SwarphCall(...) raises ValueError at runtime. See
|
|
10
|
+
# tests/test_compress_caller_convention.py.
|
|
11
|
+
SHORTHAND_CALLER = "swarph.compress"
|
|
12
|
+
|
|
7
13
|
|
|
8
14
|
@dataclass
|
|
9
15
|
class ArchivalResult:
|
|
@@ -41,7 +47,7 @@ async def shorthand(source: str, *, system_prompt: str = SHORTHAND_SYSTEM, chat=
|
|
|
41
47
|
(messages, system_prompt, **kw)->resp with .text; defaults to swarph_mesh."""
|
|
42
48
|
if chat is None:
|
|
43
49
|
from swarph_mesh import ChatMessage, SwarphCall
|
|
44
|
-
sc = SwarphCall(provider="claude", caller=
|
|
50
|
+
sc = SwarphCall(provider="claude", caller=SHORTHAND_CALLER)
|
|
45
51
|
async def chat(messages, system_prompt=None, **kw):
|
|
46
52
|
return await sc.chat(messages=messages, system_prompt=system_prompt, **kw)
|
|
47
53
|
msgs = [ChatMessage(role="user", content=source)]
|
|
@@ -4,6 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
import gzip, re
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
# Caller tag for the adversarial verify-expand model call. MUST satisfy
|
|
8
|
+
# swarph_shared's dotted caller convention or SwarphCall raises at runtime.
|
|
9
|
+
# See tests/test_compress_caller_convention.py.
|
|
10
|
+
VERIFY_EXPAND_CALLER = "swarph.compress.verify"
|
|
11
|
+
|
|
7
12
|
_LINK_RE = re.compile(r"\]\(([^)]+)\)")
|
|
8
13
|
|
|
9
14
|
|
|
@@ -67,7 +72,7 @@ async def verify_expand(source: str, compressed: str, *, chat=None) -> bool:
|
|
|
67
72
|
fact in source not recoverable from compressed. One dropped fact -> False."""
|
|
68
73
|
if chat is None:
|
|
69
74
|
from swarph_mesh import ChatMessage, SwarphCall
|
|
70
|
-
sc = SwarphCall(provider="claude", caller=
|
|
75
|
+
sc = SwarphCall(provider="claude", caller=VERIFY_EXPAND_CALLER)
|
|
71
76
|
async def chat(messages, system_prompt=None, **kw):
|
|
72
77
|
return await sc.chat(messages=messages, system_prompt=system_prompt, **kw)
|
|
73
78
|
msgs = [ChatMessage(role="user", content=f"ORIGINAL:\n{source}\n\nCOMPRESSED:\n{compressed}")]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swarph-cli
|
|
3
|
-
Version: 0.13.
|
|
3
|
+
Version: 0.13.3
|
|
4
4
|
Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider `swarph spawn` (claude/codex/antigravity per cell.provider via a ProviderMembrane + subprocess billing-scrub), interactive `swarph init`, `swarph mesh` (send/inbox/register) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.5: foolproof Windows auto-relaunch (genuine-WT detection via process ancestry, not WT_SESSION).
|
|
5
5
|
Author: Pierre Samson, Claude Opus
|
|
6
6
|
License: MIT
|
|
@@ -55,6 +55,7 @@ tests/test_artifact_mcp.py
|
|
|
55
55
|
tests/test_artifact_skill.py
|
|
56
56
|
tests/test_artifact_tool.py
|
|
57
57
|
tests/test_artifact_uri.py
|
|
58
|
+
tests/test_caller_meta_guard.py
|
|
58
59
|
tests/test_capture_lineage.py
|
|
59
60
|
tests/test_capture_liveness.py
|
|
60
61
|
tests/test_capture_manifest.py
|
|
@@ -67,6 +68,7 @@ tests/test_cell_verify.py
|
|
|
67
68
|
tests/test_chat_command.py
|
|
68
69
|
tests/test_claude_parser.py
|
|
69
70
|
tests/test_claude_tmux_template.py
|
|
71
|
+
tests/test_compress_caller_convention.py
|
|
70
72
|
tests/test_compress_command.py
|
|
71
73
|
tests/test_compress_levers.py
|
|
72
74
|
tests/test_compress_marker.py
|
|
@@ -90,6 +92,7 @@ tests/test_mesh_sidecar.py
|
|
|
90
92
|
tests/test_onboard_command.py
|
|
91
93
|
tests/test_protocol_handler.py
|
|
92
94
|
tests/test_ratify_command.py
|
|
95
|
+
tests/test_repl_caller_convention.py
|
|
93
96
|
tests/test_security.py
|
|
94
97
|
tests/test_session_path_role_gate.py
|
|
95
98
|
tests/test_smoke_chat.py
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Repo-wide caller-convention meta-guard (issue #81).
|
|
2
|
+
|
|
3
|
+
The #80 bug: a hyphenated caller tag (`swarph-compress`) was silently dead at
|
|
4
|
+
runtime because it fails swarph_shared's dotted `role.subrole` convention — and it
|
|
5
|
+
was masked because the levers/verify tests injected a fake chat and never built a
|
|
6
|
+
real SwarphCall. That fix added a per-caller regression test for the two compress
|
|
7
|
+
callers. THIS guard generalizes it: it discovers EVERY static caller tag across the
|
|
8
|
+
whole `swarph_cli` package by import-introspection and validates each, so the bug
|
|
9
|
+
class can't be re-introduced in ANY module without someone remembering to add a
|
|
10
|
+
per-caller test.
|
|
11
|
+
|
|
12
|
+
Design (converged lab+droplet, #81): import-introspection, not AST literal-walking.
|
|
13
|
+
The callers live in named `*_CALLER` constants — `caller=SHORTHAND_CALLER` is an
|
|
14
|
+
ast.Name, not a literal, so a naive literal-walk would miss exactly the callers #80
|
|
15
|
+
hardened. Introspection resolves the value for free and nudges the light convention
|
|
16
|
+
that caller tags live in discoverable `*_CALLER` constants.
|
|
17
|
+
|
|
18
|
+
NOT covered here (separate follow-up, flagged in the PR): the DYNAMIC default-caller
|
|
19
|
+
producers (`default_caller` / `_default_repl_caller`) do not guarantee an
|
|
20
|
+
[a-z]-leading slug, so a leading-digit/empty OS username yields a non-conforming
|
|
21
|
+
`cli.repl.<digit...>` tag — a real robustness bug in the sanitizers, out of scope
|
|
22
|
+
for this static meta-guard.
|
|
23
|
+
"""
|
|
24
|
+
import importlib
|
|
25
|
+
import pkgutil
|
|
26
|
+
|
|
27
|
+
import swarph_cli
|
|
28
|
+
from swarph_shared.caller_convention import validate_caller
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _iter_caller_constants():
|
|
32
|
+
"""Yield (module, attr, value) for every module-level ``*_CALLER`` str constant
|
|
33
|
+
across the swarph_cli package. Walks the package so a NEW module's caller
|
|
34
|
+
constant is covered automatically. A submodule that can't be imported in this
|
|
35
|
+
env (e.g. an optional dep) is skipped — importability is other tests' concern,
|
|
36
|
+
not this guard's."""
|
|
37
|
+
for mod in pkgutil.walk_packages(swarph_cli.__path__, swarph_cli.__name__ + "."):
|
|
38
|
+
try:
|
|
39
|
+
m = importlib.import_module(mod.name)
|
|
40
|
+
except Exception:
|
|
41
|
+
continue
|
|
42
|
+
for attr in dir(m):
|
|
43
|
+
if attr.endswith("_CALLER"):
|
|
44
|
+
val = getattr(m, attr, None)
|
|
45
|
+
if isinstance(val, str):
|
|
46
|
+
yield mod.name, attr, val
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_all_caller_constants_conform():
|
|
50
|
+
"""Every ``*_CALLER`` constant in the package satisfies the dotted convention."""
|
|
51
|
+
found = list(_iter_caller_constants())
|
|
52
|
+
# Sanity: the compress callers from #80 must be discovered, else the walk is broken
|
|
53
|
+
# (e.g. swarph_cli resolving to a stale install rather than this tree).
|
|
54
|
+
names = {f"{m.rsplit('.', 1)[-1]}.{a}" for m, a, _ in found}
|
|
55
|
+
assert "levers.SHORTHAND_CALLER" in names and "verify.VERIFY_EXPAND_CALLER" in names, (
|
|
56
|
+
f"meta-guard didn't discover the known compress callers; found: {sorted(names)}")
|
|
57
|
+
for modname, attr, val in found:
|
|
58
|
+
try:
|
|
59
|
+
validate_caller(val)
|
|
60
|
+
except ValueError as e:
|
|
61
|
+
raise AssertionError(
|
|
62
|
+
f"caller constant {modname}.{attr} = {val!r} violates the convention "
|
|
63
|
+
f"(dotted role.subrole) — would crash SwarphCall at runtime: {e}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_default_caller_producers_conform_happy_path(monkeypatch):
|
|
67
|
+
"""The default-caller producers emit a conforming tag for a normal username.
|
|
68
|
+
Username is injected so this is deterministic regardless of the CI runner's
|
|
69
|
+
real user (the adversarial leading-digit case is a flagged follow-up, not here)."""
|
|
70
|
+
monkeypatch.setenv("USER", "alice")
|
|
71
|
+
monkeypatch.setenv("LOGNAME", "alice")
|
|
72
|
+
from swarph_cli.main import default_caller
|
|
73
|
+
from swarph_cli.commands.chat import _default_repl_caller
|
|
74
|
+
for fn in (default_caller, _default_repl_caller):
|
|
75
|
+
val = fn()
|
|
76
|
+
try:
|
|
77
|
+
validate_caller(val)
|
|
78
|
+
except ValueError as e:
|
|
79
|
+
raise AssertionError(
|
|
80
|
+
f"{fn.__module__}.{fn.__name__}() -> {val!r} violates the convention: {e}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_guard_catches_a_planted_bad_caller(monkeypatch):
|
|
84
|
+
"""PIN the catch-property — a guard's catch must be COMMITTED, not remembered
|
|
85
|
+
(the very #80 lesson this PR generalizes; droplet review). Plant a hyphenated
|
|
86
|
+
`*_CALLER` on a real, discovered module and assert the guard both DISCOVERS it
|
|
87
|
+
and REJECTS it. monkeypatch auto-removes the plant so the real guard test stays
|
|
88
|
+
clean regardless of order."""
|
|
89
|
+
import pytest
|
|
90
|
+
|
|
91
|
+
import swarph_cli.compress.levers as lev
|
|
92
|
+
monkeypatch.setattr(lev, "PLANTED_BAD_CALLER", "swarph-bad-hyphen", raising=False)
|
|
93
|
+
|
|
94
|
+
# 1) introspection DISCOVERS the planted constant
|
|
95
|
+
found = {f"{m}.{a}": v for m, a, v in _iter_caller_constants()}
|
|
96
|
+
assert found.get("swarph_cli.compress.levers.PLANTED_BAD_CALLER") == "swarph-bad-hyphen", (
|
|
97
|
+
"the walk failed to discover a planted *_CALLER — discovery is broken")
|
|
98
|
+
|
|
99
|
+
# 2) the guard CATCHES it (fails loud, names the offending constant)
|
|
100
|
+
with pytest.raises(AssertionError, match="violates the convention"):
|
|
101
|
+
test_all_caller_constants_conform()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_no_modules_skipped_by_the_walk():
|
|
105
|
+
"""The guard resiliently SKIPS an unimportable submodule (so an unrelated optional-
|
|
106
|
+
dep import error can't break it) — but a silent skip could hide a bad caller in a
|
|
107
|
+
module that's importable in prod yet not in this env. Assert NONE are skipped here,
|
|
108
|
+
so the walk is exhaustive; if this ever fails in CI, run with full extras."""
|
|
109
|
+
skipped = []
|
|
110
|
+
for mod in pkgutil.walk_packages(swarph_cli.__path__, swarph_cli.__name__ + "."):
|
|
111
|
+
try:
|
|
112
|
+
importlib.import_module(mod.name)
|
|
113
|
+
except Exception as e: # noqa: BLE001 — we want to surface ANY import failure
|
|
114
|
+
skipped.append((mod.name, repr(e)))
|
|
115
|
+
assert not skipped, (
|
|
116
|
+
f"caller meta-guard walk skipped modules (a bad caller in one could ship "
|
|
117
|
+
f"uncaught): {skipped}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Regression: the compress model-call caller tags MUST satisfy swarph_shared's
|
|
2
|
+
caller convention (dotted `role.subrole`, not hyphenated). The existing
|
|
3
|
+
levers/verify tests inject a fake `chat` and never construct a real SwarphCall,
|
|
4
|
+
so they passed while the production path crashed at SwarphCall(...) on a
|
|
5
|
+
hyphenated caller ('swarph-compress'). This test checks the tags directly so
|
|
6
|
+
the break can't silently regress again."""
|
|
7
|
+
from swarph_shared.caller_convention import validate_caller
|
|
8
|
+
|
|
9
|
+
from swarph_cli.compress.levers import SHORTHAND_CALLER
|
|
10
|
+
from swarph_cli.compress.verify import VERIFY_EXPAND_CALLER
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_shorthand_caller_matches_convention():
|
|
14
|
+
# raises ValueError if it doesn't match ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$
|
|
15
|
+
validate_caller(SHORTHAND_CALLER)
|
|
16
|
+
assert SHORTHAND_CALLER == "swarph.compress"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_verify_expand_caller_matches_convention():
|
|
20
|
+
validate_caller(VERIFY_EXPAND_CALLER)
|
|
21
|
+
assert VERIFY_EXPAND_CALLER == "swarph.compress.verify"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Regression (#83): `_default_repl_caller` must emit a convention-valid caller for
|
|
2
|
+
ANY OS username — including the adversarial cases the old inline slug logic broke on.
|
|
3
|
+
|
|
4
|
+
#80 fixed a hyphenated STATIC caller constant. This is the same crash-class on the
|
|
5
|
+
DYNAMIC repl-default path: `_default_repl_caller` rolled its own slug
|
|
6
|
+
(`"".join(c if c.isalnum() else "_" ...)`) that — unlike `caller._sanitize_username`
|
|
7
|
+
which `default_caller` uses — did NOT guarantee a leading letter, collapse underscores,
|
|
8
|
+
or fall back on empty. So a leading-digit username produced `cli.repl.3bob`, which
|
|
9
|
+
fails `^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$` and would crash SwarphCall at runtime.
|
|
10
|
+
Fix: reuse `_sanitize_username` (single sanitizer, no divergence).
|
|
11
|
+
"""
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from swarph_shared.caller_convention import validate_caller
|
|
15
|
+
|
|
16
|
+
from swarph_cli.commands.chat import _default_repl_caller
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.parametrize("username", [
|
|
20
|
+
"3bob", # leading digit — the reported crash
|
|
21
|
+
"9", # all-digit
|
|
22
|
+
"!!!", # all-special -> empty slug -> must fall back, not emit `cli.repl.`
|
|
23
|
+
"ALICE", # uppercase
|
|
24
|
+
"bob.smith", # dot (would otherwise split the segment)
|
|
25
|
+
"a-b-c", # hyphens (the #80 char)
|
|
26
|
+
"_", # underscore-only -> empty after strip
|
|
27
|
+
" ", # whitespace-only
|
|
28
|
+
])
|
|
29
|
+
def test_repl_caller_conforms_for_adversarial_usernames(monkeypatch, username):
|
|
30
|
+
"""The repl default caller validates clean for every adversarial username."""
|
|
31
|
+
monkeypatch.setenv("USER", username)
|
|
32
|
+
monkeypatch.delenv("LOGNAME", raising=False)
|
|
33
|
+
# raises ValueError if the produced tag violates the convention
|
|
34
|
+
validate_caller(_default_repl_caller())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_repl_caller_stays_distinct_from_oneshot(monkeypatch):
|
|
38
|
+
"""Still `cli.repl.*` (not `cli.oneshot.*`) — the separate producer exists so
|
|
39
|
+
attribution distinguishes REPL turns from one-shot calls; the fix must not
|
|
40
|
+
collapse them."""
|
|
41
|
+
monkeypatch.setenv("USER", "alice")
|
|
42
|
+
monkeypatch.delenv("LOGNAME", raising=False)
|
|
43
|
+
assert _default_repl_caller().startswith("cli.repl.")
|
|
@@ -1323,6 +1323,13 @@ def test_run_spawn_grok_assisted_memory_no_double_system_prompt(
|
|
|
1323
1323
|
monkeypatch.setattr("shutil.which", lambda name: "/bin/fake-grok")
|
|
1324
1324
|
# neutralize the named-tmux launch so we reach launch()/execve in-test
|
|
1325
1325
|
monkeypatch.setattr(spawn, "_launch_via_tmux", lambda *a, **k: False)
|
|
1326
|
+
# This asserts on the composed argv (no doubled --system-prompt-override),
|
|
1327
|
+
# which is platform-independent. Pin to a POSIX platform so launch() takes the
|
|
1328
|
+
# os.execve branch we mock below — otherwise on Windows it takes the
|
|
1329
|
+
# subprocess.run branch (v0.12.1 pane-collapse fix), the execve mock never
|
|
1330
|
+
# fires, and the real /bin/fake-grok exec fails (WinError 2). Matches the
|
|
1331
|
+
# platform-pinning convention of the launch tests above.
|
|
1332
|
+
monkeypatch.setattr(spawn.sys, "platform", "linux")
|
|
1326
1333
|
|
|
1327
1334
|
exec_args = []
|
|
1328
1335
|
monkeypatch.setattr("os.execve", lambda p, a, e: exec_args.append((p, a, e)))
|
|
@@ -59,19 +59,27 @@ class _FakeStdout:
|
|
|
59
59
|
|
|
60
60
|
def _drive(monkeypatch, *, platform="win32", tmux=TMUX, wt=WT, in_tmux=None,
|
|
61
61
|
spawn_marker=None, isatty=True, session_exists=False,
|
|
62
|
-
new_session_exc=None):
|
|
62
|
+
new_session_exc=None, create_succeeds=True, create_succeeds_after=1):
|
|
63
63
|
"""Drive `_launch_via_tmux` with tmux/subprocess/execv mocked.
|
|
64
64
|
|
|
65
|
-
`run` dispatches on the subcommand
|
|
66
|
-
|
|
67
|
-
`
|
|
68
|
-
|
|
65
|
+
`run` dispatches on the subcommand and is STATEFUL to model reality after the
|
|
66
|
+
stale-registration fix: `has-session` returns rc 0 once the session exists —
|
|
67
|
+
either pre-existing (`session_exists`) OR after a `new-session` "takes".
|
|
68
|
+
`_tmux_create_session` clears a stale reg (`kill-session`, rc 0 here) then
|
|
69
|
+
creates + VERIFIES via `has-session`, retrying because psmux clears the stale
|
|
70
|
+
lock asynchronously. `create_succeeds_after` models that delay: the session
|
|
71
|
+
only materialises on the Nth `new-session` call (1 = first try, the happy
|
|
72
|
+
path). `create_succeeds=False` models a create that never materialises (e.g. a
|
|
73
|
+
stale lock that won't clear) → the loop exhausts and returns False.
|
|
74
|
+
`new-session` raises `new_session_exc` when set. `attach` (Windows) returns rc
|
|
75
|
+
0; POSIX attach is os.execv, mocked to raise `_ExecvReplaced`.
|
|
69
76
|
|
|
70
77
|
Returns `(result, run, execv)`. `result` is the function's bool, or the
|
|
71
78
|
`_EXECV` sentinel when the POSIX execv attach took over.
|
|
72
79
|
"""
|
|
73
80
|
monkeypatch.setattr(spawn.sys, "platform", platform)
|
|
74
81
|
monkeypatch.setattr(spawn.sys, "stdout", _FakeStdout(isatty))
|
|
82
|
+
monkeypatch.setattr(spawn.time, "sleep", lambda *_a, **_k: None) # no real wait
|
|
75
83
|
for var, val in (("TMUX", in_tmux), ("SWARPH_SPAWN", spawn_marker)):
|
|
76
84
|
if val is None:
|
|
77
85
|
monkeypatch.delenv(var, raising=False)
|
|
@@ -83,12 +91,20 @@ def _drive(monkeypatch, *, platform="win32", tmux=TMUX, wt=WT, in_tmux=None,
|
|
|
83
91
|
|
|
84
92
|
monkeypatch.setattr(spawn.shutil, "which", _which)
|
|
85
93
|
|
|
94
|
+
state = {"new_calls": 0, "created": False}
|
|
95
|
+
|
|
86
96
|
def _run(cmd, **kwargs):
|
|
87
97
|
if "has-session" in cmd:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
exists = session_exists or state["created"]
|
|
99
|
+
return MagicMock(returncode=0 if exists else 1)
|
|
100
|
+
if "new-session" in cmd:
|
|
101
|
+
if new_session_exc is not None:
|
|
102
|
+
raise new_session_exc
|
|
103
|
+
state["new_calls"] += 1
|
|
104
|
+
if create_succeeds and state["new_calls"] >= create_succeeds_after:
|
|
105
|
+
state["created"] = True
|
|
106
|
+
return MagicMock(returncode=0)
|
|
107
|
+
return MagicMock(returncode=0) # kill-session, attach, ...
|
|
92
108
|
|
|
93
109
|
run = MagicMock(side_effect=_run)
|
|
94
110
|
monkeypatch.setattr(spawn.subprocess, "run", run)
|
|
@@ -108,6 +124,11 @@ def _cmds(run):
|
|
|
108
124
|
return [c.args[0] for c in run.call_args_list]
|
|
109
125
|
|
|
110
126
|
|
|
127
|
+
def _killed_stale(run):
|
|
128
|
+
"""True if the stale-registration clear (`kill-session`) was issued."""
|
|
129
|
+
return any("kill-session" in c for c in _cmds(run))
|
|
130
|
+
|
|
131
|
+
|
|
111
132
|
def _attached_via_run(run):
|
|
112
133
|
"""True if the Windows blocking-subprocess.run attach fired."""
|
|
113
134
|
return [TMUX, "attach", "-t", SESSION] in _cmds(run)
|
|
@@ -239,17 +260,33 @@ def test_existing_session_headless_is_a_noop_handoff(monkeypatch, platform):
|
|
|
239
260
|
|
|
240
261
|
|
|
241
262
|
@pytest.mark.parametrize("platform", ["win32", "linux"])
|
|
242
|
-
def
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
|
|
263
|
+
def test_create_never_materialises_falls_through(monkeypatch, platform):
|
|
264
|
+
# The session never appears (e.g. a psmux stale registration that won't clear:
|
|
265
|
+
# new-session returns rc 0 but creates nothing). The verify+retry loop exhausts
|
|
266
|
+
# => return False so the caller drops to the standard launch; never attach a
|
|
267
|
+
# phantom session. We also issued the stale-clear kill-session first.
|
|
246
268
|
r, run, execv = _drive(monkeypatch, platform=platform,
|
|
247
|
-
session_exists=False,
|
|
269
|
+
session_exists=False, create_succeeds=False)
|
|
248
270
|
assert r is False
|
|
271
|
+
assert _killed_stale(run)
|
|
249
272
|
assert not _attached_via_run(run)
|
|
250
273
|
execv.assert_not_called()
|
|
251
274
|
|
|
252
275
|
|
|
276
|
+
@pytest.mark.parametrize("platform", ["win32", "linux", "darwin"])
|
|
277
|
+
def test_stale_registration_cleared_then_create_retried(monkeypatch, platform):
|
|
278
|
+
# The poisoned-name recovery path: kill-session clears the stale reg, but psmux
|
|
279
|
+
# clears the lock async so the FIRST new-session is a silent no-op; the session
|
|
280
|
+
# only materialises on the 2nd attempt. The verify loop must retry and succeed.
|
|
281
|
+
r, run, execv = _drive(monkeypatch, platform=platform, session_exists=False,
|
|
282
|
+
isatty=False, create_succeeds_after=2)
|
|
283
|
+
assert r is True # headless: created, claimed launch
|
|
284
|
+
assert _killed_stale(run) # stale reg cleared first
|
|
285
|
+
new_calls = [c for c in _cmds(run) if "new-session" in c]
|
|
286
|
+
assert len(new_calls) >= 2 # retried past the silent no-op
|
|
287
|
+
execv.assert_not_called()
|
|
288
|
+
|
|
289
|
+
|
|
253
290
|
@pytest.mark.parametrize("platform", ["win32", "linux"])
|
|
254
291
|
def test_create_oserror_falls_through(monkeypatch, platform):
|
|
255
292
|
r, run, execv = _drive(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|