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",