fix(issue-30-33): review 5ec8ef33 - a multi-line codex draft is not an empty box, blocked notices reach the loop while the job runs, and both clocks are labelled on every close path

- R1 (P1): without rules the input region runs from the last prompt line to
  the end of the pane; only a last line matching the adapter's footer pattern
  (codex: `  <model> · ~/<cwd> · …`) is dropped, so a draft on a second line
  keeps the nudge withheld.
- R2: while the synchronous submit runs, a relay thread finds the job it
  registers (same session, created since the submit) and prints its
  stalled:/stall_watch: notices; before, they appeared only after the job.
- R3: the broker-unavailable disk wait that runs into the deadline is the wall
  clock: wall_clock_timeout (caller rc stays 3).
- R4: a state_change_seq change seen right before the judgement restarts the
  idle window (decision 9: S seconds of continuous idle).

Regression tests are the reviewer's reproductions, each with its passing
twin; the wrapper-level #30 tests now use this environment's broker instead
of the #18 harness's public default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-09-25 14:01:53 +00:00
1 parent 47e42744e7
commit bf0f5c1014
8 files changed
+269 -12

No files matched your search

@@ -51,6 +51,12 @@ class CodexAgentAdapter(BaseAgentAdapter):
def input_placeholder(self) -> Optional[str]:
return 'Ask Codex to do anything'
@property
def input_stop_pattern(self) -> Optional[str]:
# The status footer under the input line: ` <model> <effort> · <cwd> · …`
# (the ` · ~/` or ` · /` of the cwd keeps a draft line with dots from matching)
return r'^\s+\S.*\s\u00b7\s[~/]'
@property
def resident_children(self) -> tuple:
# Kept alive while idle (measured 2026-09-25).
+3 -1
View File
@@ -164,7 +164,9 @@ class BaseAgentAdapter:
@property
def input_stop_pattern(self) -> Optional[str]:
"""Regex of the status line that ends the input area inside such a block (#30)."""
"""Regex of the status line that ends the input area (#30): inside a
block, the first matching line; below a prompt line (no rules), only
the last non-blank line of the pane."""
return None
@property
+15 -4
View File
@@ -11,7 +11,10 @@ def extract_input_region(pane_text: str, agent_name: str) -> str:
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.
3. an `input_prompt` -> from the last line starting with the prompt to
the end of the pane, minus a last non-blank line matching
`input_stop_pattern` (the status footer). Every other line below the
prompt line is input: a multi-line draft is never cut to its first line.
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
@@ -49,9 +52,17 @@ def extract_input_region(pane_text: str, agent_name: str) -> str:
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 '?']
starts = [i for i, line in enumerate(lines) if line.lstrip().startswith(prompt)]
if starts:
region_lines = [line for line in lines[starts[-1]:]]
while region_lines and not region_lines[-1].strip():
region_lines.pop()
if (len(region_lines) > 1 and adapter.input_stop_pattern
and re.search(adapter.input_stop_pattern, region_lines[-1])):
region_lines.pop()
else:
# No input line at all: report the whole tail as "not empty" (unknown).
region_lines = [pane_text or '?']
frame = adapter.input_frame_chars
if frame:
+27
View File
@@ -20,6 +20,7 @@ import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
@@ -534,6 +535,11 @@ class LoopRunner:
env = dict(os.environ)
if "DELEGATE_JOB_PYTHON" not in env:
env["DELEGATE_JOB_PYTHON"] = sys.executable
stop = threading.Event()
relay = threading.Thread(target=self._relay_while_submitting,
args=(self.session_label(agent_session), job_registry._utcnow(), stop),
daemon=True)
relay.start()
try:
return subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root), env=env)
except OSError as e:
@@ -543,6 +549,8 @@ class LoopRunner:
f"(prompt {len(p_text.encode('utf-8'))} bytes, wrapper {wrapper_script})")
return subprocess.CompletedProcess(cmd, 126, "", msg)
finally:
stop.set()
relay.join(timeout=5)
try:
os.unlink(prompt_file)
except OSError:
@@ -652,6 +660,25 @@ class LoopRunner:
log_error(f"Job '{job_id}' timed out after {max_wait}s.")
return False
def _relay_while_submitting(self, label: str, since: str, stop: threading.Event) -> None:
"""Issue #30 (review R2): the wrapper runs until its job ends and its
output is captured, so a blocked agent would only reach the loop log
after the job. While the submit runs, find the job it registers (this
session, created since the submit started) and relay its notices."""
jid = None
while not stop.wait(1.0):
if jid is None:
try:
jobs = job_registry.list_jobs(str(self.jobs_dir))
except (OSError, ValueError):
continue
mine = [r for r in jobs if r.get("agent_session") == label
and str(r.get("created_at") or "") >= since]
if not mine:
continue
jid = str(mine[-1].get("job_id"))
self._relay_stall_warnings(jid)
def _relay_stall_warnings(self, job_id: str) -> None:
"""Issue #30: surface the stall watcher's progress events (detail
`stalled:...` / `stall_watch:...`) in the loop log, once per detail.
+13 -5
View File
@@ -307,11 +307,13 @@ class StallWatch(object):
st.blocked_notified = False
sig.agent_status = new
try:
self._act(sig, idle_since)
idle_since = self._act(sig, idle_since)
except HerdrError as exc:
log.warning("stall_watch: herdr call failed while judging: %s", exc.code)
def _act(self, sig, idle_since):
"""Notify/nudge per `judge`; returns the idle start to keep (restarted
when a transition was missed between the wait and the judgement)."""
st, agent = self.st, self.args.agent
if sig.agent_status == "blocked" and not st.blocked_notified:
sig.blocked_text = self.src.blocked_text(sig.pane_id or self.src.agent_get().get("pane_id", ""))
@@ -319,16 +321,21 @@ class StallWatch(object):
log.warning("stall_watch: agent %s is blocked: %s", self.args.target, sig.blocked_text)
self._notify(("stalled:blocked " + sig.blocked_text).strip())
st.blocked_notified = True
return
return idle_since
if sig.agent_status not in IDLE_STATES or not st.reliable or st.nudged:
return
return idle_since
sig.idle_for = time.monotonic() - idle_since
if sig.idle_for < st.idle_s:
return
return idle_since
seq = sig.state_change_seq
self.src.collect(sig)
if seq is not None and sig.state_change_seq is not None and sig.state_change_seq != seq:
return # a transition we did not see: the next wait re-observes
# Review R4: a transition we did not see. Decision 9 wants S seconds
# of *continuous* idle, so the window restarts from this observation
# (collect() already took the new seq as the baseline).
log.info("stall_watch: state_change_seq %s -> %s since the idle start; idle window restarts",
seq, sig.state_change_seq)
return time.monotonic()
if sig.background_children:
log.debug("stall_watch: idle but background children %s", sig.background_children)
elif sig.input_empty is None:
@@ -345,6 +352,7 @@ class StallWatch(object):
self._notify("stalled:idle")
st.nudged = True
self.src.prompt(NUDGE.format(job=self.args.job))
return idle_since
def main(argv=None) -> int:
@@ -896,6 +896,8 @@ finalize_unfinished() {
break
fi
done
# Issue #33 (review R3): a wait that ran into the deadline is the wall clock.
[ "$(date +%s)" -ge "$deadline" ] && FINALIZE_CLOCK="wall"
fi
# timeout: if subscriber timed out, check if agent is still actively working in herdr