fix(issue-9,14): bound the R1 grace by the wall clock and keep other ls failures unknown
Round-2 review ofd5a191c(planner-reviewer-claude-01, planner-reviewer-codex-01). - delegate wrapper: the R1 idle grace and the broker_unavailable re-check counted only their sleeps against TIMEOUT, so python/herdr probe time plus fresh disk heartbeats let a job run far past --timeout (55 s for a 20 s budget in the new test). Both loops now end at START_TIME + TIMEOUT read off the clock on every pass; heartbeats extend only the idle window, and the herdr probe is cut off at the deadline (_within_deadline) - document the window policyd5a191cactually implements: an agent with no evidence is closed ~2 x IDLE_TIMEOUT after its last wire event (Issue #6 promised IDLE_TIMEOUT + 5 s) and heartbeats may be ~2 x IDLE_TIMEOUT apart; correct the claim that publish_event records a heartbeat on disk before the wire publish - it records right after the publish attempt - shim ls: every failed `agent list` other than a structured server_not_running now exits nonzero with a "state unknown" diagnostic instead of reaching the list parser, where an error document on stdout read as an empty list and let drift A terminate live rows - tests: stdout variants for down and unrelated errors, reconcile keeping herdr_confirmed False on an unrelated failure, and a wall-clock upper bound under continuous heartbeats and slow probes; the three new checks fail ond5a191c- VERSIONS.md v4.4.2: items 3 and 6 and the test count (19) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
This commit is contained in:
1 parent
d5a191c47c
commit
952657efb4
5 files changed
+221
-77
No files matched your search
+10
-1
@@ -1249,7 +1249,9 @@ except Exception:
|
|||||||
# error.code == "server_not_running" means "down". A successful list can
|
# error.code == "server_not_running" means "down". A successful list can
|
||||||
# legitimately contain that text (an agent named server_not_running-codex),
|
# legitimately contain that text (an agent named server_not_running-codex),
|
||||||
# and reading it as "down" made reconcile terminate every live row on that
|
# and reading it as "down" made reconcile terminate every live row on that
|
||||||
# server. Any other failure falls through to the unknown path below.
|
# server. Any other failed call is "unknown": it exits nonzero below
|
||||||
|
# without its output ever reaching the list parser, so an error document on
|
||||||
|
# stdout cannot pass for an empty list either.
|
||||||
_ls_err=$(mktemp 2>/dev/null || echo "/tmp/mam_ls_err_$$")
|
_ls_err=$(mktemp 2>/dev/null || echo "/tmp/mam_ls_err_$$")
|
||||||
_ls_rc=0
|
_ls_rc=0
|
||||||
_ls_raw="$(_real_herdr agent list 2>"$_ls_err")" || _ls_rc=$?
|
_ls_raw="$(_real_herdr agent list 2>"$_ls_err")" || _ls_rc=$?
|
||||||
@@ -1281,6 +1283,13 @@ print(down)
|
|||||||
echo "no server running (herdr session ${_sess:-default})" >&2
|
echo "no server running (herdr session ${_sess:-default})" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [ "$_ls_rc" -ne 0 ]; then
|
||||||
|
# Unknown, never empty: keep the nonzero status and word the diagnostic
|
||||||
|
# so it matches none of reconcile.sh's empty-server phrases ("no server
|
||||||
|
# running", "no sessions", "failed to connect").
|
||||||
|
echo "herdr agent list returned rc=$_ls_rc (herdr session ${_sess:-default}); state unknown" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
printf '%s\n' "$_ls_raw" | \
|
printf '%s\n' "$_ls_raw" | \
|
||||||
MAM_LS_FORMAT="$format" MAM_REAL_HERDR="$REAL_HERDR" MAM_LS_SESSION="$_sess" \
|
MAM_LS_FORMAT="$format" MAM_REAL_HERDR="$REAL_HERDR" MAM_LS_SESSION="$_sess" \
|
||||||
TZ=UTC LC_ALL=C python3 -c '
|
TZ=UTC LC_ALL=C python3 -c '
|
||||||
|
|||||||
@@ -599,12 +599,35 @@ _agent_still_working() {
|
|||||||
_pane_tail "$sess" 8 2>/dev/null | grep -Fq "esc to interrupt"
|
_pane_tail "$sess" 8 2>/dev/null | grep -Fq "esc to interrupt"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# _within_deadline <deadline_epoch> <cmd...>
|
||||||
|
# Run a probe that can block (a herdr RPC, a pane capture) without letting it
|
||||||
|
# outlive a wall-clock deadline: once the clock reaches <deadline> the probe
|
||||||
|
# and its children are killed and the result is rc 124, i.e. "no evidence".
|
||||||
|
_within_deadline() {
|
||||||
|
local deadline="$1" pid
|
||||||
|
shift
|
||||||
|
"$@" &
|
||||||
|
pid=$!
|
||||||
|
while kill -0 "$pid" 2>/dev/null; do
|
||||||
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||||
|
pkill -P "$pid" 2>/dev/null || true
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
wait "$pid" 2>/dev/null || true
|
||||||
|
return 124
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
wait "$pid"
|
||||||
|
}
|
||||||
|
|
||||||
# ISSUE-14: agent-agnostic liveness. publish_event appends every event to the
|
# ISSUE-14: agent-agnostic liveness. publish_event appends every event to the
|
||||||
# job's on-disk events.log before the wire publish, so a `progress` heartbeat
|
# job's on-disk events.log right after its wire publish attempt, whether that
|
||||||
# that the subscriber never saw (it had already idled out, or the broker
|
# publish succeeded or not, so a `progress` heartbeat the subscriber never saw
|
||||||
# dropped it) is still visible here. An agent running its tests in a
|
# (it had already idled out, or the broker dropped it) is still visible here -
|
||||||
# background shell looks idle to herdr and shows no busy token, yet keeps
|
# late by at most its publish retries while the broker is failing. An agent
|
||||||
# heartbeating - this is the only evidence that covers that case.
|
# running its tests in a background shell looks idle to herdr and shows no
|
||||||
|
# busy token, yet keeps heartbeating - this is the only evidence that covers
|
||||||
|
# that case.
|
||||||
_recent_disk_heartbeat() {
|
_recent_disk_heartbeat() {
|
||||||
local job_id="$1" window="$2" f="$REGISTRY_DIR/$job_id.events.log"
|
local job_id="$1" window="$2" f="$REGISTRY_DIR/$job_id.events.log"
|
||||||
[ -f "$f" ] || return 1
|
[ -f "$f" ] || return 1
|
||||||
@@ -672,19 +695,18 @@ finalize_unfinished() {
|
|||||||
else
|
else
|
||||||
FINAL_JOB_STATUS="$job_status"
|
FINAL_JOB_STATUS="$job_status"
|
||||||
fi
|
fi
|
||||||
# broker_unavailable: re-observe disk for remaining deadline at 3s intervals
|
# broker_unavailable: re-observe the disk at 3 s intervals until this
|
||||||
|
# dispatch's wall-clock deadline (START_TIME + TIMEOUT). The deadline is read
|
||||||
|
# off the clock on every pass; summing the sleeps alone ignores the time the
|
||||||
|
# disk probes themselves take and overruns TIMEOUT.
|
||||||
if [ "$job_status" = "broker_unavailable" ]; then
|
if [ "$job_status" = "broker_unavailable" ]; then
|
||||||
local now elapsed remaining
|
local deadline left disk_st
|
||||||
now=$(date +%s)
|
deadline=$(( ${START_TIME:-$(date +%s)} + TIMEOUT ))
|
||||||
elapsed=$(( now - ${START_TIME:-now} ))
|
while :; do
|
||||||
remaining=$(( TIMEOUT - elapsed ))
|
left=$(( deadline - $(date +%s) ))
|
||||||
[ $remaining -lt 0 ] && remaining=0
|
[ "$left" -le 0 ] && break
|
||||||
while [ $remaining -gt 0 ]; do
|
[ "$left" -gt 3 ] && left=3
|
||||||
local wait_interval=3
|
sleep "$left"
|
||||||
[ $remaining -lt $wait_interval ] && wait_interval=$remaining
|
|
||||||
sleep "$wait_interval"
|
|
||||||
remaining=$(( remaining - wait_interval ))
|
|
||||||
local disk_st
|
|
||||||
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
|
||||||
job_status="$disk_st"
|
job_status="$disk_st"
|
||||||
@@ -703,21 +725,23 @@ finalize_unfinished() {
|
|||||||
target_agent=$(_sanitize_herdr_agent_name "$sess")
|
target_agent=$(_sanitize_herdr_agent_name "$sess")
|
||||||
|
|
||||||
# ISSUE-14: the subscriber's idle clock only counts events it received over
|
# ISSUE-14: the subscriber's idle clock only counts events it received over
|
||||||
# the wire. Keep re-observing the disk while ANY liveness evidence is at
|
# the wire. Keep re-observing the disk while liveness evidence keeps
|
||||||
# most IDLE_TIMEOUT old - herdr `working`, the pane busy token, or a fresh
|
# turning up - herdr `working`, the pane busy token, or a heartbeat in
|
||||||
# heartbeat in events.log (see _recent_disk_heartbeat). Each sighting opens
|
# events.log at most IDLE_TIMEOUT old (see _recent_disk_heartbeat).
|
||||||
# a new idle window; an agent that leaves no evidence for a whole window is
|
# Window policy: entering this grace opens one IDLE_TIMEOUT window and
|
||||||
# closed, still inside the wall clock. Before this, one heartbeat gap
|
# every sighting re-opens it from the time of that poll. An agent that
|
||||||
# longer than the idle timeout closed a live review as deadline_timeout
|
# leaves no evidence at all is therefore closed about 2 x IDLE_TIMEOUT
|
||||||
# and its later `completed` was refused.
|
# after its last wire event (the subscriber's idle wait plus this window;
|
||||||
local now elapsed remaining evidence_until
|
# it was IDLE_TIMEOUT before disk heartbeats counted), and heartbeats may
|
||||||
now=$(date +%s)
|
# be up to about 2 x IDLE_TIMEOUT apart, since one still counts until it
|
||||||
elapsed=$(( now - ${START_TIME:-now} ))
|
# is IDLE_TIMEOUT old. Evidence only ever extends the idle window, never
|
||||||
remaining=$(( TIMEOUT - elapsed ))
|
# this dispatch's wall clock: the loop ends at START_TIME + TIMEOUT read
|
||||||
[ $remaining -lt 0 ] && remaining=0
|
# off the clock on every pass, and the herdr probe is cut off at that
|
||||||
evidence_until=$(( now + IDLE_TIMEOUT ))
|
# deadline too (_within_deadline).
|
||||||
while [ $remaining -gt 0 ]; do
|
local deadline evidence_until left disk_st
|
||||||
local disk_st
|
deadline=$(( ${START_TIME:-$(date +%s)} + TIMEOUT ))
|
||||||
|
evidence_until=$(( $(date +%s) + IDLE_TIMEOUT ))
|
||||||
|
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||||
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
|
||||||
job_status="$disk_st"
|
job_status="$disk_st"
|
||||||
@@ -725,16 +749,15 @@ finalize_unfinished() {
|
|||||||
[ "$disk_st" = "completed" ] && return 0
|
[ "$disk_st" = "completed" ] && return 0
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if { [ "$sess" != "human" ] && [ -n "$sess" ] && _agent_still_working "$sess" "$target_agent"; } \
|
if { [ "$sess" != "human" ] && [ -n "$sess" ] && _within_deadline "$deadline" _agent_still_working "$sess" "$target_agent"; } \
|
||||||
|| _recent_disk_heartbeat "$job_id" "$IDLE_TIMEOUT"; then
|
|| _recent_disk_heartbeat "$job_id" "$IDLE_TIMEOUT"; then
|
||||||
evidence_until=$(( $(date +%s) + IDLE_TIMEOUT ))
|
evidence_until=$(( $(date +%s) + IDLE_TIMEOUT ))
|
||||||
elif [ "$(date +%s)" -ge "$evidence_until" ]; then
|
elif [ "$(date +%s)" -ge "$evidence_until" ]; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
local wait_interval=1
|
left=$(( deadline - $(date +%s) ))
|
||||||
[ $remaining -lt $wait_interval ] && wait_interval=$remaining
|
[ "$left" -le 0 ] && break
|
||||||
sleep "$wait_interval"
|
sleep 1
|
||||||
remaining=$(( remaining - wait_interval ))
|
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -44,14 +44,14 @@
|
|||||||
#### 🌟 주요 개선 사항
|
#### 🌟 주요 개선 사항
|
||||||
1. **herdr 서버 기동 env 마커 denylist (Issue #9, `lib.sh` 쉼)**: 쉼이 `herdr --session <name> server` 를 부트스트랩할 때 세션 식별 마커만 제거한 환경을 넘긴다 — `CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_SESSION_ATTENDED`, `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_PID`, `CLAUDE_CODE_MESSAGING_TOKEN`, `CLAUDE_CODE_MESSAGING_SOCKET`, `CLAUDE_CODE_BRIDGE_SESSION_ID`, `ANTIGRAVITY_CONVERSATION_ID`, `HERMES_SESSION_ID`, `GROK_SESSION_ID`, `OPENCODE_SESSION_ID`. `CLAUDE_PROJECT_DIR`·`CLAUDE_CONFIG_DIR` 등 사용자 설정은 그대로 상속된다. 상속된 `CLAUDE_CODE_CHILD_SESSION` 때문에 서브에이전트 claude 의 transcript 저장이 꺼져 resume 이 불가능하던 증상을 해소.
|
1. **herdr 서버 기동 env 마커 denylist (Issue #9, `lib.sh` 쉼)**: 쉼이 `herdr --session <name> server` 를 부트스트랩할 때 세션 식별 마커만 제거한 환경을 넘긴다 — `CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_SESSION_ATTENDED`, `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_PID`, `CLAUDE_CODE_MESSAGING_TOKEN`, `CLAUDE_CODE_MESSAGING_SOCKET`, `CLAUDE_CODE_BRIDGE_SESSION_ID`, `ANTIGRAVITY_CONVERSATION_ID`, `HERMES_SESSION_ID`, `GROK_SESSION_ID`, `OPENCODE_SESSION_ID`. `CLAUDE_PROJECT_DIR`·`CLAUDE_CONFIG_DIR` 등 사용자 설정은 그대로 상속된다. 상속된 `CLAUDE_CODE_CHILD_SESSION` 때문에 서브에이전트 claude 의 transcript 저장이 꺼져 resume 이 불가능하던 증상을 해소.
|
||||||
2. **조회 경로 자동 기동 금지 `MAM_NO_AUTOSTART=1` (Issue #9)**: 이 값이 설정되면 쉼은 서버를 띄우지 않고 호출만 전달한다. `status.sh`·`reconcile.sh` 가 이를 export 하므로 read-only `multi-agent-mux-status` 조회가 더 이상 서버 데몬을 기동하지 않는다.
|
2. **조회 경로 자동 기동 금지 `MAM_NO_AUTOSTART=1` (Issue #9)**: 이 값이 설정되면 쉼은 서버를 띄우지 않고 호출만 전달한다. `status.sh`·`reconcile.sh` 가 이를 export 하므로 read-only `multi-agent-mux-status` 조회가 더 이상 서버 데몬을 기동하지 않는다.
|
||||||
3. **쉼 `ls` 의 "no server running" 번역 (Issue #9)**: 서버가 내려가 있으면 쉼의 `ls` 가 `no server running (herdr session <name>)` 으로 보고하고, `reconcile.sh` 는 이를 빈 서버로 판정해 `herdr_confirmed` 를 유지한다(파싱 실패로 모든 서버의 drift 감지가 멈추는 회귀 방지). 번역은 `agent list` 가 **실패**하고 그 오류 봉투의 `error.code` 가 정확히 `server_not_running` 일 때만 적용한다. 성공한 목록에 같은 문자열이 들어 있거나(예: 에이전트 이름 `server_not_running-codex`) 다른 실패인 경우는 번역하지 않으므로, 살아 있는 서버가 빈 서버로 오판되어 drift A 로 전 세션이 종료 처리되지 않고 그 밖의 실패는 종전대로 unknown(`herdr_confirmed=False`)으로 남는다.
|
3. **쉼 `ls` 의 "no server running" 번역 (Issue #9)**: 서버가 내려가 있으면 쉼의 `ls` 가 `no server running (herdr session <name>)` 으로 보고하고, `reconcile.sh` 는 이를 빈 서버로 판정해 `herdr_confirmed` 를 유지한다(파싱 실패로 모든 서버의 drift 감지가 멈추는 회귀 방지). 번역은 `agent list` 가 **실패**하고 그 오류 봉투(stderr·stdout 어느 쪽이든)의 `error.code` 가 정확히 `server_not_running` 일 때만 적용한다. 성공한 목록에 같은 문자열이 들어 있으면(예: 에이전트 이름 `server_not_running-codex`) 번역하지 않으므로 살아 있는 서버가 빈 서버로 오판되어 drift A 로 전 세션이 종료 처리되지 않는다. 그 밖의 실패는 출력이 목록 파서에 닿기 전에 비-0 으로 끝나(`… state unknown`, reconcile 의 빈 서버 문구와 겹치지 않음) unknown(`herdr_confirmed=False`)으로 남는다 — stdout 으로 온 오류 문서도 더 이상 빈 목록으로 읽히지 않는다.
|
||||||
4. **accepted 제출의 전달 확인 `SKS_DELIVERY_GRACE_S` (Issue #14, `send_keys_safe`)**: `agent prompt --wait` 결과가 `timeout`/`agent_prompt_stalled` 이면 herdr 가 입력을 이미 수락한 것으로 보고, `SKS_DELIVERY_GRACE_S`(기본 15초) 동안 `agent_status` working, 제출 전 화면에 없던 busy 토큰(`esc to interrupt`), 제출 전 스냅샷 대비 pane 변화 중 하나를 전달 확인 신호로 인정한다. 신호가 없으면 종전대로 rc 4 로 fail-closed 하고, 그 밖의 오류(`transport_error` 등)는 accepted 로 취급하지 않는다. 단일 주입 계약(재전송 없음)은 유지된다.
|
4. **accepted 제출의 전달 확인 `SKS_DELIVERY_GRACE_S` (Issue #14, `send_keys_safe`)**: `agent prompt --wait` 결과가 `timeout`/`agent_prompt_stalled` 이면 herdr 가 입력을 이미 수락한 것으로 보고, `SKS_DELIVERY_GRACE_S`(기본 15초) 동안 `agent_status` working, 제출 전 화면에 없던 busy 토큰(`esc to interrupt`), 제출 전 스냅샷 대비 pane 변화 중 하나를 전달 확인 신호로 인정한다. 신호가 없으면 종전대로 rc 4 로 fail-closed 하고, 그 밖의 오류(`transport_error` 등)는 accepted 로 취급하지 않는다. 단일 주입 계약(재전송 없음)은 유지된다.
|
||||||
5. **래퍼 레지스트리 ack 유예 `MAM_INJECT_ACK_GRACE_S` (Issue #14, `multi-agent-mux-delegate-job`)**: `send_keys_safe` 가 전달을 확인하지 못해도 잡을 `agent_blocked` 로 닫기 전에 `MAM_INJECT_ACK_GRACE_S`(기본 10초) 동안 레지스트리를 관측하고, `pending` 이 아닌 상태(에이전트의 `started` 등)가 보이면 전달된 것으로 보고 진행한다(`_inject_acknowledged`).
|
5. **래퍼 레지스트리 ack 유예 `MAM_INJECT_ACK_GRACE_S` (Issue #14, `multi-agent-mux-delegate-job`)**: `send_keys_safe` 가 전달을 확인하지 못해도 잡을 `agent_blocked` 로 닫기 전에 `MAM_INJECT_ACK_GRACE_S`(기본 10초) 동안 레지스트리를 관측하고, `pending` 이 아닌 상태(에이전트의 `started` 등)가 보이면 전달된 것으로 보고 진행한다(`_inject_acknowledged`).
|
||||||
6. **R1 유예의 pane busy 토큰·디스크 heartbeat 인정 (Issue #14, `_agent_still_working`, `_recent_disk_heartbeat`)**: 구독자 idle 타임아웃 후 R1 유예가 herdr `working` 외에 pane 최근 8줄의 `esc to interrupt` 토큰, 그리고 잡 `events.log` 의 최근 이벤트(`publish_event` 가 와이어 발행 전에 로컬에 기록하는 `progress` heartbeat)도 작업 중 근거로 받아들인다. 근거가 관측될 때마다 `IDLE_TIMEOUT` 만큼의 새 창이 열리고, 한 창 내내 아무 근거도 없으면 종전대로 닫는다(벽시계 `TIMEOUT` 내). idle 타임아웃보다 긴 codex 리뷰, heartbeat 간격이 한 번 idle 창을 넘긴 리뷰, 백그라운드 셸로 테스트를 돌려 herdr 에 idle 로 보이는 creator 가 턴 도중 `deadline_timeout` 으로 닫히고 이후 `completed` 가 거부되던 문제를 방지.
|
6. **R1 유예의 pane busy 토큰·디스크 heartbeat 인정 (Issue #14, `_agent_still_working`, `_recent_disk_heartbeat`)**: 구독자 idle 타임아웃 후 R1 유예가 herdr `working` 외에 pane 최근 8줄의 `esc to interrupt` 토큰, 그리고 잡 `events.log` 의 최근 이벤트(`publish_event` 가 와이어 발행 시도 **뒤**에 성공 여부와 무관하게 로컬에 기록하는 `progress` heartbeat — 브로커 장애 시에는 발행 재시도 시간만큼 늦게 기록된다)도 작업 중 근거로 받아들인다. 유예에 들어가면 `IDLE_TIMEOUT` 창 하나가 열리고, 근거가 관측될 때마다 그 시점부터 창이 다시 열린다. 따라서 **아무 근거도 없는 무응답 에이전트는 마지막 와이어 이벤트 후 약 2×`IDLE_TIMEOUT` 에 닫히고**(디스크 heartbeat 를 인정하기 전에는 `IDLE_TIMEOUT`; Issue #6 수용 기준 "≤ `IDLE_TIMEOUT`+5s 내 `error`"는 "≤ 2×`IDLE_TIMEOUT` + 여유"로 바뀐다), **heartbeat 간격은 약 2×`IDLE_TIMEOUT` 까지 허용된다**(heartbeat 하나가 `IDLE_TIMEOUT` 만큼 오래될 때까지 근거로 인정되므로). 근거는 idle 창만 늘릴 뿐 벽시계는 늘리지 않는다: 루프는 매 반복 시계로 다시 읽은 `START_TIME + TIMEOUT` 에서 끝나고, herdr 관측 호출도 그 시각에 끊는다(`_within_deadline`). `broker_unavailable` 디스크 재관측 루프도 같은 시계 기준이다. 이로써 idle 타임아웃보다 긴 codex 리뷰, heartbeat 간격이 한 번 idle 창을 넘긴 리뷰, 백그라운드 셸로 테스트를 돌려 herdr 에 idle 로 보이는 creator 가 턴 도중 `deadline_timeout` 으로 닫히고 이후 `completed` 가 거부되던 문제를 막는다.
|
||||||
|
|
||||||
#### ✅ 품질 검증
|
#### ✅ 품질 검증
|
||||||
- **T1-7 트리거 변경** (`tests/test_issue6_fail_closed.py`): 주입 실패 유발값을 `prompt_wait_result=timeout` → `transport_error` 로 변경(Issue #14 이후 `timeout` 은 accepted 제출이므로 더 이상 확정 실패가 아님)하고 `MAM_INJECT_ACK_GRACE_S=1` 로 대기를 단축.
|
- **T1-7 트리거 변경** (`tests/test_issue6_fail_closed.py`): 주입 실패 유발값을 `prompt_wait_result=timeout` → `transport_error` 로 변경(Issue #14 이후 `timeout` 은 accepted 제출이므로 더 이상 확정 실패가 아님)하고 `MAM_INJECT_ACK_GRACE_S=1` 로 대기를 단축.
|
||||||
- **신규 테스트 17개**: `tests/test_issue9_server_env.py` 8개(마커 제거, `MAM_NO_AUTOSTART` 부트스트랩 생략·호출 전달, status 조회 무기동, 정적 opt-out, `ls` 번역(최소·herdr 0.9 실제 오류 봉투), 성공 목록 속 `server_not_running` 문자열 비오판, 그 밖의 실패 unknown 유지, reconcile drift A 비발생), `tests/test_issue14_delivery_ack.py` 9개(busy 토큰·pane 변화 확인, 무신호 fail-closed, 선행 busy 토큰 배제, `transport_error` 비수용, mock herdr timeout 확인, 래퍼 레지스트리 ack, idle 타임아웃 pane busy 유예, idle 타임아웃 디스크 heartbeat 유예).
|
- **신규 테스트 19개**: `tests/test_issue9_server_env.py` 9개(마커 제거, `MAM_NO_AUTOSTART` 부트스트랩 생략·호출 전달, status 조회 무기동, 정적 opt-out, `ls` 번역(최소·herdr 0.9 실제 오류 봉투·stdout 봉투), 성공 목록 속 `server_not_running` 문자열 비오판, 그 밖의 실패 unknown 유지(stderr·stdout), reconcile drift A 비발생, 다른 실패 시 reconcile `herdr_confirmed=False`), `tests/test_issue14_delivery_ack.py` 10개(busy 토큰·pane 변화 확인, 무신호 fail-closed, 선행 busy 토큰 배제, `transport_error` 비수용, mock herdr timeout 확인, 래퍼 레지스트리 ack, idle 타임아웃 pane busy 유예, idle 타임아웃 디스크 heartbeat 유예, heartbeat 가 이어져도 벽시계 `TIMEOUT` 안에서 종료).
|
||||||
- **테스트 기대값 정정**: `tests/test_a4_adapter_contract.py` 가 `4d444ce` 의 agy `exit_key=/exit` 변경을 반영하도록 수정(그동안 전체 스위트에서 1건 실패하던 문제 해소).
|
- **테스트 기대값 정정**: `tests/test_a4_adapter_contract.py` 가 `4d444ce` 의 agy `exit_key=/exit` 변경을 반영하도록 수정(그동안 전체 스위트에서 1건 실패하던 문제 해소).
|
||||||
- **실측 (커밋 `497633f` 기록)**: 새로 부트스트랩한 서버에 마커가 없고 claude pane 의 transcript 저장이 복구됨, codex 잡 `d04ebcb5` 의 `started`/`completed` 가 `agent_blocked` 없이 수락됨.
|
- **실측 (커밋 `497633f` 기록)**: 새로 부트스트랩한 서버에 마커가 없고 claude pane 의 transcript 저장이 복구됨, codex 잡 `d04ebcb5` 의 `started`/`completed` 가 `agent_blocked` 없이 수락됨.
|
||||||
|
|
||||||
|
|||||||
@@ -366,3 +366,77 @@ def test_wrapper_idle_timeout_grace_accepts_disk_heartbeat(tmp_path):
|
|||||||
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") == "completed", f"status={job_data.get('status')}\n{stdout}\n{stderr}"
|
assert job_data.get("status") == "completed", f"status={job_data.get('status')}\n{stdout}\n{stderr}"
|
||||||
assert proc.returncode == 0, f"expected rc=0 while heartbeats kept landing, got {proc.returncode}\n{stdout}\n{stderr}"
|
assert proc.returncode == 0, f"expected rc=0 while heartbeats kept landing, got {proc.returncode}\n{stdout}\n{stderr}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrapper_idle_grace_never_outlives_the_wall_clock(tmp_path):
|
||||||
|
"""Heartbeats may extend the idle window, never the overall --timeout.
|
||||||
|
Fresh heartbeats land in events.log every second for the whole run, and
|
||||||
|
every python probe the wrapper makes is slowed down (herdr-RPC-like
|
||||||
|
latency). Counting sleeps instead of reading the clock let the grace loop
|
||||||
|
run about twice as long as TIMEOUT; the job must instead close as
|
||||||
|
deadline_timeout within TIMEOUT plus a small close/publish margin."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
timeout_s, idle_s = 20, 4
|
||||||
|
reg_dir = tmp_path / "jobs"
|
||||||
|
reg_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
slow_py = tmp_path / "slow-python"
|
||||||
|
slow_py.write_text(f'#!/bin/sh\nsleep 0.7\nexec "{sys.executable}" "$@"\n')
|
||||||
|
slow_py.chmod(0o755)
|
||||||
|
env = _base_env(tmp_path, {"DELEGATE_JOB_PYTHON": slow_py})
|
||||||
|
|
||||||
|
started = time.time()
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"bash", str(WRAPPER), "submit",
|
||||||
|
"--agent", "human",
|
||||||
|
"--prompt", "issue 14 wall clock",
|
||||||
|
"--registry-dir", str(reg_dir),
|
||||||
|
"--workdir", str(tmp_path),
|
||||||
|
"--timeout", str(timeout_s),
|
||||||
|
"--idle-timeout", str(idle_s),
|
||||||
|
],
|
||||||
|
cwd=str(tmp_path), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
jid = ""
|
||||||
|
for _ in range(200):
|
||||||
|
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"
|
||||||
|
events_log = reg_dir / f"{jid}.events.log"
|
||||||
|
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def keep_heartbeating():
|
||||||
|
# The same line shape publish_event appends; only its timestamp matters
|
||||||
|
# to _recent_disk_heartbeat.
|
||||||
|
seq = 10_000
|
||||||
|
while not stop.is_set():
|
||||||
|
seq += 1
|
||||||
|
line = {"schema_version": 1, "seq": seq, "job_id": jid, "event": "progress",
|
||||||
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"detail": "still working", "data": {}}
|
||||||
|
with open(events_log, "a") as fh:
|
||||||
|
fh.write(json.dumps(line) + "\n")
|
||||||
|
stop.wait(1)
|
||||||
|
|
||||||
|
beat = threading.Thread(target=keep_heartbeating, daemon=True)
|
||||||
|
beat.start()
|
||||||
|
try:
|
||||||
|
stdout, stderr = proc.communicate(timeout=120)
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
elapsed = time.time() - started
|
||||||
|
|
||||||
|
job_data = json.loads((reg_dir / f"{jid}.json").read_text())
|
||||||
|
assert proc.returncode == 2, f"expected the deadline_timeout path (rc=2), got {proc.returncode}\n{stderr}"
|
||||||
|
assert job_data.get("status") == "error", f"status={job_data.get('status')}\n{stderr}"
|
||||||
|
# The heartbeats were honoured: the job was not closed after the first idle
|
||||||
|
# window, it stayed open up to the wall clock ...
|
||||||
|
assert elapsed >= timeout_s - 1, f"closed before the wall clock: {elapsed:.1f}s\n{stderr}"
|
||||||
|
# ... and not a moment past it beyond closing and publishing the error.
|
||||||
|
assert elapsed <= timeout_s + 12, f"idle grace outlived --timeout {timeout_s}s: {elapsed:.1f}s\n{stderr}"
|
||||||
@@ -195,18 +195,32 @@ def test_reconcile_and_status_opt_out_of_autostart_statically():
|
|||||||
f"{script.name} must opt out of the shim's server bootstrap"
|
f"{script.name} must opt out of the shim's server bootstrap"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("envelope", [
|
def _fail_with(text: str, stream: str) -> str:
|
||||||
{"error": {"code": "server_not_running"}},
|
"""Fake `agent list` body: write `text` to `stream` and exit 1."""
|
||||||
REAL_DOWN_ENVELOPE,
|
return f'sys.{stream}.write({json.dumps(text)} + "\\n"); sys.exit(1)'
|
||||||
], ids=["minimal", "herdr-0.9"])
|
|
||||||
def test_ls_reports_down_server_as_no_server_running(mam_sandbox, envelope):
|
|
||||||
|
# reconcile.sh reads a nonzero `ls` whose stderr contains any of these as a
|
||||||
|
# confirmed-EMPTY server (`is_empty`); anything else as unknown.
|
||||||
|
RECONCILE_EMPTY_PHRASES = ("no server running", "no sessions", "failed to connect")
|
||||||
|
|
||||||
|
# An unrelated structured failure whose message happens to mention the code.
|
||||||
|
TRANSPORT_ERROR = json.dumps({"error": {"code": "transport_error",
|
||||||
|
"message": "socket reset while probing server_not_running"}})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("envelope,stream", [
|
||||||
|
({"error": {"code": "server_not_running"}}, "stderr"),
|
||||||
|
(REAL_DOWN_ENVELOPE, "stderr"),
|
||||||
|
({"error": {"code": "server_not_running"}}, "stdout"),
|
||||||
|
], ids=["minimal", "herdr-0.9", "minimal-on-stdout"])
|
||||||
|
def test_ls_reports_down_server_as_no_server_running(mam_sandbox, envelope, stream):
|
||||||
"""reconcile.sh treats a nonzero `ls` as 'empty' only when stderr says
|
"""reconcile.sh treats a nonzero `ls` as 'empty' only when stderr says
|
||||||
'no server running'; anything else flips herdr_confirmed=False for every
|
'no server running'; anything else flips herdr_confirmed=False for every
|
||||||
server. The shim must therefore translate server_not_running explicitly."""
|
server. The shim must therefore translate server_not_running explicitly."""
|
||||||
env = _install_fake_herdr(mam_sandbox)
|
env = _install_fake_herdr(mam_sandbox)
|
||||||
env["MAM_NO_AUTOSTART"] = "1"
|
env["MAM_NO_AUTOSTART"] = "1"
|
||||||
_fake_agent_list(mam_sandbox,
|
_fake_agent_list(mam_sandbox, _fail_with(json.dumps(envelope), stream))
|
||||||
f'sys.stderr.write({json.dumps(json.dumps(envelope))} + "\\n"); sys.exit(1)')
|
|
||||||
|
|
||||||
res = _shim_ls(mam_sandbox, env, "down-srv")
|
res = _shim_ls(mam_sandbox, env, "down-srv")
|
||||||
assert res.returncode != 0
|
assert res.returncode != 0
|
||||||
@@ -229,51 +243,75 @@ def test_ls_lists_agents_whose_names_contain_server_not_running(mam_sandbox):
|
|||||||
assert names == ["server_not_running-codex", "creator-claude-01"], res.stdout
|
assert names == ["server_not_running-codex", "creator-claude-01"], res.stdout
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("stderr_text", [
|
@pytest.mark.parametrize("text,stream", [
|
||||||
json.dumps({"error": {"code": "transport_error",
|
(TRANSPORT_ERROR, "stderr"),
|
||||||
"message": "socket reset while probing server_not_running"}}),
|
(TRANSPORT_ERROR, "stdout"),
|
||||||
"warning: server_not_running cache entry is stale",
|
("warning: server_not_running cache entry is stale", "stderr"),
|
||||||
"",
|
("", "stderr"),
|
||||||
], ids=["other-code", "plain-text", "empty"])
|
], ids=["other-code", "other-code-on-stdout", "plain-text", "empty"])
|
||||||
def test_ls_leaves_other_failures_unknown(mam_sandbox, stderr_text):
|
def test_ls_leaves_other_failures_unknown(mam_sandbox, text, stream):
|
||||||
"""Only the structured code means "down". Any other failure keeps the
|
"""Only the structured code means "down". Any other failure must stay
|
||||||
unknown path (nonzero exit without 'no server running'), so reconcile
|
unknown - nonzero exit, no empty-server phrase - wherever its error
|
||||||
drops herdr_confirmed instead of reading a confirmed-empty server."""
|
document lands. On stdout it used to reach the list parser, which read a
|
||||||
|
JSON error without result.agents as a successful EMPTY list (rc 0)."""
|
||||||
env = _install_fake_herdr(mam_sandbox)
|
env = _install_fake_herdr(mam_sandbox)
|
||||||
env["MAM_NO_AUTOSTART"] = "1"
|
env["MAM_NO_AUTOSTART"] = "1"
|
||||||
_fake_agent_list(mam_sandbox,
|
_fake_agent_list(mam_sandbox, _fail_with(text, stream))
|
||||||
f'sys.stderr.write({json.dumps(stderr_text)} + "\\n"); sys.exit(1)')
|
|
||||||
|
|
||||||
res = _shim_ls(mam_sandbox, env, "flaky-srv")
|
res = _shim_ls(mam_sandbox, env, "flaky-srv")
|
||||||
assert res.returncode != 0
|
assert res.returncode != 0, f"a failed list must not exit 0: {res.stdout!r} {res.stderr!r}"
|
||||||
assert "no server running" not in res.stderr, res.stderr
|
for phrase in RECONCILE_EMPTY_PHRASES:
|
||||||
|
assert phrase not in res.stderr.lower(), res.stderr
|
||||||
assert res.stdout.strip() == ""
|
assert res.stdout.strip() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def _write_running_rows(sandbox: Path, names, server: str = "live-srv") -> None:
|
||||||
|
rows = "".join(
|
||||||
|
f" - name: {name}\n"
|
||||||
|
" status: running\n"
|
||||||
|
f" herdr_session: {server}\n"
|
||||||
|
" pane:\n"
|
||||||
|
" cwd: /tmp/nonexistent-workspace\n"
|
||||||
|
for name in names
|
||||||
|
)
|
||||||
|
(sandbox / ".mam" / "agent-sessions.yaml").write_text("herdr_sessions:\n" + rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_dry_run(sandbox: Path, env: dict) -> dict:
|
||||||
|
reconcile = sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||||
|
res = subprocess.run(["bash", str(reconcile), "--once", "--emit-diff", "--dry-run"],
|
||||||
|
capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=120)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
return json.loads(res.stdout)
|
||||||
|
|
||||||
|
|
||||||
def test_reconcile_keeps_live_rows_when_a_name_contains_server_not_running(mam_sandbox):
|
def test_reconcile_keeps_live_rows_when_a_name_contains_server_not_running(mam_sandbox):
|
||||||
"""End to end through reconcile.sh: the server answers a successful list in
|
"""End to end through reconcile.sh: the server answers a successful list in
|
||||||
which the down-server code appears as data. Both registered rows must stay
|
which the down-server code appears as data. Both registered rows must stay
|
||||||
alive with herdr_confirmed intact, so drift A cannot mark them terminated."""
|
alive with herdr_confirmed intact, so drift A cannot mark them terminated."""
|
||||||
env = _install_fake_herdr(mam_sandbox)
|
env = _install_fake_herdr(mam_sandbox)
|
||||||
_fake_agent_list(mam_sandbox, f"print({json.dumps(json.dumps(LISTING_WITH_SUBSTRING))})")
|
_fake_agent_list(mam_sandbox, f"print({json.dumps(json.dumps(LISTING_WITH_SUBSTRING))})")
|
||||||
rows = "".join(
|
_write_running_rows(mam_sandbox, ("server_not_running-codex", "creator-claude-01"))
|
||||||
f" - name: {name}\n"
|
|
||||||
" status: running\n"
|
|
||||||
" herdr_session: live-srv\n"
|
|
||||||
" pane:\n"
|
|
||||||
" cwd: /tmp/nonexistent-workspace\n"
|
|
||||||
for name in ("server_not_running-codex", "creator-claude-01")
|
|
||||||
)
|
|
||||||
(mam_sandbox / ".mam" / "agent-sessions.yaml").write_text("herdr_sessions:\n" + rows)
|
|
||||||
reconcile = mam_sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
|
||||||
|
|
||||||
res = subprocess.run(["bash", str(reconcile), "--once", "--emit-diff", "--dry-run"],
|
report = _reconcile_dry_run(mam_sandbox, env)
|
||||||
capture_output=True, text=True, cwd=str(mam_sandbox), env=env, timeout=120)
|
|
||||||
assert res.returncode == 0, res.stderr
|
|
||||||
|
|
||||||
report = json.loads(res.stdout)
|
|
||||||
assert report["herdr_confirmed"] is True, report
|
assert report["herdr_confirmed"] is True, report
|
||||||
alive = set(report["herdr_sessions_alive"])
|
alive = set(report["herdr_sessions_alive"])
|
||||||
assert {"server_not_running-codex|live-srv", "creator-claude-01|live-srv"} <= alive, alive
|
assert {"server_not_running-codex|live-srv", "creator-claude-01|live-srv"} <= alive, alive
|
||||||
drift_a = [d for d in report["drifts"] if d.get("class") == "A"]
|
drift_a = [d for d in report["drifts"] if d.get("class") == "A"]
|
||||||
assert not drift_a, drift_a
|
assert not drift_a, drift_a
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_treats_other_list_failures_as_unknown(mam_sandbox):
|
||||||
|
"""End to end through reconcile.sh: a server whose `agent list` fails with
|
||||||
|
an unrelated structured error on stdout must drop herdr_confirmed, so
|
||||||
|
drift A never marks its live row terminated."""
|
||||||
|
env = _install_fake_herdr(mam_sandbox)
|
||||||
|
_fake_agent_list(mam_sandbox, _fail_with(TRANSPORT_ERROR, "stdout"))
|
||||||
|
_write_running_rows(mam_sandbox, ("creator-claude-01",))
|
||||||
|
|
||||||
|
report = _reconcile_dry_run(mam_sandbox, env)
|
||||||
|
assert report["herdr_confirmed"] is False, report
|
||||||
|
drift_a = [d for d in report["drifts"] if d.get("class") == "A"]
|
||||||
|
assert not drift_a, drift_a
|
||||||
|
terminated = [a for a in report.get("actions", []) if str(a).startswith("terminated")]
|
||||||
|
assert not terminated, terminated
|
||||||
Reference in new issue
Block a user