diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index fe26298..49d9751 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -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 diff --git a/tests/test_issue14_delivery_ack.py b/tests/test_issue14_delivery_ack.py index 219740a..99ba462 100644 --- a/tests/test_issue14_delivery_ack.py +++ b/tests/test_issue14_delivery_ack.py @@ -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}"