test(issue-28): make the host-dependent tests portable; POSIX ps in the process scanners

The 33 failures on the Linux / Python 3.14 host (#28) were environment
dependencies, not regressions; each test is worth keeping, so fix the
dependency instead of deleting the test:

- test_workspace_scope: `source` under `shell=True` ran in dash; run the
  command string under bash.
- conftest autouse: MAM_ENV_FILE points at an absent file for every test,
  so a workspace .mam.env with a reachable broker no longer breaks the
  "broker unavailable" tests (T1-4).
- help-capture guard: compare normalized help text (whitespace collapsed,
  "optional arguments"/"options" unified) so argparse re-wrapping across
  Python versions is not drift.
- deploy freshness D-11..D-19 and D-22..D-30: skip with a reason when the
  nats-docker submodule is not initialized.
- lib_py.agents.base.conversation_in_use and heartbeat._process_table:
  `ps -eo pid=,ppid=,args=` (POSIX) instead of the BSD `-axo ... command=`
  form, the most likely cause of the 12 purge failures on Linux (the holder
  scan returned nothing); to be confirmed on that host.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-20 19:15:50 +09:00
1 parent 46ea4cc1e4
commit 7a94ba2c3e
6 files changed
+48 -4

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ def conversation_in_use(uuid: str, paths=()) -> bool:
if os.environ.get(var, "").strip() == uuid: if os.environ.get(var, "").strip() == uuid:
return True return True
try: try:
out = subprocess.run(["ps", "-axo", "pid=,ppid=,command="], out = subprocess.run(["ps", "-eo", "pid=,ppid=,args="],
capture_output=True, text=True, timeout=10).stdout capture_output=True, text=True, timeout=10).stdout
except Exception: except Exception:
out = "" out = ""
@@ -83,7 +83,7 @@ def _is_agent_cli(command: str) -> bool:
def _process_table(): def _process_table():
"""{pid: (ppid, command)} from ps; empty when ps is unavailable.""" """{pid: (ppid, command)} from ps; empty when ps is unavailable."""
try: try:
out = subprocess.run(["ps", "-axo", "pid=,ppid=,command="], capture_output=True, text=True).stdout out = subprocess.run(["ps", "-eo", "pid=,ppid=,args="], capture_output=True, text=True).stdout
except OSError: except OSError:
return {} return {}
table = {} table = {}
+5
View File
@@ -170,6 +170,11 @@ def _isolate_mam_state_dirs(_ambient_control_plane, tmp_path, monkeypatch):
state = tmp_path / ".mam-test-state" state = tmp_path / ".mam-test-state"
monkeypatch.setenv("MAM_JOBS_DIR", str(state / "jobs")) monkeypatch.setenv("MAM_JOBS_DIR", str(state / "jobs"))
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(state / "delegate_job_logs")) monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(state / "delegate_job_logs"))
# Issue #28: a workspace whose real .mam.env names a reachable broker made
# "broker unavailable" tests (T1-4) read it through the wrapper's env loader.
# Point every test at an absent env file; a test that wants a real broker
# config sets MAM_ENV_FILE itself.
monkeypatch.setenv("MAM_ENV_FILE", str(state / ".mam.env.absent"))
@pytest.fixture @pytest.fixture
+27
View File
@@ -279,12 +279,19 @@ def _resolve_private_server_doc() -> str:
PRIVATE_SERVER_DOC_PATH = _resolve_private_server_doc() PRIVATE_SERVER_DOC_PATH = _resolve_private_server_doc()
# Issue #28: these guards read the nats-docker submodule. A checkout without
# `git submodule update --init` has nothing to check; skip with the reason
# instead of failing on every host that cloned without submodules.
needs_private_server_doc = pytest.mark.skipif(
not os.path.exists(PRIVATE_SERVER_DOC_PATH),
reason="nats-docker submodule not initialized (git submodule update --init)")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-11 — (G-D1) PRIVATE_SERVER.md must only document MQTT_* environment # D-11 — (G-D1) PRIVATE_SERVER.md must only document MQTT_* environment
# variables that broker_config_from_env() actually parses. # variables that broker_config_from_env() actually parses.
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_private_server_doc
def test_d11_private_server_env_names_valid(): def test_d11_private_server_env_names_valid():
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts")) sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
import mqtt_common import mqtt_common
@@ -315,6 +322,7 @@ def test_d11_private_server_env_names_valid():
# D-12 — (G-D2) PRIVATE_SERVER.md must not contain invalid MAM_MQTT_* in # D-12 — (G-D2) PRIVATE_SERVER.md must not contain invalid MAM_MQTT_* in
# active configuration code blocks. # active configuration code blocks.
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_private_server_doc
def test_d12_private_server_no_mam_mqtt_in_code_fences(): def test_d12_private_server_no_mam_mqtt_in_code_fences():
doc_path = PRIVATE_SERVER_DOC_PATH doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f: with open(doc_path, "r", encoding="utf-8") as f:
@@ -331,6 +339,7 @@ def test_d12_private_server_no_mam_mqtt_in_code_fences():
# D-13 — (G-D3) nats-server launch instructions in PRIVATE_SERVER.md must # D-13 — (G-D3) nats-server launch instructions in PRIVATE_SERVER.md must
# use valid config blocks (mqtt {) and not HTTP port flag (-m 1883). # use valid config blocks (mqtt {) and not HTTP port flag (-m 1883).
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_private_server_doc
def test_d13_private_server_nats_config_valid(): def test_d13_private_server_nats_config_valid():
doc_path = PRIVATE_SERVER_DOC_PATH doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f: with open(doc_path, "r", encoding="utf-8") as f:
@@ -347,6 +356,7 @@ def test_d13_private_server_nats_config_valid():
# D-14 — (G-D4) Verification commands in PRIVATE_SERVER.md must use valid # D-14 — (G-D4) Verification commands in PRIVATE_SERVER.md must use valid
# CLI flags matching the actual scripts' argparse parsers. # CLI flags matching the actual scripts' argparse parsers.
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_private_server_doc
def test_d14_private_server_cli_args_valid(): def test_d14_private_server_cli_args_valid():
doc_path = PRIVATE_SERVER_DOC_PATH doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f: with open(doc_path, "r", encoding="utf-8") as f:
@@ -367,6 +377,7 @@ def test_d14_private_server_cli_args_valid():
# D-15 — (G-D5) store_dir in code blocks must be absolute path (/ or $HOME) # D-15 — (G-D5) store_dir in code blocks must be absolute path (/ or $HOME)
# and heredocs writing it must be unquoted (<<EOF). # and heredocs writing it must be unquoted (<<EOF).
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_private_server_doc
def test_d15_private_server_store_dir_valid(): def test_d15_private_server_store_dir_valid():
doc_path = PRIVATE_SERVER_DOC_PATH doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f: with open(doc_path, "r", encoding="utf-8") as f:
@@ -388,6 +399,7 @@ def test_d15_private_server_store_dir_valid():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-16 — (G-D6) nats image references in code fences must use pinned alpine # D-16 — (G-D6) nats image references in code fences must use pinned alpine
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_private_server_doc
def test_d16_private_server_nats_image_alpine_pinned(): def test_d16_private_server_nats_image_alpine_pinned():
doc_path = PRIVATE_SERVER_DOC_PATH doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f: with open(doc_path, "r", encoding="utf-8") as f:
@@ -404,6 +416,7 @@ def test_d16_private_server_nats_image_alpine_pinned():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-17 — (G-D7) Port 8222 in docker examples must be bound to 127.0.0.1 # D-17 — (G-D7) Port 8222 in docker examples must be bound to 127.0.0.1
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_private_server_doc
def test_d17_private_server_monitoring_port_localhost_bound(): def test_d17_private_server_monitoring_port_localhost_bound():
doc_path = PRIVATE_SERVER_DOC_PATH doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f: with open(doc_path, "r", encoding="utf-8") as f:
@@ -421,6 +434,7 @@ def test_d17_private_server_monitoring_port_localhost_bound():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-18 — (G-D8) TLS examples must not use IP literals for MQTT_BROKER # D-18 — (G-D8) TLS examples must not use IP literals for MQTT_BROKER
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_private_server_doc
def test_d18_private_server_tls_examples_use_domain_names(): def test_d18_private_server_tls_examples_use_domain_names():
doc_path = PRIVATE_SERVER_DOC_PATH doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f: with open(doc_path, "r", encoding="utf-8") as f:
@@ -437,6 +451,7 @@ def test_d18_private_server_tls_examples_use_domain_names():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-19 — (G-D9) Subject literals in config examples match DEFAULT_TOPIC_ROOT # D-19 — (G-D9) Subject literals in config examples match DEFAULT_TOPIC_ROOT
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_private_server_doc
def test_d19_private_server_subject_literals_match_default_topic_root(): def test_d19_private_server_subject_literals_match_default_topic_root():
doc_path = PRIVATE_SERVER_DOC_PATH doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f: with open(doc_path, "r", encoding="utf-8") as f:
@@ -492,6 +507,9 @@ def _resolve_docker_dir() -> str:
DOCKER_DIR = _resolve_docker_dir() DOCKER_DIR = _resolve_docker_dir()
COMPOSE_PATH = os.path.join(DOCKER_DIR, "docker-compose.yaml") COMPOSE_PATH = os.path.join(DOCKER_DIR, "docker-compose.yaml")
needs_docker_assets = pytest.mark.skipif(
not os.path.exists(COMPOSE_PATH),
reason="nats-docker submodule not initialized (git submodule update --init)")
NATS_CONF_PATH = os.path.join(DOCKER_DIR, "nats.conf") NATS_CONF_PATH = os.path.join(DOCKER_DIR, "nats.conf")
ENV_EXAMPLE_PATH = os.path.join(DOCKER_DIR, ".env.example") ENV_EXAMPLE_PATH = os.path.join(DOCKER_DIR, ".env.example")
DOCKER_README_PATH = os.path.join(DOCKER_DIR, "README.md") DOCKER_README_PATH = os.path.join(DOCKER_DIR, "README.md")
@@ -523,6 +541,7 @@ def _active_conf_lines():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-22 — docker/ assets exist and are populated # D-22 — docker/ assets exist and are populated
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_docker_assets
def test_d22_docker_assets_exist_and_are_populated(): def test_d22_docker_assets_exist_and_are_populated():
for p in [COMPOSE_PATH, NATS_CONF_PATH, ENV_EXAMPLE_PATH, DOCKER_README_PATH]: for p in [COMPOSE_PATH, NATS_CONF_PATH, ENV_EXAMPLE_PATH, DOCKER_README_PATH]:
assert os.path.exists(p), f"Required asset {p} does not exist" assert os.path.exists(p), f"Required asset {p} does not exist"
@@ -533,6 +552,7 @@ def test_d22_docker_assets_exist_and_are_populated():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-23 — compose image matches doc and is alpine # D-23 — compose image matches doc and is alpine
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_docker_assets
def test_d23_compose_image_matches_doc_and_is_alpine(): def test_d23_compose_image_matches_doc_and_is_alpine():
_, svc = _load_compose() _, svc = _load_compose()
compose_img = svc.get("image", "") compose_img = svc.get("image", "")
@@ -552,6 +572,7 @@ def test_d23_compose_image_matches_doc_and_is_alpine():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-24 — compose port exposure contract # D-24 — compose port exposure contract
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_docker_assets
def test_d24_compose_port_exposure_contract(): def test_d24_compose_port_exposure_contract():
_, svc = _load_compose() _, svc = _load_compose()
ports = svc.get("ports", []) ports = svc.get("ports", [])
@@ -573,6 +594,7 @@ def test_d24_compose_port_exposure_contract():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-25 — secrets are fail closed # D-25 — secrets are fail closed
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_docker_assets
def test_d25_secrets_are_fail_closed(): def test_d25_secrets_are_fail_closed():
_, svc = _load_compose() _, svc = _load_compose()
env = svc.get("environment", {}) env = svc.get("environment", {})
@@ -615,6 +637,7 @@ def test_d25_secrets_are_fail_closed():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-26 — nats_conf jetstream and mqtt contract # D-26 — nats_conf jetstream and mqtt contract
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_docker_assets
def test_d26_nats_conf_jetstream_and_mqtt_contract(): def test_d26_nats_conf_jetstream_and_mqtt_contract():
_, svc = _load_compose() _, svc = _load_compose()
volumes = svc.get("volumes", []) volumes = svc.get("volumes", [])
@@ -645,6 +668,7 @@ def test_d26_nats_conf_jetstream_and_mqtt_contract():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-27 — observer permissions match topic root # D-27 — observer permissions match topic root
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_docker_assets
def test_d27_observer_permissions_match_topic_root(): def test_d27_observer_permissions_match_topic_root():
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts")) sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
import mqtt_common import mqtt_common
@@ -666,6 +690,7 @@ def test_d27_observer_permissions_match_topic_root():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-28 — healthcheck contract and image coupling # D-28 — healthcheck contract and image coupling
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_docker_assets
def test_d28_healthcheck_contract_and_image_coupling(): def test_d28_healthcheck_contract_and_image_coupling():
_, svc = _load_compose() _, svc = _load_compose()
hc = svc.get("healthcheck") hc = svc.get("healthcheck")
@@ -684,6 +709,7 @@ def test_d28_healthcheck_contract_and_image_coupling():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-29 — env secrets never tracked # D-29 — env secrets never tracked
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_docker_assets
def test_d29_env_secrets_never_tracked(): def test_d29_env_secrets_never_tracked():
is_submodule = os.path.exists(os.path.join(REPO_ROOT, ".gitmodules")) and "nats-docker" in DOCKER_DIR is_submodule = os.path.exists(os.path.join(REPO_ROOT, ".gitmodules")) and "nats-docker" in DOCKER_DIR
target_repo = os.path.join(REPO_ROOT, "nats-docker") if is_submodule else REPO_ROOT target_repo = os.path.join(REPO_ROOT, "nats-docker") if is_submodule else REPO_ROOT
@@ -703,6 +729,7 @@ def test_d29_env_secrets_never_tracked():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# D-30 — websocket origin policy is startable # D-30 — websocket origin policy is startable
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@needs_docker_assets
def test_d30_websocket_origin_policy_is_startable(): def test_d30_websocket_origin_policy_is_startable():
active_lines = _active_conf_lines() active_lines = _active_conf_lines()
active_text = "\n".join(active_lines) active_text = "\n".join(active_lines)
+11 -1
View File
@@ -497,6 +497,11 @@ def test_importing_delegation_does_not_mutate_environment(mam_sandbox):
assert set(os.environ) == keys assert set(os.environ) == keys
def _normalize_help(text: str) -> str:
text = text.replace("optional arguments:", "options:")
return re.sub(r"\s+", " ", text).strip()
def test_shim_help_output_matches_pre_move_capture(): def test_shim_help_output_matches_pre_move_capture():
"""The shim must be indistinguishable from the implementation at the CLI. """The shim must be indistinguishable from the implementation at the CLI.
@@ -512,7 +517,12 @@ def test_shim_help_output_matches_pre_move_capture():
capture_output=True, text=True, timeout=60, capture_output=True, text=True, timeout=60,
) )
assert res.returncode == 0, res.stderr assert res.returncode == 0, res.stderr
assert res.stdout + res.stderr == golden, "--help drifted for {}".format(mod) # argparse re-wraps usage/help text and renamed the "optional arguments"
# group across Python versions (Issue #28: the captures were taken on
# 3.9, the Linux host runs 3.14). Compare the content, not the layout:
# whitespace runs collapsed, the group label unified.
assert _normalize_help(res.stdout + res.stderr) == _normalize_help(golden), \
"--help drifted for {}".format(mod)
# --- PV-4: the context matrix, each scenario in a fresh interpreter ------------ # --- PV-4: the context matrix, each scenario in a fresh interpreter ------------
+3 -1
View File
@@ -25,7 +25,9 @@ class TestWorkspaceScope(unittest.TestCase):
lib_sh = os.path.join(repo_root, ".agents", "skills", "lib.sh") lib_sh = os.path.join(repo_root, ".agents", "skills", "lib.sh")
cmd = f"source {lib_sh} && env -u HERDR_SESSION_NAME -u HERDR_SERVER_NAME bash -c 'source {lib_sh} && resolve_herdr_session \"test-session\" \"{self.ws_dir}\"'" cmd = f"source {lib_sh} && env -u HERDR_SESSION_NAME -u HERDR_SERVER_NAME bash -c 'source {lib_sh} && resolve_herdr_session \"test-session\" \"{self.ws_dir}\"'"
res = subprocess.run(cmd, shell=True, capture_output=True, text=True) # `source` is a bash builtin; on hosts where /bin/sh is dash `shell=True`
# fails with "source: not found" (Issue #28), so run the string under bash.
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
self.assertEqual(res.returncode, 0, f"Command failed: {res.stderr}") self.assertEqual(res.returncode, 0, f"Command failed: {res.stderr}")
parent = os.path.basename(os.path.dirname(os.path.abspath(self.ws_dir))).lower().replace('_', '-') parent = os.path.basename(os.path.dirname(os.path.abspath(self.ws_dir))).lower().replace('_', '-')
work = os.path.basename(os.path.abspath(self.ws_dir)).lower().replace('_', '-') work = os.path.basename(os.path.abspath(self.ws_dir)).lower().replace('_', '-')