fix(issue-14): keep the idle-timeout working grace alive on the pane busy token
The R1 grace after a subscriber idle timeout only asked herdr whether the agent was still `working`. herdr never reports that for codex, so a codex review longer than the idle timeout was closed as deadline_timeout mid-turn and its eventual `completed` refused - the same status blindness behind the agent_blocked misjudgement. `_agent_still_working` now also accepts the pane's own busy token, the evidence send_keys_safe already uses to confirm delivery. Adds a wrapper test where a codex-like agent (idle per herdr, busy per pane) idles out the subscriber and completes out of band afterwards. Refs #14 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
1 parent
4b3134554a
commit
eaa49aaafe
2 files changed
+77
-27
No files matched your search
@@ -585,6 +585,17 @@ _inject_acknowledged() {
|
||||
done
|
||||
}
|
||||
|
||||
# ISSUE-14: herdr's agent_status is blind for some agents (codex stays `idle`
|
||||
# for the whole turn), so "still working" must also accept the pane's own busy
|
||||
# token - the same evidence send_keys_safe uses to confirm delivery. Without it
|
||||
# a codex review longer than the idle timeout was closed as deadline_timeout
|
||||
# mid-turn and its eventual `completed` refused.
|
||||
_agent_still_working() {
|
||||
local sess="$1" target="$2"
|
||||
[ "$(_sks_agent_status "$sess" "$target")" = "working" ] && return 0
|
||||
_pane_tail "$sess" 8 2>/dev/null | grep -Fq "esc to interrupt"
|
||||
}
|
||||
|
||||
_map_sub_rc() {
|
||||
local sub_rc="$1" job_id="$2"
|
||||
local job_status="running"
|
||||
@@ -661,23 +672,12 @@ finalize_unfinished() {
|
||||
local sess="${target_sess#herdr:}"
|
||||
local target_agent
|
||||
target_agent=$(_sanitize_herdr_agent_name "$sess")
|
||||
local agent_st=""
|
||||
local still_working=0
|
||||
if [ "$sess" != "human" ] && [ -n "$sess" ]; then
|
||||
local raw_st
|
||||
raw_st="$(_sks_herdr agent get "$target_agent" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
|
||||
if [ -n "$raw_st" ]; then
|
||||
agent_st="$("$(_delegate_py_bin)" -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' <<< "$raw_st" 2>/dev/null || true)"
|
||||
fi
|
||||
_agent_still_working "$sess" "$target_agent" && still_working=1
|
||||
fi
|
||||
|
||||
if [ "$agent_st" = "working" ]; then
|
||||
if [ "$still_working" = "1" ]; then
|
||||
local now elapsed remaining
|
||||
now=$(date +%s)
|
||||
elapsed=$(( now - ${START_TIME:-now} ))
|
||||
@@ -692,19 +692,7 @@ except Exception:
|
||||
[ "$disk_st" = "completed" ] && return 0
|
||||
break
|
||||
fi
|
||||
local cur_raw cur_st=""
|
||||
cur_raw="$(_sks_herdr agent get "$target_agent" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
|
||||
if [ -n "$cur_raw" ]; then
|
||||
cur_st="$("$(_delegate_py_bin)" -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' <<< "$cur_raw" 2>/dev/null || true)"
|
||||
fi
|
||||
if [ "$cur_st" != "working" ]; then
|
||||
if ! _agent_still_working "$sess" "$target_agent"; then
|
||||
sleep 1
|
||||
disk_st="$(_disk_status "$job_id")"
|
||||
if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then
|
||||
|
||||
@@ -240,3 +240,65 @@ def test_wrapper_honours_registry_ack_over_unconfirmed_inject(tmp_path, mock_her
|
||||
assert "agent_blocked" not in events_log.read_text(), events_log.read_text()
|
||||
job_data = json.loads((reg_dir / f"{jid}.json").read_text())
|
||||
assert job_data.get("status") != "pending"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wrapper: idle-timeout grace (R1) must see a working codex through the pane
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_wrapper_idle_timeout_grace_accepts_pane_busy_token(tmp_path, mock_herdr):
|
||||
"""herdr keeps reporting `idle` for a codex-like agent that is visibly
|
||||
working (busy token in the pane). When the subscriber idles out, the
|
||||
wrapper must keep waiting for the on-disk result instead of closing the
|
||||
job as deadline_timeout mid-turn; the agent then completes out of band."""
|
||||
reg_dir = tmp_path / "jobs"
|
||||
reg_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
state_file = Path(mock_herdr)
|
||||
state = json.loads(state_file.read_text())
|
||||
# `timeout` from agent prompt keeps agent_status idle; the mock appends the
|
||||
# busy token to the pane on submission, exactly like a codex turn.
|
||||
state["agents"]["slow-codex"] = {
|
||||
"agent": "codex",
|
||||
"agent_status": "idle",
|
||||
"prompt_wait_result": "timeout",
|
||||
"buffer": "Ready",
|
||||
}
|
||||
state_file.write_text(json.dumps(state, indent=2))
|
||||
|
||||
env = _base_env(tmp_path, {"SKS_IDLE_TIMEOUT_MS": "500", "SKS_DELIVERY_GRACE_S": "5"})
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
"bash", str(WRAPPER), "submit",
|
||||
"--agent", "codex",
|
||||
"--agent-session", "herdr:slow-codex",
|
||||
"--prompt", "issue 14 idle grace",
|
||||
"--registry-dir", str(reg_dir),
|
||||
"--workdir", str(tmp_path),
|
||||
"--timeout", "40",
|
||||
"--idle-timeout", "3",
|
||||
],
|
||||
cwd=str(tmp_path), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
)
|
||||
|
||||
jid = ""
|
||||
for _ in range(100):
|
||||
job_files = list(reg_dir.glob("*.json"))
|
||||
if job_files:
|
||||
jid = job_files[0].stem
|
||||
break
|
||||
time.sleep(0.1)
|
||||
assert jid, "job was not registered"
|
||||
|
||||
# Well past the 3 s idle timeout, the agent finishes.
|
||||
time.sleep(14)
|
||||
done = subprocess.run(
|
||||
[sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg_dir),
|
||||
"update", "--job", jid, "--status", "completed"],
|
||||
capture_output=True, text=True, cwd=str(tmp_path), env=env,
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
|
||||
stdout, stderr = proc.communicate(timeout=90)
|
||||
job_data = json.loads((reg_dir / f"{jid}.json").read_text())
|
||||
assert job_data.get("status") == "completed", f"status={job_data.get('status')}\n{stdout}\n{stderr}"
|
||||
assert proc.returncode == 0, f"expected rc=0 after the working grace, got {proc.returncode}\n{stdout}\n{stderr}"
|
||||
Reference in new issue
Block a user