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:
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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user