Files
multi-agent-mux/tests/test_issue30_stall_watch.py
T
GodopuandClaude Opus 5.5 622c1f8a9d feat(issue-30): a stall watcher that only notifies
lib_py/stall_watch.py runs beside the wrapper's subscriber for a local herdr
job (started after delivery, stopped when the subscriber ends; MAM_STALL_WATCH=0
turns it off). It follows `herdr agent wait` transitions and publishes only
`progress` events through one publisher; it has no path that closes a job.

- blocked: `stalled:blocked <detection line>` once per episode, never answered.
- idle for MAM_STALL_IDLE_S (600) with no background descendants of the agent
  pid (pane process-info + /proc; adapter resident_children and the heartbeat
  timer excluded) and an empty input box: `stalled:idle` and one nudge.
- input box judged from adapter facts measured on herdr 0.9.1 (codex '›',
  grok frame '│', opencode '┃' block + stop pattern); the whole-pane fallback
  is gone. No facts: withheld + `stall_watch:input_check_unavailable` once;
  never empty for 3 x S: `stall_watch:input_never_empty` once.
- herdr failures are not "no event": warning + `stall_watch:herdr_unavailable:
  <code>` (or agent_not_found), backoff, rc 2 after MAM_STALL_HERDR_FAIL_S
  (120); the wrapper prints the watcher's last lines.
- remote and human jobs are skipped with one log line.
- agent_status_reliable is an adapter field; false turns idle judgement off.
- the loop runner relays stalled:/stall_watch: notices once each.
- scoped_env drops a pane-inherited workspace scope for name lookups (#34 rule).

The module sits at the top of lib_py: delegation and loop may not import the
agent adapters (their sibling-import rules). Idle pane captures of the five
CLIs are fixtures; the mock herdr answers process-info in the real shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:43:21 +00:00

566 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Issue #30: a stall watcher that only notifies.
The watcher is a real `python -m lib_py.stall_watch` process against
a job registered by the real wrapper (`submit --dry-run`) and moved by the real
producer (`publish_event.py`); the broker is unreachable, so publish_event
exits 2 after the record and events.log were written. herdr is the conftest
mock, whose `pane process-info` answer has the shape of a real herdr 0.9.1
answer (S1), and whose `agent read` text is a real idle-pane capture
(tests/fixtures/panes, S14). The agent process and its children are real
processes, so the descendant scan reads the real /proc.
"""
from __future__ import annotations
import ast
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
PUBLISH = SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "publish_event.py"
STALL_WATCH = SKILLS / "lib_py" / "stall_watch.py"
PANES = REPO_ROOT / "tests" / "fixtures" / "panes"
if str(SKILLS) not in sys.path:
sys.path.insert(0, str(SKILLS))
from lib_py.agents.input_region import extract_input_region # noqa: E402
from lib_py import stall_watch # noqa: E402
from lib_py.loop import watcher # noqa: E402
pytestmark = pytest.mark.skipif(not sys.platform.startswith("linux"), reason="real process trees via /proc")
UNREACHABLE = {"MQTT_BROKER": "127.0.0.1", "MQTT_PORT": "9", "MQTT_TLS": "0"}
# `herdr pane process-info --pane w1:p1` on herdr 0.9.1, 2026-09-25 (plan F11).
REAL_PROCESS_INFO = {"result": {"process_info": {
"foreground_process_group_id": 11407,
"foreground_processes": [{"argv": ["claude"], "cmdline": "claude --dangerously-skip-permissions",
"cwd": "/home/u/ws", "name": "claude", "pid": 11407}],
"pane_id": "w1:p1", "shell_pid": 8187}, "type": "pane_process_info"}}
# The input line of each idle capture, and a synthetic "someone is typing"
# variant of it (no real typing capture was taken: typing into another agent's
# pane was out of scope for the capture job).
TYPING = {"claude": (r"^❯\s*$", "❯ please continue"), "agy": (r"^>\s*$", "> please continue"),
"grok": (r"│ ❯ +", "│ ❯ please continue "), "opencode": (r"^ ┃\s*$", " ┃ please continue"),
"codex": (r"^› Ask Codex to do anything", "› please continue")}
def _pane(agent: str, typing: bool = False) -> str:
text = "".join(l for l in (PANES / f"{agent}_idle.txt").read_text().splitlines(True) if not l.startswith("# "))
if typing:
pat, rep = TYPING[agent]
typed = re.sub(pat, rep, text, count=1, flags=re.M)
assert typed != text, agent
return typed
return text
# ---------------------------------------------------------------------------
# S14 - the input-box facts are pinned to real captures
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("agent", sorted(TYPING))
def test_s14_a_real_idle_capture_has_an_empty_input_box_and_typing_does_not(agent):
assert extract_input_region(_pane(agent), agent) == ""
assert extract_input_region(_pane(agent, typing=True), agent) == "please continue"
def test_s14_rule_agents_keep_their_path_and_an_agent_without_facts_raises():
ruled = "old\n" + "─" * 20 + "\n❯ \n" + "─" * 20 + "\nfooter"
assert extract_input_region(ruled, "hermes") == ""
with pytest.raises(ValueError):
extract_input_region(ruled, "no-such-agent")
# no whole-pane fallback: a pane without any input line is not "empty"
assert extract_input_region("just a transcript\n", "codex") != ""
assert extract_input_region("just a transcript\n", "opencode") != ""
# ---------------------------------------------------------------------------
# judge - the pure decision, as a table
# ---------------------------------------------------------------------------
def _sig(**kw):
base = dict(agent_status="idle", idle_for=10.0, agent_pid=123, background_children=[],
input_empty=True, job_status="running")
base.update(kw)
return stall_watch.Signals(**base)
@pytest.mark.parametrize("sig,st,want", [
(_sig(), {}, stall_watch.NOTIFY_IDLE_AND_NUDGE),
(_sig(agent_status="done"), {}, stall_watch.NOTIFY_IDLE_AND_NUDGE),
(_sig(idle_for=1.0), {}, None),
(_sig(background_children=["9:sleep"]), {}, None),
(_sig(input_empty=False), {}, None),
(_sig(input_empty=None), {}, None),
(_sig(agent_pid=None), {}, None),
(_sig(job_status="completed"), {}, None),
(_sig(), {"nudged": True}, None),
(_sig(), {"reliable": False}, None),
(_sig(agent_status="working"), {}, None),
(_sig(agent_status="blocked"), {}, stall_watch.NOTIFY_BLOCKED),
(_sig(agent_status="blocked"), {"reliable": False}, stall_watch.NOTIFY_BLOCKED),
(_sig(agent_status="blocked"), {"blocked_notified": True}, None),
])
def test_judge_table(sig, st, want):
assert stall_watch.judge(sig, stall_watch.WatchState(idle_s=5.0, **st)) == want
# ---------------------------------------------------------------------------
# harness
# ---------------------------------------------------------------------------
def _env(sandbox: Path, **extra) -> dict:
env = {k: v for k, v in os.environ.items() if not k.startswith("HERDR_")}
env.update(UNREACHABLE, DELEGATE_JOB_PYTHON=sys.executable, PYTHONPATH=str(SKILLS), MAM_JOB_ID="",
DELEGATE_JOB_LOGS_DIR=str(sandbox / ".mam" / "logs"))
env.update({k: str(v) for k, v in extra.items()})
return env
def _job(env: dict, reg: Path) -> str:
res = subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human", "--agent-session", "herdr:img",
"--dry-run", "--registry-dir", str(reg), "--prompt", "work"],
capture_output=True, text=True, env=env, cwd=env["HOME"], timeout=120)
assert res.returncode == 0, res.stdout + res.stderr
jid = re.search(r"registered job:\s*(\S+)", res.stdout).group(1)
_publish(env, reg, jid, "started")
assert _record(reg, jid)["status"] == "running"
return jid
def _record(reg: Path, jid: str) -> dict:
return json.loads((reg / f"{jid}.json").read_text())
def _publish(env: dict, reg: Path, jid: str, event: str, *extra: str) -> None:
res = subprocess.run([sys.executable, str(PUBLISH), "--registry-dir", str(reg), "--job", jid, "--event", event,
"--expect-dispatch-token", _record(reg, jid)["dispatch_token"], "--attempts", "1", *extra],
capture_output=True, text=True, env=env, timeout=120)
assert res.returncode in (0, 2), res.stderr
def _details(reg: Path, jid: str) -> list:
log = reg / f"{jid}.events.log"
if not log.exists():
return []
out = []
for line in log.read_text().splitlines():
try:
ev = json.loads(line)
except ValueError:
continue
if ev.get("event") == "progress":
out.append(ev.get("detail", ""))
return out
def _prompts(state_file: Path) -> list:
return [c for c in json.loads(state_file.read_text()).get("calls", []) if c[:2] == ["agent", "prompt"]]
def _agent(state_file: Path, name: str, agent: str, pid: int, status: str, buffer: str, detection: str = ""):
state = json.loads(state_file.read_text())
state["wait_blocks_s"] = 0.5
state.setdefault("agents", {})[name] = {
"name": name, "agent": agent, "status": "running", "agent_status": status, "pane_id": "w1:p1",
"workspace_id": "w1", "pid": pid, "buffer": buffer, "detection": detection}
state_file.write_text(json.dumps(state, indent=2))
def _proc(script: str) -> subprocess.Popen:
p = subprocess.Popen(["bash", "-c", script], start_new_session=True)
time.sleep(0.3) # let `exec` and the background child settle
return p
def _kill(p: subprocess.Popen) -> None:
try:
os.killpg(p.pid, 9)
except ProcessLookupError:
pass
p.wait()
def _watch(env: dict, reg: Path, jid: str, target: str, agent: str) -> subprocess.Popen:
tok = _record(reg, jid)["dispatch_token"]
return subprocess.Popen([sys.executable, "-m", "lib_py.stall_watch", "--job", jid,
"--registry-dir", str(reg), "--target", target, "--agent", agent,
"--expect-dispatch-token", tok],
env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
def _until(pred, limit=20.0):
end = time.monotonic() + limit
while time.monotonic() < end:
if pred():
return True
time.sleep(0.2)
return pred()
def _finish(env, reg, jid, w: subprocess.Popen) -> str:
"""Complete the job for real: the watcher sees the terminal record and exits 0."""
_publish(env, reg, jid, "completed")
out, _ = w.communicate(timeout=30)
assert w.returncode == 0, out
return out
@pytest.fixture
def box(mam_sandbox, mock_herdr):
reg = mam_sandbox / ".mam" / "jobs"
env = _env(mam_sandbox, MAM_STALL_IDLE_S=2)
return mam_sandbox, mock_herdr, reg, env
# ---------------------------------------------------------------------------
# S1 - the mock answers in the shape of the real herdr
# ---------------------------------------------------------------------------
def test_s1_mock_process_info_has_the_real_herdr_shape(box):
_, state, _, env = box
_agent(state, "creator-claude-t", "claude", 4242, "idle", "Ready")
out = subprocess.run(["herdr", "pane", "process-info", "--pane", "w1:p1"], capture_output=True, text=True,
env=env).stdout
mock = json.loads(out)
real, got = REAL_PROCESS_INFO["result"], mock["result"]
assert set(real) <= set(got)
assert set(real["process_info"]) <= set(got["process_info"])
assert set(real["process_info"]["foreground_processes"][0]) <= set(got["process_info"]["foreground_processes"][0])
assert got["process_info"]["foreground_processes"][0]["pid"] == 4242
# ---------------------------------------------------------------------------
# S2 - blocked: reported once with the dialog text, never answered
# ---------------------------------------------------------------------------
def test_s2_blocked_is_reported_once_with_the_dialog_and_never_answered(box):
_, state, reg, env = box
jid = _job(env, reg)
agent = _proc("exec sleep 300")
try:
_agent(state, "creator-claude-t", "claude", agent.pid, "blocked", _pane("claude"),
detection="\n Dangerous rm operation detected. Do you want to proceed?\n 1. Yes\n")
w = _watch(env, reg, jid, "creator-claude-t", "claude")
assert _until(lambda: _details(reg, jid))
time.sleep(2)
assert _details(reg, jid) == ["stalled:blocked Dangerous rm operation detected. Do you want to proceed?"]
calls = json.loads(state.read_text())["calls"]
assert not [c for c in calls if c[:2] == ["agent", "prompt"] or "send-keys" in c]
assert _record(reg, jid)["status"] == "running"
out = _finish(env, reg, jid, w)
assert "WARNING" in out and "blocked" in out
finally:
_kill(agent)
# ---------------------------------------------------------------------------
# S3/S4/S5 - idle: nudged once only without background work
# ---------------------------------------------------------------------------
def test_s3_idle_without_children_is_nudged_exactly_once(box):
_, state, reg, env = box
jid = _job(env, reg)
agent = _proc("exec sleep 300")
try:
_agent(state, "creator-claude-t", "claude", agent.pid, "idle", _pane("claude"))
w = _watch(env, reg, jid, "creator-claude-t", "claude")
assert _until(lambda: _prompts(state))
time.sleep(4)
assert _details(reg, jid) == ["stalled:idle"]
prompts = _prompts(state)
assert len(prompts) == 1
assert prompts[0][2] == "creator-claude-t" and prompts[0][3].startswith(f"Job {jid}: you look idle")
assert _record(reg, jid)["status"] == "running"
_finish(env, reg, jid, w)
finally:
_kill(agent)
def test_s4_idle_with_a_background_child_is_left_alone(box):
_, state, reg, env = box
jid = _job(env, reg)
agent = _proc("sleep 300 & exec sleep 300")
try:
assert stall_watch.background_children(agent.pid)
_agent(state, "creator-claude-t", "claude", agent.pid, "idle", _pane("claude"))
w = _watch(env, reg, jid, "creator-claude-t", "claude")
time.sleep(6)
assert not [d for d in _details(reg, jid) if d.startswith("stalled:")]
assert not _prompts(state)
_finish(env, reg, jid, w)
finally:
_kill(agent)
@pytest.mark.parametrize("agent_name,nudged", [("codex", True), ("claude", False)])
def test_s5_the_adapters_resident_children_change_the_judgement(box, agent_name, nudged):
_, state, reg, env = box
jid = _job(env, reg)
agent = _proc("(exec -a codex-code-mode-host sleep 300) & exec sleep 300")
try:
_agent(state, f"reviewer-{agent_name}-t", agent_name, agent.pid, "idle", _pane(agent_name))
w = _watch(env, reg, jid, f"reviewer-{agent_name}-t", agent_name)
if nudged:
assert _until(lambda: _prompts(state))
else:
time.sleep(6)
assert len(_prompts(state)) == (1 if nudged else 0)
assert (_details(reg, jid) == ["stalled:idle"]) is nudged
_finish(env, reg, jid, w)
finally:
_kill(agent)
# ---------------------------------------------------------------------------
# S6 - the heartbeat timer is never background work
# ---------------------------------------------------------------------------
def test_s6_the_heartbeat_timer_is_not_a_background_child(box):
_, state, reg, env = box
jid = _job(env, reg)
tok = _record(reg, jid)["dispatch_token"]
publish = (f"{sys.executable} {PUBLISH} --registry-dir {reg} --job {jid} --event progress --detail x "
f"--expect-dispatch-token {tok} --attempts 1 --heartbeat 1; exec sleep 300")
agent = subprocess.Popen(["bash", "-c", publish], env=env, start_new_session=True)
timer = []
try:
def _timer():
out = subprocess.run(["pgrep", "-f", f"lib_py.delegation.heartbeat --job {jid}"],
capture_output=True, text=True).stdout.split()
timer[:] = [int(p) for p in out]
return timer
assert _until(_timer, 30)
assert _until(lambda: open(f"/proc/{agent.pid}/cmdline", "rb").read().startswith(b"sleep"), 30)
kids = stall_watch.background_children(agent.pid)
assert not [k for k in kids if int(k.split(":")[0]) in timer], kids
finally:
for pid in timer:
try:
os.kill(pid, 9)
except ProcessLookupError:
pass
_kill(agent)
# ---------------------------------------------------------------------------
# S7 - someone typing, or no input facts: no nudge, and the latter says so
# ---------------------------------------------------------------------------
def test_s7_a_typed_input_box_withholds_the_nudge(box):
_, state, reg, env = box
jid = _job(env, reg)
agent = _proc("exec sleep 300")
try:
_agent(state, "reviewer-grok-t", "grok", agent.pid, "idle", _pane("grok", typing=True))
w = _watch(env, reg, jid, "reviewer-grok-t", "grok")
time.sleep(6)
assert _details(reg, jid) == []
assert not _prompts(state)
out = _finish(env, reg, jid, w)
assert "not empty" in out
finally:
_kill(agent)
def test_s7b_an_agent_without_input_facts_is_withheld_and_reported_once(box):
_, state, reg, env = box
jid = _job(env, reg)
agent = _proc("exec sleep 300")
try:
_agent(state, "worker-x", "noinput", agent.pid, "idle", "whatever")
w = _watch(env, reg, jid, "worker-x", "noinput")
assert _until(lambda: _details(reg, jid))
time.sleep(5)
assert _details(reg, jid) == ["stall_watch:input_check_unavailable:noinput"]
assert not _prompts(state)
out = _finish(env, reg, jid, w)
assert out.count("input check unavailable") == 1
finally:
_kill(agent)
# ---------------------------------------------------------------------------
# S8 - a failing herdr is never "no event"
# ---------------------------------------------------------------------------
def test_s8_herdr_failure_is_reported_and_the_watcher_stops_with_rc_2(box):
_, state, reg, env = box
jid = _job(env, reg)
st = json.loads(state.read_text())
st["force_error_code"] = "protocol_mismatch"
state.write_text(json.dumps(st))
env["MAM_STALL_HERDR_FAIL_S"] = "3"
w = _watch(env, reg, jid, "creator-claude-t", "claude")
out, _ = w.communicate(timeout=60)
assert w.returncode == 2, out
assert _details(reg, jid) == ["stall_watch:herdr_unavailable:protocol_mismatch"]
assert "herdr unavailable" in out and "WARNING" in out
assert _record(reg, jid)["status"] == "running"
# ---------------------------------------------------------------------------
# S12 - the watcher has no path that closes a job
# ---------------------------------------------------------------------------
def test_s12_the_watcher_can_only_publish_progress():
tree = ast.parse(STALL_WATCH.read_text())
names = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
attrs = {n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute)}
imported = {a.name for n in ast.walk(tree) if isinstance(n, (ast.Import, ast.ImportFrom)) for a in n.names}
froms = {n.module for n in ast.walk(tree) if isinstance(n, ast.ImportFrom)}
for forbidden in ("close", "update_status", "_operator_write"):
assert forbidden not in names | attrs | imported, forbidden
assert "lib_py.delegation.registry" not in froms and "registry" not in imported
# every `--event` in the file is followed by the literal "progress"
events = []
for node in ast.walk(tree):
if isinstance(node, (ast.List, ast.Tuple)):
elts = node.elts
for i, e in enumerate(elts[:-1]):
if isinstance(e, ast.Constant) and e.value == "--event":
events.append(getattr(elts[i + 1], "value", None))
assert events == ["progress"]
publishers = [n for n in ast.walk(tree) if isinstance(n, ast.Constant) and n.value == "lib_py.delegation.publish_event"]
assert len(publishers) == 1
# ---------------------------------------------------------------------------
# S13 - an adapter whose agent_status is unreliable gets no idle judgement
# ---------------------------------------------------------------------------
def test_s13_unreliable_agent_status_turns_idle_off_but_keeps_blocked(box):
_, state, reg, env = box
env["MAM_TEST_UNRELIABLE_AGENT"] = "claude"
jid = _job(env, reg)
agent = _proc("exec sleep 300")
try:
_agent(state, "creator-claude-t", "claude", agent.pid, "idle", _pane("claude"),
detection="Do you want to proceed?")
w = _watch(env, reg, jid, "creator-claude-t", "claude")
time.sleep(5)
assert _details(reg, jid) == [] and not _prompts(state)
st = json.loads(state.read_text())
st["agents"]["creator-claude-t"]["agent_status"] = "blocked"
state.write_text(json.dumps(st))
assert _until(lambda: _details(reg, jid))
assert _details(reg, jid) == ["stalled:blocked Do you want to proceed?"]
out = _finish(env, reg, jid, w)
assert "idle judgement off for claude" in out
finally:
_kill(agent)
# ---------------------------------------------------------------------------
# scope - the #34 rule reaches the python herdr calls
# ---------------------------------------------------------------------------
def test_scoped_env_drops_the_pane_inherited_workspace_for_name_lookups_only():
pane = {"HERDR_SESSION": "multi-agent-mux", "HERDR_WORKSPACE_ID": "w6", "HERDR_PANE_ID": "w6:p1"}
by_name = watcher.scoped_env("multi-agent-mux", environ=pane, argv=["herdr", "agent", "wait", "rev"])
assert "HERDR_WORKSPACE_ID" not in by_name and by_name["HERDR_PANE_ID"] == "w6:p1"
by_pane = watcher.scoped_env("multi-agent-mux", environ=pane, argv=["herdr", "pane", "read", "w2:p1"])
assert by_pane["HERDR_WORKSPACE_ID"] == "w6"
chosen = dict(pane, HERDR_WORKSPACE_ID="w5")
assert watcher.scoped_env("multi-agent-mux", environ=chosen, argv=["herdr", "agent", "get", "rev"])[
"HERDR_WORKSPACE_ID"] == "w5"
assert watcher.scoped_env(None, environ={"HERDR_WORKSPACE_ID": "w6"},
argv=["herdr", "agent", "get", "rev"])["HERDR_WORKSPACE_ID"] == "w6"
# ---------------------------------------------------------------------------
# S9 / S10 - the real wrapper starts and stops the watcher (the #18 harness)
# ---------------------------------------------------------------------------
import test_issue18_liveness_contract as t18 # noqa: E402
def _watchers(jid: str) -> list:
out = subprocess.run(["pgrep", "-f", f"lib_py.stall_watch --job {jid}"], capture_output=True, text=True).stdout
return [int(p) for p in out.split()]
@pytest.mark.parametrize("switch", ["1", "0"])
def test_s9_the_wrapper_runs_the_watcher_for_the_jobs_life(tmp_path, mock_herdr, switch):
reg = tmp_path / "jobs"
reg.mkdir()
t18._headless_agent(Path(mock_herdr), "headless-codex")
proc, env = t18._submit(tmp_path, reg, "herdr:headless-codex", "codex", ["--ack-timeout", "40"],
env_extra={"MAM_STALL_WATCH": switch}, timeout_s=60)
jid = t18._wait_job_id(reg)
try:
time.sleep(1)
assert t18._publish(reg, jid, "started", env).returncode == 0
if switch == "1":
assert _until(lambda: (reg / f"{jid}.stall.out").exists() and _watchers(jid), 20), "no watcher"
else:
time.sleep(2)
assert t18._publish(reg, jid, "completed", env, "--detail", "done").returncode == 0
stdout, stderr = proc.communicate(timeout=60)
finally:
if proc.poll() is None:
proc.kill()
assert proc.returncode == 0, stdout + stderr
assert _watchers(jid) == [], "the watcher outlived its job"
assert (reg / f"{jid}.stall.out").exists() is (switch == "1")
assert "stall watch log" not in stderr # a completed job carries no watcher log
@pytest.mark.parametrize("agent,session,why", [("human", "herdr:human", "human job"),
("claude", "remote:far-01", "remote job")])
def test_s10_remote_and_human_jobs_are_not_watched(tmp_path, agent, session, why):
reg = tmp_path / "jobs"
reg.mkdir()
proc, env = t18._submit(tmp_path, reg, session, agent, ["--ack-timeout", "40"], timeout_s=60,
env_extra={"MAM_DISPATCH_SECRET": "s10-secret"})
jid = t18._wait_job_id(reg)
try:
time.sleep(1)
assert t18._publish(reg, jid, "started", env).returncode == 0
time.sleep(1)
assert t18._publish(reg, jid, "completed", env, "--detail", "done").returncode == 0
stdout, stderr = proc.communicate(timeout=60)
finally:
if proc.poll() is None:
proc.kill()
assert proc.returncode == 0, stdout + stderr
assert f"stall watch: {why}, no local pane - stall detection off" in stderr
assert not (reg / f"{jid}.stall.out").exists()
# ---------------------------------------------------------------------------
# S11 - the loop runner relays the watcher's notices once, and keeps waiting
# ---------------------------------------------------------------------------
def test_s11_the_runner_warns_once_per_stall_notice(mam_sandbox, monkeypatch, capsys):
reg = mam_sandbox / ".mam" / "jobs"
env = _env(mam_sandbox)
jid = _job(env, reg)
for d in ("stalled:idle", "stalled:idle", "stall_watch:herdr_unavailable:protocol_mismatch", "ordinary"):
_publish(env, reg, jid, "progress", "--detail", d)
monkeypatch.setenv("WORKSPACE_ROOT", str(mam_sandbox))
monkeypatch.setenv("MAM_JOBS_DIR", str(reg))
monkeypatch.delenv("MAM_REAL_ROOT", raising=False)
from lib_py.loop import runner as lr
r = lr.LoopRunner(["--creator", "c", "--task", "t"])
r.parse_args()
import threading
threading.Timer(2.5, lambda: _publish(env, reg, jid, "completed")).start()
assert r.wait_for_job(jid, max_wait=30, check_interval=0.3) is True
out = capsys.readouterr()
text = out.out + out.err
assert text.count(f"job {jid}: stalled:idle") == 1, text
assert text.count(f"job {jid}: stall_watch:herdr_unavailable:protocol_mismatch") == 1, text
assert "ordinary" not in text