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:
GodopuandClaude Opus 5.5 committed 2026-09-25 11:43:21 +00:00
1 parent ec9586bb9b
commit 622c1f8a9d
18 files changed
+1245 -30

No files matched your search

+23 -3
View File
@@ -391,6 +391,12 @@ while args and args[0] in ("-L", "--server", "-s", "--session"):
if not args:
sys.exit(0)
# Issue #30: a herdr that fails every call the way a server with a
# mismatched protocol does (exit 1, error JSON on stderr).
if state.get("force_error_code"):
sys.stderr.write(json.dumps({"error": {"code": state["force_error_code"], "message": "forced"}}) + "\\n")
sys.exit(1)
cmd1 = args[0]
if cmd1 == "workspace":
if len(args) < 2:
@@ -622,9 +628,10 @@ elif cmd1 == "pane":
if len(args) < 3:
sys.exit(1)
pane_id = args[2]
detection = "--source" in args and args[args.index("--source") + 1] == "detection"
for a_name, data in state.get("agents", {}).items():
if data.get("pane_id") == pane_id or a_name == pane_id:
print(data.get("buffer", "Ready"))
print(data.get("detection", "") if detection else data.get("buffer", "Ready"))
sys.exit(0)
for p in state.get("panes", []):
if p.get("pane_id") == pane_id:
@@ -656,15 +663,23 @@ elif cmd1 == "pane":
if "--pane" in args:
pane_id = args[args.index("--pane") + 1]
pid = 9999
pname = "claude"
for name, data in state.get("agents", {}).items():
if data.get("pane_id") == pane_id or pane_id == "w1:p1":
pid = data.get("pid", 9999)
pname = data.get("agent", "claude")
break
# Issue #30: the shape of a real herdr 0.9.1 `pane process-info` answer
# (tests/test_issue30_stall_watch.py REAL_PROCESS_INFO).
res = {
"process_info": {
"foreground_processes": [{"pid": pid}],
"foreground_process_group_id": pid,
"foreground_processes": [{"argv": [pname], "cmdline": pname, "cwd": os.getcwd(),
"name": pname, "pid": pid}],
"pane_id": pane_id or "w1:p1",
"shell_pid": pid
}
},
"type": "pane_process_info"
}
print(json.dumps({"result": res}))
sys.exit(0)
@@ -936,6 +951,7 @@ elif cmd1 == "agent":
"agent": agent_data.get("agent", "claude"),
"status": agent_data.get("status", "running"),
"agent_status": agent_st,
"state_change_seq": agent_data.get("state_change_seq", 0),
"interactive_ready": agent_data.get("interactive_ready", True),
"cwd": agent_data.get("cwd", ""),
"pane_id": agent_data.get("pane_id", "w1:p1"),
@@ -1010,6 +1026,10 @@ elif cmd1 == "agent":
}))
sys.exit(0)
else:
# Issue #30: a real wait blocks until its --timeout; opt in to that
# (capped) so a watcher looping on waits does not spin.
if state.get("wait_blocks_s") and timeout_ms:
time.sleep(min(timeout_ms / 1000.0, float(state["wait_blocks_s"])))
sys.stderr.write(json.dumps({
"error": {
"code": "timeout",
+16
View File
@@ -0,0 +1,16 @@
# captured 2026-09-25 from an idle agent, input box empty: herdr 0.9.1 `herdr agent read reviewer-agy-01` (last 15 lines)
• All 70 tests passed in 161.02s.
• Combined Test Total: 87 passed, 0 failed.
• Cleanup: Temporary scratch files in /tmp have been purged.
──────
### 4. Deliverables
• Review Report: Saved to report-final.md ending with standalone line
[VERDICT: PASS].
• Delegation Event: Published completed event with dispatch token
4988b0584e6b4da4af4be4d94f5f1328. Job transitioned to completed.
───────────────────────────────────────────────────────────────────────────
>
───────────────────────────────────────────────────────────────────────────
? for shortcuts Gemini 3.8 Flash · high
+16
View File
@@ -0,0 +1,16 @@
# captured 2026-09-25 from an idle agent, input box empty: herdr 0.9.1 `herdr agent read planner-reviewer-claude-01` (last 15 lines)
Remote and human jobs are skipped with one log line. Status reliability
lives in an adapter field that gates the idle judgement.
A new section maps each rule to the code that enforces it and the test
that pins it, and lists the four rules that cannot be enforced in code.
Documentation shrinks to usage lines and table rows. The tests use the
real wrapper, subscriber, registry, publish script and watcher with real
process trees, and the 14-minute end-to-end scenario runs once.
✻ Cogitated for 12m 56s · done 11:04 AM
✔ Update installed · Restart to update
────────────────────────────────────────────────────────────────────────────
❯
────────────────────────────────────────────────────────────────────────────
⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents
+16
View File
@@ -0,0 +1,16 @@
# captured 2026-09-25 from an idle agent, input box empty: herdr 0.9.1 `herdr agent read planner-reviewer-codex-01` (last 15 lines)
• Explored
└ Search "status"|"detail"|"completed" in b8682c96.json
• 리뷰를 PASS로 완료했습니다. 추가 검증 3개와 정적 검사를 통과했으며,
completed 발행과 상태 반영을 확인했습니다.
최종 리포트 (.mam/jobs/b8682c96/codex-reports/report-final.md)
Worked for 5m 5s · done 2:00 AM
› Ask Codex to do anything
gpt-6-astra high · ~/PuKi/laa/canary_projects/multi-agent-mux · Complete…
+16
View File
@@ -0,0 +1,16 @@
# captured 2026-09-25 from an idle agent, input box empty: herdr 0.9.1 `herdr agent read reviewer-grok-01` (last 15 lines)
Worked for 2m54s
█
Help improve Grok [Opt out] [Opt in]
Off by default. Opt-in to allow SpaceXAI to retain coding data, e.g.,
prompts, traces, & metrics, for training and debugging purposes. Change
anytime via settings.
Read Terms and Privacy Policy.
╭──────────────────────────────────────────────────────────────────────╮
│ ❯ │
╰─────────────────────────────────── Grok 4.7 (high) · always-approve ─╯
Shift+Tab:mode │ Ctrl+.:shortcuts
+16
View File
@@ -0,0 +1,16 @@
# captured 2026-09-25 from an idle agent, input box empty: herdr 0.9.1 `herdr agent read reviewer-opencode-01` (last 15 lines)
유출시키고 있습니다. 하위 워크스페이스 접두사가 테스트 export와 일치하는 pane에서 실행될 때 H-21이 잘못
실패합니다. 이는 제 자신의 w2 pane에서 재현되었으며, 환경을 제거한 상태로 재실행하면 통과됩니다. 한 줄의
unset 수정으로 해결됩니다. MAM 자체 사용 모델(pane에서 에이전트 실행 테스트)에서는 이 문제가 재발
가능합니다.
보고서: .mam/jobs/2986037e/opencode-reports/report-final.md — VERDICT: PASS
▣ Build · GLM 5.3 Cloud · 26m 12s
┃
┃
┃ ~/PuKi/laa/canary_projects/multi-
┃ Build auto · GLM 5.3 Cloud Ollama agent-mux:main
╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
/home/godopu16/PuKi/laa/canary_projects/multi-agent-mux 629.5K ctrl+p commands • OpenCode 1.18.32
+2
View File
@@ -720,6 +720,8 @@ def test_opencode_input_region_properties():
assert adapter.input_prompt == '❯'
assert adapter.input_placeholder == ''
assert adapter.input_rule_pattern == '─{10,}'
# Issue #30: the input box itself is a ┃ block (herdr 0.9.1 capture).
assert adapter.input_block_prefix == '┃'
def test_opencode_verify_artifact_created_at_epoch_filtering(tmp_path):
+565
View File
@@ -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