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>
This commit is contained in:
1 parent
ec9586bb9b
commit
622c1f8a9d
18 files changed
+1245
-30
No files matched your search
@@ -0,0 +1,565 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user