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
+23
-3
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user