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:
GodopuandClaude Fable 5.1 committed 2026-09-12 23:26:30 +09:00
1 parent 4b3134554a
commit eaa49aaafe
2 files changed
+77 -27

No files matched your search

@@ -585,6 +585,17 @@ _inject_acknowledged() {
done 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() { _map_sub_rc() {
local sub_rc="$1" job_id="$2" local sub_rc="$1" job_id="$2"
local job_status="running" local job_status="running"
@@ -661,23 +672,12 @@ finalize_unfinished() {
local sess="${target_sess#herdr:}" local sess="${target_sess#herdr:}"
local target_agent local target_agent
target_agent=$(_sanitize_herdr_agent_name "$sess") target_agent=$(_sanitize_herdr_agent_name "$sess")
local agent_st="" local still_working=0
if [ "$sess" != "human" ] && [ -n "$sess" ]; then if [ "$sess" != "human" ] && [ -n "$sess" ]; then
local raw_st _agent_still_working "$sess" "$target_agent" && still_working=1
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
fi fi
if [ "$agent_st" = "working" ]; then if [ "$still_working" = "1" ]; then
local now elapsed remaining local now elapsed remaining
now=$(date +%s) now=$(date +%s)
elapsed=$(( now - ${START_TIME:-now} )) elapsed=$(( now - ${START_TIME:-now} ))
@@ -692,19 +692,7 @@ except Exception:
[ "$disk_st" = "completed" ] && return 0 [ "$disk_st" = "completed" ] && return 0
break break
fi fi
local cur_raw cur_st="" if ! _agent_still_working "$sess" "$target_agent"; then
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
sleep 1 sleep 1
disk_st="$(_disk_status "$job_id")" disk_st="$(_disk_status "$job_id")"
if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then
+62
View File
@@ -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() assert "agent_blocked" not in events_log.read_text(), events_log.read_text()
job_data = json.loads((reg_dir / f"{jid}.json").read_text()) job_data = json.loads((reg_dir / f"{jid}.json").read_text())
assert job_data.get("status") != "pending" 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}"