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
@@ -59,11 +59,16 @@ def main():
|
||||
print(f"MAM_INPUT_PROMPT={q(adapter.input_prompt or '')}")
|
||||
print(f"MAM_INPUT_PLACEHOLDER={q(adapter.input_placeholder or '')}")
|
||||
print(f"MAM_INPUT_RULE_PATTERN={q(adapter.input_rule_pattern or '')}")
|
||||
print(f"MAM_INPUT_FRAME_CHARS={q(adapter.input_frame_chars)}")
|
||||
print(f"MAM_INPUT_BLOCK_PREFIX={q(adapter.input_block_prefix or '')}")
|
||||
print(f"MAM_INPUT_STOP_PATTERN={q(adapter.input_stop_pattern or '')}")
|
||||
print(f"MAM_READY_TOKENS={q(adapter.ready_tokens)}")
|
||||
print(f"MAM_STRONG_READY_TOKENS={q(adapter.strong_ready_tokens)}")
|
||||
print(f"MAM_WEAK_READY_TOKENS={q(adapter.weak_ready_tokens)}")
|
||||
print(f"MAM_MODAL_TOKENS={q(adapter.modal_tokens or '')}")
|
||||
print(f"MAM_STARTUP_DIALOGS={q(json.dumps(adapter.startup_dialogs))}")
|
||||
print(f"MAM_RESIDENT_CHILDREN={q(json.dumps(list(adapter.resident_children)))}")
|
||||
print(f"MAM_AGENT_STATUS_RELIABLE={1 if adapter.agent_status_reliable else 0}")
|
||||
print(f"MAM_EXIT_KEY={q(adapter.exit_key)}")
|
||||
print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}")
|
||||
|
||||
|
||||
@@ -42,10 +42,20 @@ class CodexAgentAdapter(BaseAgentAdapter):
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
return ('session_id',)
|
||||
|
||||
@property
|
||||
def input_prompt(self) -> Optional[str]:
|
||||
# herdr 0.9.1 capture: one `› Ask Codex to do anything` line, no rules.
|
||||
return '›'
|
||||
|
||||
@property
|
||||
def input_placeholder(self) -> Optional[str]:
|
||||
return 'Ask Codex to do anything'
|
||||
|
||||
@property
|
||||
def resident_children(self) -> tuple:
|
||||
# Kept alive while idle (measured 2026-09-25).
|
||||
return ('codex-code-mode-host',)
|
||||
|
||||
def _codex_home(self, ctx: DiscoveryContext) -> str:
|
||||
return os.environ.get('CODEX_HOME') or f"{ctx.home_dir}/.codex"
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ class GrokAgentAdapter(BaseAgentAdapter):
|
||||
def input_rule_pattern(self) -> str:
|
||||
return '─{10,}'
|
||||
|
||||
@property
|
||||
def input_frame_chars(self) -> str:
|
||||
# The input box is drawn as ╭─╮ / │ ❯ │ / ╰─╯ (herdr 0.9.1 capture).
|
||||
return '│'
|
||||
|
||||
def _ws_dir(self, ctx: DiscoveryContext) -> str:
|
||||
return quote(os.path.realpath(ctx.cwd), safe='')
|
||||
|
||||
|
||||
@@ -45,8 +45,20 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
|
||||
@property
|
||||
def input_rule_pattern(self) -> Optional[str]:
|
||||
# Kept for lib.sh's readiness corroboration; the input box itself is a
|
||||
# ┃ block (input_block_prefix), which the extractor reads first.
|
||||
return '─{10,}'
|
||||
|
||||
@property
|
||||
def input_block_prefix(self) -> Optional[str]:
|
||||
return '┃'
|
||||
|
||||
@property
|
||||
def input_stop_pattern(self) -> Optional[str]:
|
||||
# The input area ends at the right-aligned cwd line (a long run of
|
||||
# blanks after the ┃) or the `Build auto · <model>` status line.
|
||||
return r'^\s{20,}\S|^\s*(Build|Plan)\b.*·'
|
||||
|
||||
def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]:
|
||||
# 1. Try opencode CLI "opencode db path"
|
||||
try:
|
||||
|
||||
@@ -152,6 +152,34 @@ class BaseAgentAdapter:
|
||||
def input_rule_pattern(self) -> Optional[str]:
|
||||
return None
|
||||
|
||||
@property
|
||||
def input_frame_chars(self) -> str:
|
||||
"""Box-border characters stripped from both ends of an input line (#30)."""
|
||||
return ''
|
||||
|
||||
@property
|
||||
def input_block_prefix(self) -> Optional[str]:
|
||||
"""Line prefix of an input box drawn as a block instead of between rules (#30)."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def input_stop_pattern(self) -> Optional[str]:
|
||||
"""Regex of the status line that ends the input area inside such a block (#30)."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def resident_children(self) -> tuple:
|
||||
"""Process names this CLI keeps as children while idle (#30). The stall
|
||||
watcher does not count them as background work."""
|
||||
return ()
|
||||
|
||||
@property
|
||||
def agent_status_reliable(self) -> bool:
|
||||
"""Whether herdr's agent_status for this CLI can drive the idle judgement
|
||||
(#30). On herdr 0.9.1 claude, agy, grok, opencode and codex all report
|
||||
working/idle transitions; a False here turns the idle nudge off."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def modal_tokens(self) -> Optional[str]:
|
||||
return None
|
||||
|
||||
@@ -5,33 +5,60 @@ from lib_py.agents.registry import get_adapter
|
||||
|
||||
def extract_input_region(pane_text: str, agent_name: str) -> str:
|
||||
"""
|
||||
Extracts the input area text between horizontal rule separators (─{10,}) for a given agent.
|
||||
If rules are absent, falls back to full pane_text to support mocks and unruled panes.
|
||||
Strips leading prompt tokens ('>', '❯') and placeholders ('Ask anything...').
|
||||
Raises ValueError for unsupported agents (like hermes with no input area facts).
|
||||
Extracts the input area text of a pane, using only the agent adapter's facts
|
||||
(issue #30, measured on herdr 0.9.1 captures in tests/fixtures/panes):
|
||||
|
||||
1. an `input_block_prefix` -> the last contiguous block of lines starting
|
||||
with it, prefix removed, cut at the first `input_stop_pattern` line;
|
||||
2. two or more `input_rule_pattern` rules -> the lines between the last two;
|
||||
3. an `input_prompt` -> the last line starting with the prompt.
|
||||
|
||||
Then `input_frame_chars`, the prompt and the placeholder are stripped. There
|
||||
is no whole-pane fallback: a pane with none of these shapes has no input
|
||||
region and yields a non-empty "unknown" result rather than a guess.
|
||||
Raises ValueError for agents without input area facts (like hermes' peers).
|
||||
"""
|
||||
adapter = get_adapter(agent_name)
|
||||
if not adapter or not adapter.input_prompt:
|
||||
raise ValueError(f"Agent {agent_name!r} has no input area facts")
|
||||
|
||||
rule_pattern = adapter.input_rule_pattern or r'─{10,}'
|
||||
rule_re = re.compile(rule_pattern)
|
||||
lines = pane_text.splitlines()
|
||||
rule_indices = [i for i, line in enumerate(lines) if rule_re.search(line)]
|
||||
prompt = adapter.input_prompt
|
||||
region_lines = None
|
||||
|
||||
if len(rule_indices) >= 2:
|
||||
# Exclude the rule lines themselves: lines between rule_indices[-2] + 1 and rule_indices[-1]
|
||||
region_lines = lines[rule_indices[-2] + 1:rule_indices[-1]]
|
||||
elif len(rule_indices) == 1:
|
||||
region_lines = lines[rule_indices[0] + 1:]
|
||||
else:
|
||||
region_lines = lines
|
||||
if adapter.input_block_prefix:
|
||||
prefix = adapter.input_block_prefix
|
||||
block = []
|
||||
for line in reversed(lines):
|
||||
if line.lstrip().startswith(prefix):
|
||||
block.insert(0, line.lstrip()[len(prefix):])
|
||||
elif block:
|
||||
break
|
||||
stop_re = re.compile(adapter.input_stop_pattern) if adapter.input_stop_pattern else None
|
||||
for i, line in enumerate(block):
|
||||
if stop_re and stop_re.search(line):
|
||||
block = block[:i]
|
||||
break
|
||||
# No block on screen is not an empty box: fall through to the other shapes.
|
||||
region_lines = block or None
|
||||
|
||||
if region_lines is None and adapter.input_rule_pattern:
|
||||
rule_re = re.compile(adapter.input_rule_pattern)
|
||||
rule_indices = [i for i, line in enumerate(lines) if rule_re.search(line)]
|
||||
if len(rule_indices) >= 2:
|
||||
region_lines = lines[rule_indices[-2] + 1:rule_indices[-1]]
|
||||
|
||||
if region_lines is None:
|
||||
starts = [line for line in lines if line.lstrip().startswith(prompt)]
|
||||
# No input line at all: report the whole tail as "not empty" (unknown).
|
||||
region_lines = starts[-1:] if starts else [pane_text or '?']
|
||||
|
||||
frame = adapter.input_frame_chars
|
||||
if frame:
|
||||
region_lines = [line.strip().strip(frame) for line in region_lines]
|
||||
|
||||
region_text = "\n".join(region_lines)
|
||||
|
||||
prompt = adapter.input_prompt
|
||||
if prompt:
|
||||
region_text = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_text, flags=re.MULTILINE)
|
||||
region_text = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_text, flags=re.MULTILINE)
|
||||
|
||||
placeholder = adapter.input_placeholder
|
||||
if placeholder:
|
||||
|
||||
Reference in new issue
Block a user