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:
GodopuandClaude Opus 5.5 committed 2026-09-30 15:54:58 +00:00
1 parent 80dc2eb45e
commit 2826e709ae
8 files changed
+656 -49

No files matched your search

+28 -34
View File
@@ -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: