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

+184 -2
View File
@@ -486,6 +486,14 @@ def test_scoped_env_drops_the_pane_inherited_workspace_for_name_lookups_only():
import test_issue18_liveness_contract as t18 # noqa: E402
LIVE_BROKER = ("MQTT_BROKER", "MQTT_PORT", "MQTT_TLS", "MQTT_USERNAME", "MQTT_PASSWORD")
def _live_broker() -> dict:
"""This environment's broker (the #18 harness defaults to a public one)."""
return {k: os.environ[k] for k in LIVE_BROKER if k in os.environ}
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()]
@@ -497,7 +505,7 @@ def test_s9_the_wrapper_runs_the_watcher_for_the_jobs_life(tmp_path, mock_herdr,
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)
env_extra={"MAM_STALL_WATCH": switch, **_live_broker()}, timeout_s=60)
jid = t18._wait_job_id(reg)
try:
time.sleep(1)
@@ -523,7 +531,7 @@ 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"})
env_extra={"MAM_DISPATCH_SECRET": "s10-secret", **_live_broker()})
jid = t18._wait_job_id(reg)
try:
time.sleep(1)
@@ -563,3 +571,177 @@ def test_s11_the_runner_warns_once_per_stall_notice(mam_sandbox, monkeypatch, ca
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
# ---------------------------------------------------------------------------
# Review 5ec8ef33 (planner-reviewer-codex-01) regressions, taken from the
# reviewer's independent reproductions (codex-reports/test_review_regressions.py)
# R1 multi-line codex draft, R2 live relay during the synchronous submit,
# R4 continuous idle after a missed transition. Each has its passing twin.
# ---------------------------------------------------------------------------
import textwrap # noqa: E402
@pytest.mark.parametrize("typing", [False, True])
def test_r1_a_multi_line_codex_draft_is_not_an_empty_box(mam_sandbox, mock_herdr, typing):
reg = mam_sandbox / ".mam" / "jobs"
env = _env(mam_sandbox, MAM_STALL_IDLE_S=1)
jid = _job(env, reg)
agent = _proc("exec sleep 300")
w = None
try:
pane = _pane("codex")
if typing:
pane = pane.replace("› Ask Codex to do anything", "›\n draft on the second input line")
_agent(mock_herdr, "reviewer-codex-t", "codex", agent.pid, "idle", pane)
w = _watch(env, reg, jid, "reviewer-codex-t", "codex")
if typing:
time.sleep(5)
else:
assert _until(lambda: _prompts(mock_herdr))
prompts = _prompts(mock_herdr)
_finish(env, reg, jid, w)
assert len(prompts) == (0 if typing else 1), prompts
finally:
if w is not None and w.poll() is None:
w.terminate()
w.communicate(timeout=10)
_kill(agent)
@pytest.mark.parametrize("blocked", [False, True])
def test_r2_the_loop_relays_a_blocked_notice_while_the_job_is_open(mam_sandbox, mock_herdr, blocked):
reg = mam_sandbox / ".mam" / "jobs"
reg.mkdir(parents=True, exist_ok=True)
t18._headless_agent(mock_herdr, "headless-codex")
env = t18._env(mam_sandbox, {"MAM_STALL_WATCH": "1", "MAM_STALL_IDLE_S": "600",
"SKS_IDLE_TIMEOUT_MS": "500", "SKS_EMPTY_GIVEUP": "1",
"MAM_ACK_TIMEOUT_S": "40", "MAM_JOBS_DIR": str(reg),
"PYTHONPATH": str(SKILLS), "PYTHONUNBUFFERED": "1"})
env.update(_live_broker())
script = '''from lib_py.loop.runner import LoopRunner
r = LoopRunner(["--creator", "headless-codex", "--task", "review check", "--job-timeout", "60"])
r.parse_args()
jid = r.delegate_job("headless-codex", "codex", "Worker", "review check")
assert jid
assert r.wait_for_job(jid, max_wait=10)
'''
p = subprocess.Popen([sys.executable, "-c", script], cwd=mam_sandbox, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
jid = None
try:
jid = t18._wait_job_id(reg)
time.sleep(1)
assert t18._publish(reg, jid, "started", env).returncode == 0
assert _until(lambda: (reg / f"{jid}.stall.out").exists())
state = json.loads(mock_herdr.read_text())
state["wait_blocks_s"] = 0.3
state["agents"]["headless-codex"].update(
agent_status="blocked" if blocked else "working", pane_id="w1:p1",
detection="Approval needed. Do you want to proceed?")
mock_herdr.write_text(json.dumps(state))
if blocked:
assert _until(lambda: any(d.startswith("stalled:blocked") for d in _details(reg, jid)), 40)
time.sleep(1)
assert _record(reg, jid)["status"] == "running"
os.set_blocking(p.stdout.fileno(), False)
live = bytearray()
while True:
try:
chunk = os.read(p.stdout.fileno(), 65536)
except BlockingIOError:
break
if not chunk:
break
live.extend(chunk)
os.set_blocking(p.stdout.fileno(), True)
assert t18._publish(reg, jid, "completed", env).returncode == 0
tail, _ = p.communicate(timeout=60)
assert p.returncode == 0, (bytes(live) + tail).decode(errors="replace")
assert (b"stalled:blocked" in live) is blocked, {
"while_running": bytes(live).decode(errors="replace"),
"after_completion": tail.decode(errors="replace"),
}
finally:
if p.poll() is None:
p.kill()
p.communicate(timeout=10)
@pytest.mark.parametrize("missed_transition", [False, True])
def test_r4_a_missed_transition_restarts_the_idle_window(mam_sandbox, mock_herdr, missed_transition):
"""Real watcher/clock/process tree; an external herdr stub models a missed transition.
The wait/get agent snapshots include state_change_seq, unlike the repository's
wait mock. process-info retains its measured nested result shape.
"""
reg = mam_sandbox / ".mam" / "jobs"
env = _env(mam_sandbox, MAM_STALL_IDLE_S=10)
jid = _job(env, reg)
agent = _proc("exec sleep 300")
observed = mam_sandbox / "observed.json"
observed.write_text(json.dumps({"seq": 0, "miss": missed_transition, "pid": agent.pid,
"pane": _pane("codex")}))
shim = mam_sandbox / "bin" / "herdr"
shim.write_text(textwrap.dedent('''\
#!/usr/bin/env python3
import json, sys, time
from pathlib import Path
f = Path(__file__).parent.parent / 'observed.json'
state = json.loads(f.read_text())
args = sys.argv[1:]
if args[:2] == ['agent', 'wait']:
if not state.get('started'):
state['started'] = time.monotonic()
f.write_text(json.dumps(state))
print(json.dumps({'result': {'agent': {'agent_status': 'idle', 'state_change_seq': 0, 'pane_id': 'w1:p1'}}}))
else:
time.sleep(int(args[args.index('--timeout') + 1]) / 1000)
print(json.dumps({'error': {'code': 'timeout'}}), file=sys.stderr)
sys.exit(1)
elif args[:2] == ['agent', 'get']:
if state['miss'] and not state.get('changed'):
state['changed'] = time.monotonic()
state['seq'] = 2
f.write_text(json.dumps(state))
print(json.dumps({'result': {'agent': {'agent_status': 'idle', 'state_change_seq': state['seq'], 'pane_id': 'w1:p1'}}}))
elif args[:2] == ['pane', 'process-info']:
print(json.dumps({'result': {'type': 'pane_process_info', 'process_info': {
'pane_id': 'w1:p1', 'shell_pid': 1, 'foreground_process_group_id': state['pid'],
'foreground_processes': [{'pid': state['pid'], 'name': 'codex', 'argv': ['codex'], 'cmdline': 'codex', 'cwd': '/tmp'}]}}}))
elif args[:2] == ['agent', 'read']:
print(state['pane'])
elif args[:2] == ['agent', 'prompt']:
state['prompted'] = time.monotonic()
f.write_text(json.dumps(state))
print('{}')
else:
sys.exit(2)
'''))
shim.chmod(0o755)
w = None
try:
w = _watch(env, reg, jid, "reviewer-codex-t", "codex")
assert _until(lambda: 'prompted' in json.loads(observed.read_text()), 90)
times = json.loads(observed.read_text())
# It may still be in a 30-second herdr wait after sending the prompt.
_publish(env, reg, jid, 'completed')
w.terminate()
w.communicate(timeout=10)
idle_time = times['prompted'] - times.get('changed', times['started'])
assert idle_time >= 10, {'continuous_idle_seconds': idle_time, 'threshold': 10}
finally:
if w is not None and w.poll() is None:
w.terminate()
w.communicate(timeout=10)
_kill(agent)
def test_r1_the_codex_extractor_keeps_every_line_under_the_prompt():
from lib_py.agents.input_region import extract_input_region
idle = _pane("codex")
assert extract_input_region(idle, "codex") == ""
draft = idle.replace("› Ask Codex to do anything", "›\n draft on the second input line")
assert extract_input_region(draft, "codex") == "draft on the second input line"
no_footer = "\n".join(idle.splitlines()[:-1]).replace("› Ask Codex to do anything", "›\n a · b · c")
assert extract_input_region(no_footer, "codex") != "", "only the last line may be the footer"