test(issue-26): no test depends on a third-party MQTT broker
- tests/_local_mqtt.py: in-process MQTT 3.1.1 broker on 127.0.0.1 (what the repo uses: protocol level 4, clean session, QoS 0/1, retained). - conftest: one session broker named by MQTT_BROKER/MQTT_PORT/MQTT_TLS; other redirecting/auth MQTT_* dropped and restored afterwards. - Harnesses of the 22 broker-dependent tests (#18 acceptance1/2/3/5, #6 t1_1/2/7/8/14/15/16/20, #19 MQTT loop x2, #33 b3/r3/b7, #30 s9/s10/r2) use it; the "broker unreachable" skips are gone (a local broker that does not deliver is a failure). acceptance1/5 and #19 also assert on the subscriber, runner and audit logs the real producers write. - tests/test_local_mqtt.py: conformance with the real clients and the real publish_event -> job_subscriber path (C0), an AST guard against public broker literals and MQTT_* deleted without a replacement (C2), and the session's broker config (C3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
80dc2eb45e
commit
2826e709ae
8 files changed
+656
-49
No files matched your search
@@ -18,11 +18,12 @@ Contract (registry fields, wrapper options):
|
||||
its own registry and broker credentials runs it, and the orchestrator's
|
||||
subscriber mirrors its events into the orchestrator's registry.
|
||||
|
||||
Tests marked "broker" talk to the configured broker (the public default in the
|
||||
sandbox); each stays under a minute.
|
||||
Tests marked "broker" talk to the session's in-process broker (tests/_local_mqtt.py,
|
||||
Issue #26); each stays under a minute.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -36,6 +37,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from _local_mqtt import local_broker_env
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILLS = REPO_ROOT / ".agents" / "skills"
|
||||
LIB_SH = SKILLS / "lib.sh"
|
||||
@@ -59,6 +62,7 @@ def _env(cwd: Path, extra=None) -> dict:
|
||||
env["PYTHONPATH"] = str(SKILLS)
|
||||
for k in ("MQTT_BROKER", "MQTT_HOST", "MQTT_PORT", "MQTT_TLS", "MQTT_USERNAME", "MQTT_PASSWORD"):
|
||||
env.pop(k, None)
|
||||
env.update(local_broker_env()) # Issue #26: the session's in-process broker
|
||||
if extra:
|
||||
env.update({k: str(v) for k, v in extra.items()})
|
||||
return env
|
||||
@@ -1091,6 +1095,12 @@ def test_acceptance1_headless_agent_survives_on_heartbeats_alone(tmp_path, mock_
|
||||
assert _prompt_calls(Path(mock_herdr)) == 1
|
||||
beats = [e for e in _events(reg, jid) if e["event"] == "progress"]
|
||||
assert len(beats) >= 5, len(beats)
|
||||
# Issue #26: the wrapper's own subscriber saw them on the wire (its console
|
||||
# record, written by job_subscriber, not by the test)
|
||||
sub = (reg / f"{jid}.subscriber.out").read_text()
|
||||
assert f"SUBSCRIBED {_job(reg, jid)['topic_prefix']}/events" in sub, sub
|
||||
assert len(re.findall(rf"job={jid} seq=\d+ progress\s+heartbeat$", sub, re.M)) >= 5, sub
|
||||
assert re.search(rf"job={jid} seq=\d+ completed\s+PASS$", sub, re.M), sub
|
||||
brief = (reg / jid / "brief.md").read_text()
|
||||
assert "heartbeat every 2 s" in brief and "after 2 missed heartbeats" in brief and "within 40 s" in brief
|
||||
|
||||
@@ -1161,31 +1171,6 @@ def test_acceptance3_lost_heartbeats_close_the_job_and_a_late_completed_is_refus
|
||||
# 6. remote transport (broker): no shared disk, no pane, only the wire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _skip_if_broker_did_not_deliver(reg: Path, runner_log: Path) -> None:
|
||||
"""acceptance5 runs over a public broker. The remote runner already covers
|
||||
the idle window with `started --heartbeat` (remote.py handle_dispatch). The
|
||||
observed failures were of one kind: the runner published started and
|
||||
completed successfully, the orchestrator had subscribed to the right topic,
|
||||
and it received nothing at all for the job - the broker did not deliver.
|
||||
Only that proven non-delivery skips; a drop (HMAC, identity), a wrong
|
||||
topic, or a runner that failed to publish still fails."""
|
||||
log = runner_log.read_text(errors="replace")
|
||||
m = re.search(r"published started seq=1 job=(\w+)", log)
|
||||
if not m:
|
||||
return
|
||||
jid = m.group(1)
|
||||
rec = reg / f"{jid}.json"
|
||||
if not rec.exists():
|
||||
return
|
||||
topic = json.loads(rec.read_text()).get("topic_prefix") or ""
|
||||
out = reg / f"{jid}.subscriber.out"
|
||||
sub = out.read_text(errors="replace") if out.exists() else ""
|
||||
if (topic and f"SUBSCRIBED {topic}/events" in sub
|
||||
and f"job={jid}" not in sub and f"drop event for job {jid}" not in sub):
|
||||
pytest.skip(f"public broker did not deliver the runner's events to the orchestrator "
|
||||
f"(runner published started for {jid}; subscriber received nothing for it)")
|
||||
|
||||
|
||||
def test_acceptance5_remote_runner_completes_a_job_over_the_broker_only(tmp_path):
|
||||
orch = tmp_path / "orchestrator"
|
||||
remote = tmp_path / "remote-host"
|
||||
@@ -1210,8 +1195,7 @@ def test_acceptance5_remote_runner_completes_a_job_over_the_broker_only(tmp_path
|
||||
deadline = time.time() + 45
|
||||
while time.time() < deadline and "SUBSCRIBED" not in runner_log.read_text(errors="replace"):
|
||||
time.sleep(0.2)
|
||||
if "SUBSCRIBED" not in runner_log.read_text(errors="replace"):
|
||||
pytest.skip("broker unreachable: " + runner_log.read_text(errors="replace")[-300:])
|
||||
assert "SUBSCRIBED" in runner_log.read_text(errors="replace"), runner_log.read_text(errors="replace")
|
||||
|
||||
reg = orch / "jobs"
|
||||
proc, env = _submit(orch, reg, f"remote:{session}", "remote",
|
||||
@@ -1219,11 +1203,6 @@ def test_acceptance5_remote_runner_completes_a_job_over_the_broker_only(tmp_path
|
||||
env_extra={"MAM_DISPATCH_SECRET": secret}, timeout_s=120)
|
||||
stdout, stderr = proc.communicate(timeout=180)
|
||||
jid = _wait_job_id(reg)
|
||||
if proc.returncode != 0:
|
||||
if ("no CONNACK from broker" in stderr
|
||||
and "published started" not in runner_log.read_text(errors="replace")):
|
||||
pytest.skip("broker unreachable when publishing the dispatch (no CONNACK); the runner never saw the job")
|
||||
_skip_if_broker_did_not_deliver(reg, runner_log)
|
||||
assert proc.returncode == 0, f"rc={proc.returncode}\n{stdout}\n{stderr}\nrunner:\n{runner_log.read_text()}"
|
||||
# delivery is confirmed by the protocol: the subscriber's attributed
|
||||
# audit record of the remote `started` (review R1: the registry status
|
||||
@@ -1246,6 +1225,21 @@ def test_acceptance5_remote_runner_completes_a_job_over_the_broker_only(tmp_path
|
||||
# the agent command's last stdout line became the completed detail
|
||||
assert "remote work done for " + jid in stdout, stdout
|
||||
assert runner.returncode == 0
|
||||
# Issue #26: both ends' own logs. The runner (and the publish_event it
|
||||
# runs) wrote runner.log; the orchestrator's subscriber wrote the audit
|
||||
# record of what it received, attributed to this dispatch.
|
||||
rlog = runner_log.read_text(errors="replace")
|
||||
assert f"DISPATCH job={jid} " in rlog, rlog
|
||||
assert f"published started seq=1 job={jid} " in rlog, rlog
|
||||
assert re.search(rf"published completed seq=\d+ job={jid} ", rlog), rlog
|
||||
assert f"DONE job={jid} rc=0" in rlog, rlog
|
||||
audit = [json.loads(l) for l in (orch / "logs" / jid / "events.ndjson").read_text().splitlines() if l.strip()]
|
||||
fp = hashlib.sha256(_job(reg, jid)["dispatch_token"].encode("utf-8")).hexdigest()
|
||||
for ev in ("started", "completed"):
|
||||
got = [a for a in audit if a.get("event") == "received" and a.get("source_event") == ev]
|
||||
assert got and got[0]["dispatch_verified"] is True and got[0]["dispatch_fingerprint"] == fp, (ev, audit)
|
||||
sub = (reg / f"{jid}.subscriber.out").read_text()
|
||||
assert f"SUBSCRIBED {_job(reg, jid)['topic_prefix']}/events" in sub, sub
|
||||
assert "살아있는 에이전트 세션" not in stdout, "no pane injection path for a remote session"
|
||||
finally:
|
||||
if runner.poll() is None:
|
||||
|
||||
Reference in new issue
Block a user