fix(#30, #33): 위임 잡 예산 조절과 벽시계 라벨 분리, 멈춘 에이전트 알림(알림 전용) #41

Merged
Godopu merged 9 commits from fix/issue-30-33 into main 2026-09-26 07:08:46 +00:00
Owner

Closes #30
Closes #33

위임 잡의 예산을 조절할 수 없고 벽시계 초과와 무응답이 같은 라벨로 기록되던 문제(#33)와, 살아 있지만 멈춘 에이전트를 잡 제한 시간이 지나야만 알 수 있던 문제(#30)를 함께 고친다. 두 이슈 모두 루프의 잡 대기와 래퍼의 잡 감시를 건드려 한 브랜치로 묶었다.

이슈 댓글의 계획은 코드와 herdr 0.9.1 에 대조해 검토한 뒤 조정했다. 확정된 결정과 근거는 아래 "계획과 달라진 점"에 있다. 예산 연장은 이번 범위에서 빼고 #39 로 분리했다.

#33 — 예산과 라벨

  • 예산을 바꿀 수 있다. 래퍼의 예산은 --timeout 이 없으면 MAM_JOB_TIMEOUT_S 를 쓰고, 둘 다 없으면 기존대로 3600초다. 루프는 --job-timeout <sec> 을 받아 모든 위임 잡에 넘긴다.
  • 루프가 먼저 포기하지 않는다. 러너의 잡 대기 상한과 #34 의 하위 잡 대기(MAM_CHILD_WAIT_S) 기본값이 3600초 예산을 전제한 상수 3900 이었다. 예산을 올리면 잡은 살아 있는데 루프가 3900초에 실패로 끝났다. 이제 둘 다 잡 예산 + 300초로 계산한다. 명시적으로 준 MAM_CHILD_WAIT_S 는 그대로 존중한다.
  • 두 시계가 다른 라벨을 받는다. 구독자는 원래 두 시계를 따로 셌지만 둘 다 종료 코드 2 를 돌려줘서 래퍼가 둘 다 deadline_timeout 으로 기록했다. 이제 벽시계 초과는 wall_clock_timeout, 무응답은 종전대로 deadline_timeout 이다. 종료 코드는 구독자와 래퍼 양쪽에서 이름 붙은 상수로 참조하고, 두 쪽 값이 어긋나지 않는지 테스트로 고정했다. 등록부의 종결 요청 종류와 #34 의 하위 잡 표 분류에도 새 라벨을 반영했다. delegate-job wait 는 두 시계 모두 기존대로 종료 코드 2 를 돌려준다.
  • 원격 잡을 포함해 모든 transport 에 적용된다.

#30 — 멈춤 알림 (알림 전용)

새 감시자 lib_py/stall_watch.py 가 래퍼 옆에서 잡의 에이전트를 지켜본다. 잡을 닫는 코드 경로가 없다. 종결 근거는 #18 대로 프로토콜 이벤트와 시간 초과뿐이며, 이를 테스트로 고정했다.

  • blocked: progress --detail stalled:blocked 를 발행하고 herdr detection 화면의 문구를 붙여 경고한다. 자동으로 응답하지 않는다. #30 의 동기 사례가 Claude Code 의 "Dangerous rm operation … Do you want to proceed?" 창이고, herdr 문서도 막힌 창은 사용자에게 물어보라고 한다.
  • idle: 잡이 열려 있는데 idle 이 MAM_STALL_IDLE_S(기본 600초) 이어지고, 에이전트에게 백그라운드 자식 프로세스가 없으면 stalled:idle 을 발행하고 "이어서 하라"는 짧은 신호를 한 번 보낸다. 입력창이 비어 있을 때만 보내고 잡당 한 번뿐이다.
  • 백그라운드 자식 판정. herdr 만으로는 알 수 없다. Claude Code 는 백그라운드 명령을 별도 프로세스 그룹으로 띄워서 herdr pane process-info 에는 에이전트만 보인다. 그래서 herdr 로 에이전트 pid 를 얻고, 운영체제에서 그 pid 의 자손을 확인한다. 에이전트가 늘 띄워 두는 상주 자식(예: codex 의 codex-code-mode-host)은 에이전트 어댑터의 목록으로 뺀다.
  • 원격 잡(transport: remote) 은 감시하지 않고 그 사실을 로그에 한 줄 남긴다.
  • herdr 호출이 실패하면 "이벤트 없음"으로 넘기지 않고 경고한다(MAM_STALL_HERDR_FAIL_S, 기본 120초). 원인은 따지지 않는다.
  • MAM_STALL_WATCH=0 으로 끌 수 있다.

계획과 달라진 점

이슈 댓글의 계획을 코드와 대조한 검토 결과다.

  • 예산 연장을 뺐다(#39 로 분리). 루프 러너는 래퍼를 동기 호출하고 래퍼는 잡이 끝날 때까지 반환하지 않아서, 계획대로 "러너가 정책으로 연장을 결정"할 수 없었다. 또 연장 거부 근거로 제안된 state_change_seq 정체는 #33 의 동기 사례, 즉 한 시간 넘게 같은 상태로 전체 스위트를 도는 정상 creator 를 거부하게 만든다. #39 는 에이전트가 스스로 연장을 요청하는 방식을 다룬다.
  • 멈춤 판정으로 잡을 닫지 않는다. #30 본문의 "알림 전용" 원칙을 따랐다. 이와 어긋나던 #30 댓글 358·375 의 "재주입이 실패하면 stalled 로 종결"과 다이얼로그 자동 수락 부분은 2026-09-25 에 댓글에서 지웠다.
  • idle 판정에 백그라운드 자식 조건을 되살렸다. 본문이 필수라고 한 조건인데 댓글의 규칙에서 빠져 있었다. 없으면 백그라운드로 테스트를 돌리며 기다리는 정상 에이전트를 멈춤으로 오인한다.
  • 규칙은 코드로 강제하고 문서는 최소화했다. 새 옵션과 환경변수는 기존 표에 한 행씩만 추가했다. CLI 별 agent_status 신뢰도와 상주 자식 목록은 문서가 아니라 에이전트 어댑터의 필드다.
  • #17 항목 4(래퍼 종결 처리의 Python 이전)는 하지 않았다. #30 이 알림 전용이 되면서 종결 처리를 건드리지 않게 됐고, bash 쪽 변경은 라벨을 나누는 부분으로 최소화했다.

검증

항목 결과
전체 스위트 (1673818) 1201 통과 / 12 실패 / 19 건너뜀
리뷰, 브랜치 전체 claude·agy·grok·codex 전원 PASS
실제 에이전트 시험 두 경우 모두 통과

실패 12건은 모두 이 호스트의 기준 실패(tests/test_issue10_13_purge.py)이며 base 에서도 같다.

리뷰어. 첫 루프의 3회차에서 네 명이 PASS 였지만 reviewer-opencode-01 이 Ollama 주간 사용량 한도에 걸려 잡을 끝내지 못해 루프가 실패로 끝났다. opencode 는 한도가 풀릴 때까지 정지했고, 최종 head 에서 나머지 네 명으로 브랜치 전체를 다시 리뷰했다. opencode 는 첫 회차에 PASS 를 냈다.

리뷰어 PASS 뒤 전체 스위트에서 잡은 회귀. tests/test_tier1_unit.py::test_g8_subscriber_wait_any_does_not_exit_early_if_partial_pending 가 시간 초과면 종료 코드 2 라고 가정했는데, 두 시계를 같은 값으로 줘서 벽시계가 먼저 닿아 새 코드를 받았다. base 에서는 통과하는 결정적 회귀였다. 고치는 과정에서 delegate-job wait 가 구독자의 새 코드를 그대로 흘리던 것도 찾아 함께 고쳤다.

실제 에이전트 시험. 감시자는 herdr 와 운영체제 프로세스 목록에 기대므로, 실제 claude 에이전트로 MAM_STALL_IDLE_S=45 를 주고 두 경우를 확인했다.

  • A. started 만 보내고 완료 없이 턴을 끝낸 에이전트: 48초 뒤 stalled:idle 경고와 신호 한 번, 에이전트가 받자마자 completed.
  • B. 150초짜리 명령을 백그라운드로 돌리며 쉬는 에이전트: 경고도 신호도 없이, 명령이 끝난 뒤 스스로 completed.

blocked 는 권한 플래그를 켠 에이전트에서 실제 확인 창을 띄우기 어려워 실제 시험을 하지 않았고 테스트로만 확인했다.

관련

  • #39 예산 연장을 에이전트가 요청하는 방식 (이번에 분리)
  • #18 생존·전달 판정의 프로토콜 계약
  • #17 항목 4 래퍼 종결 처리의 Python 이전 (하지 않음)

🤖 Generated with Claude Code

Closes #30 Closes #33 위임 잡의 예산을 조절할 수 없고 벽시계 초과와 무응답이 같은 라벨로 기록되던 문제(#33)와, 살아 있지만 멈춘 에이전트를 잡 제한 시간이 지나야만 알 수 있던 문제(#30)를 함께 고친다. 두 이슈 모두 루프의 잡 대기와 래퍼의 잡 감시를 건드려 한 브랜치로 묶었다. 이슈 댓글의 계획은 코드와 herdr 0.9.1 에 대조해 검토한 뒤 조정했다. 확정된 결정과 근거는 아래 "계획과 달라진 점"에 있다. 예산 연장은 이번 범위에서 빼고 #39 로 분리했다. ## #33 — 예산과 라벨 - **예산을 바꿀 수 있다.** 래퍼의 예산은 `--timeout` 이 없으면 `MAM_JOB_TIMEOUT_S` 를 쓰고, 둘 다 없으면 기존대로 3600초다. 루프는 `--job-timeout <sec>` 을 받아 모든 위임 잡에 넘긴다. - **루프가 먼저 포기하지 않는다.** 러너의 잡 대기 상한과 #34 의 하위 잡 대기(`MAM_CHILD_WAIT_S`) 기본값이 3600초 예산을 전제한 상수 3900 이었다. 예산을 올리면 잡은 살아 있는데 루프가 3900초에 실패로 끝났다. 이제 둘 다 잡 예산 + 300초로 계산한다. 명시적으로 준 `MAM_CHILD_WAIT_S` 는 그대로 존중한다. - **두 시계가 다른 라벨을 받는다.** 구독자는 원래 두 시계를 따로 셌지만 둘 다 종료 코드 2 를 돌려줘서 래퍼가 둘 다 `deadline_timeout` 으로 기록했다. 이제 벽시계 초과는 `wall_clock_timeout`, 무응답은 종전대로 `deadline_timeout` 이다. 종료 코드는 구독자와 래퍼 양쪽에서 이름 붙은 상수로 참조하고, 두 쪽 값이 어긋나지 않는지 테스트로 고정했다. 등록부의 종결 요청 종류와 #34 의 하위 잡 표 분류에도 새 라벨을 반영했다. `delegate-job wait` 는 두 시계 모두 기존대로 종료 코드 2 를 돌려준다. - 원격 잡을 포함해 모든 transport 에 적용된다. ## #30 — 멈춤 알림 (알림 전용) 새 감시자 `lib_py/stall_watch.py` 가 래퍼 옆에서 잡의 에이전트를 지켜본다. **잡을 닫는 코드 경로가 없다.** 종결 근거는 #18 대로 프로토콜 이벤트와 시간 초과뿐이며, 이를 테스트로 고정했다. - **`blocked`:** `progress --detail stalled:blocked` 를 발행하고 herdr `detection` 화면의 문구를 붙여 경고한다. **자동으로 응답하지 않는다.** #30 의 동기 사례가 Claude Code 의 "Dangerous rm operation … Do you want to proceed?" 창이고, herdr 문서도 막힌 창은 사용자에게 물어보라고 한다. - **`idle`:** 잡이 열려 있는데 `idle` 이 `MAM_STALL_IDLE_S`(기본 600초) 이어지고, 에이전트에게 백그라운드 자식 프로세스가 없으면 `stalled:idle` 을 발행하고 "이어서 하라"는 짧은 신호를 한 번 보낸다. 입력창이 비어 있을 때만 보내고 잡당 한 번뿐이다. - **백그라운드 자식 판정.** herdr 만으로는 알 수 없다. Claude Code 는 백그라운드 명령을 별도 프로세스 그룹으로 띄워서 herdr `pane process-info` 에는 에이전트만 보인다. 그래서 herdr 로 에이전트 pid 를 얻고, 운영체제에서 그 pid 의 자손을 확인한다. 에이전트가 늘 띄워 두는 상주 자식(예: codex 의 `codex-code-mode-host`)은 에이전트 어댑터의 목록으로 뺀다. - **원격 잡(`transport: remote`)** 은 감시하지 않고 그 사실을 로그에 한 줄 남긴다. - **herdr 호출이 실패하면** "이벤트 없음"으로 넘기지 않고 경고한다(`MAM_STALL_HERDR_FAIL_S`, 기본 120초). 원인은 따지지 않는다. - `MAM_STALL_WATCH=0` 으로 끌 수 있다. ## 계획과 달라진 점 이슈 댓글의 계획을 코드와 대조한 검토 결과다. - **예산 연장을 뺐다(#39 로 분리).** 루프 러너는 래퍼를 동기 호출하고 래퍼는 잡이 끝날 때까지 반환하지 않아서, 계획대로 "러너가 정책으로 연장을 결정"할 수 없었다. 또 연장 거부 근거로 제안된 `state_change_seq` 정체는 #33 의 동기 사례, 즉 한 시간 넘게 같은 상태로 전체 스위트를 도는 정상 creator 를 거부하게 만든다. #39 는 에이전트가 스스로 연장을 요청하는 방식을 다룬다. - **멈춤 판정으로 잡을 닫지 않는다.** #30 본문의 "알림 전용" 원칙을 따랐다. 이와 어긋나던 #30 댓글 358·375 의 "재주입이 실패하면 stalled 로 종결"과 다이얼로그 자동 수락 부분은 2026-09-25 에 댓글에서 지웠다. - **`idle` 판정에 백그라운드 자식 조건을 되살렸다.** 본문이 필수라고 한 조건인데 댓글의 규칙에서 빠져 있었다. 없으면 백그라운드로 테스트를 돌리며 기다리는 정상 에이전트를 멈춤으로 오인한다. - **규칙은 코드로 강제하고 문서는 최소화했다.** 새 옵션과 환경변수는 기존 표에 한 행씩만 추가했다. CLI 별 `agent_status` 신뢰도와 상주 자식 목록은 문서가 아니라 에이전트 어댑터의 필드다. - #17 항목 4(래퍼 종결 처리의 Python 이전)는 하지 않았다. #30 이 알림 전용이 되면서 종결 처리를 건드리지 않게 됐고, bash 쪽 변경은 라벨을 나누는 부분으로 최소화했다. ## 검증 | 항목 | 결과 | |---|---| | 전체 스위트 (`1673818`) | 1201 통과 / 12 실패 / 19 건너뜀 | | 리뷰, 브랜치 전체 | claude·agy·grok·codex 전원 PASS | | 실제 에이전트 시험 | 두 경우 모두 통과 | 실패 12건은 모두 이 호스트의 기준 실패(`tests/test_issue10_13_purge.py`)이며 base 에서도 같다. **리뷰어.** 첫 루프의 3회차에서 네 명이 PASS 였지만 `reviewer-opencode-01` 이 Ollama 주간 사용량 한도에 걸려 잡을 끝내지 못해 루프가 실패로 끝났다. opencode 는 한도가 풀릴 때까지 정지했고, 최종 head 에서 나머지 네 명으로 브랜치 전체를 다시 리뷰했다. opencode 는 첫 회차에 PASS 를 냈다. **리뷰어 PASS 뒤 전체 스위트에서 잡은 회귀.** `tests/test_tier1_unit.py::test_g8_subscriber_wait_any_does_not_exit_early_if_partial_pending` 가 시간 초과면 종료 코드 2 라고 가정했는데, 두 시계를 같은 값으로 줘서 벽시계가 먼저 닿아 새 코드를 받았다. base 에서는 통과하는 결정적 회귀였다. 고치는 과정에서 `delegate-job wait` 가 구독자의 새 코드를 그대로 흘리던 것도 찾아 함께 고쳤다. **실제 에이전트 시험.** 감시자는 herdr 와 운영체제 프로세스 목록에 기대므로, 실제 claude 에이전트로 `MAM_STALL_IDLE_S=45` 를 주고 두 경우를 확인했다. - A. `started` 만 보내고 완료 없이 턴을 끝낸 에이전트: 48초 뒤 `stalled:idle` 경고와 신호 한 번, 에이전트가 받자마자 `completed`. - B. 150초짜리 명령을 백그라운드로 돌리며 쉬는 에이전트: 경고도 신호도 없이, 명령이 끝난 뒤 스스로 `completed`. `blocked` 는 권한 플래그를 켠 에이전트에서 실제 확인 창을 띄우기 어려워 실제 시험을 하지 않았고 테스트로만 확인했다. ## 관련 - #39 예산 연장을 에이전트가 요청하는 방식 (이번에 분리) - #18 생존·전달 판정의 프로토콜 계약 - #17 항목 4 래퍼 종결 처리의 Python 이전 (하지 않음) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Godopu added 6 commits 2026-09-26 02:03:07 +00:00
- wrapper: TIMEOUT falls back to MAM_JOB_TIMEOUT_S (--timeout > env > 3600);
  an invalid value warns and keeps 3600.
- loop: --job-timeout <sec> (run_loop.sh passes it on; refused in the legacy
  shell loop). The runner always hands the wrapper --timeout, so the budget
  the wrapper registers is the one its wait caps come from.
- runner: wait_for_job and the MAM_CHILD_WAIT_S default are budget + 300 s
  (MAM_WAIT_GRACE_S), today's 3900 with no option; an explicit
  MAM_CHILD_WAIT_S still wins.
- subscriber: the wall-clock budget returns 4 (RC_WALL_CLOCK), silence keeps
  2 (RC_NO_EVENT). The wrapper maps both to caller rc 2 and labels the close
  from the rc: wall_clock_timeout vs deadline_timeout; the disk-heartbeat
  grace that ends on the deadline is the wall clock too.
- registry close accepts wall_clock_timeout (engine: -> error); the remote
  runner's deadline and command-timeout details lead with wall_clock_timeout;
  the sub-job table files it CLOSED-UNCONFIRMED (first token, colon stripped).

Test expectations changed only where the contract changed: the loop's
contract_args now starts with --timeout, the subscriber's exit-code docstring
names rc 2/4, and the registry --help capture lists the new close kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lib_py/stall_watch.py runs beside the wrapper's subscriber for a local herdr
job (started after delivery, stopped when the subscriber ends; MAM_STALL_WATCH=0
turns it off). It follows `herdr agent wait` transitions and publishes only
`progress` events through one publisher; it has no path that closes a job.

- blocked: `stalled:blocked <detection line>` once per episode, never answered.
- idle for MAM_STALL_IDLE_S (600) with no background descendants of the agent
  pid (pane process-info + /proc; adapter resident_children and the heartbeat
  timer excluded) and an empty input box: `stalled:idle` and one nudge.
- input box judged from adapter facts measured on herdr 0.9.1 (codex '›',
  grok frame '│', opencode '┃' block + stop pattern); the whole-pane fallback
  is gone. No facts: withheld + `stall_watch:input_check_unavailable` once;
  never empty for 3 x S: `stall_watch:input_never_empty` once.
- herdr failures are not "no event": warning + `stall_watch:herdr_unavailable:
  <code>` (or agent_not_found), backoff, rc 2 after MAM_STALL_HERDR_FAIL_S
  (120); the wrapper prints the watcher's last lines.
- remote and human jobs are skipped with one log line.
- agent_status_reliable is an adapter field; false turns idle judgement off.
- the loop runner relays stalled:/stall_watch: notices once each.
- scoped_env drops a pane-inherited workspace scope for name lookups (#34 rule).

The module sits at the top of lib_py: delegation and loop may not import the
agent adapters (their sibling-import rules). Idle pane captures of the five
CLIs are fixtures; the mock herdr answers process-info in the real shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
- input_region: in the prompt-line shape the input box starts only at a
  column-0 prompt (codex indents a draft's continuation lines), and the
  prompt and the (whole-line) placeholder are stripped from the box's first
  non-blank line only, so `› Explain this terminal prompt:\n  ›` is not empty.
- test_r4 runs with S=40 again: the defective code nudged ~30 s after a
  missed transition, which S=10 could not tell from the fixed ~10 s.

Both new checks fail against the defective versions (stall_watch.py at
47e4274 -> 30.24 s < 40; input_region.py at bf0f5c1 -> nudge sent) and pass
now, each with its passing twin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- job_subscriber.py returns only named codes (RC_COMPLETED 0, RC_ERROR 1,
  RC_NO_EVENT 2, RC_BROKER_UNAVAILABLE 3, RC_WALL_CLOCK 4). The wrapper
  mirrors them as SUB_RC_* and compares subscriber codes only by name; a test
  keeps the mirror equal to the Python constants and forbids numeric
  comparisons of $sub_rc/$sub_exit and numeric `return` in the subscriber.
- `delegate-job wait` passed the subscriber's code straight through, so a
  spent budget would have reached its callers as 4; it is 2 again (B7 fails
  without the mapping).
- tier1 G-8 gave both clocks 0.5 s and raced; the wall clock now runs out
  first by construction (idle 30 s) and the test asserts RC_WALL_CLOCK.
  Other subscriber rc asserts use the names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Godopu added 1 commit 2026-09-26 04:17:25 +00:00
Round-1 review of PR #41 (claude N1, codex R1/R2).

N1 - the wrapper stopped its stall watcher with `kill` followed by an
unbounded `wait`. A TERM that lands in the watcher's fork-exec window is
swallowed (bash defers terminating signals in the forked child until the
exec), the watcher then installs its own handler and lives until the job
closes, and the wrapper hung until someone killed the watcher by hand
(reproduced twice in test_issue14's ack test; the wrapper trace stops at
`kill` + `wait`).
R1 - a wrapper that is itself terminated left the watcher orphaned.
R2 - a `completed` or a re-dispatch that landed during a blocking herdr
wait (up to 60 s) was still followed by the idle notice and the nudge.

- multi-agent-mux-delegate-job: `stop_stall_watch` sends TERM, polls up
  to 5 s, then KILLs and reports "ignored TERM; killed"; `start_stall_watch`
  passes `--watch-pid $$`.
- lib_py/stall_watch.py: `--watch-pid` (rc 0 once that pid is gone, checked
  between herdr calls); `_dispatch_open()` re-reads the record after every
  wait and right before the nudge - terminal status, another dispatch token
  or attempt, or a missing record ends the run without a notice; a refused
  `stalled:idle` publish withholds the nudge (`_notify` returns False).
- delegate-job SKILL.md: the watcher's lifetime contract; exit-code rows 2
  and 3 name the idle clock and the wall-clock label; "before v4.7.5" ->
  v4.8.0 (M5, NB2).
- tests/test_issue30_stall_watch.py (M8): the module-wide Linux mark is
  narrowed to S6 (/proc); r4's stub stamps time.time() so its cross-process
  comparison holds on macOS too (47 passed, 1 skipped here).
- tests/test_issue30_stall_watch_stop.py (new, macOS and Linux): the stop
  routine against a TERM-ignoring stand-in returns in 4-12 s; the wrapper
  returns (rc 3) with a TERM-ignoring watcher shim; `--watch-pid` ends the
  watcher; a TERM to the real wrapper leaves no watcher; a job completed or
  re-dispatched during the wait is not nudged; a refused notice withholds
  the nudge. Every one of them hangs or fails on the round-1 code.

Not in this commit (follow-ups): M7 test hook in product code (no adapter
is unreliable, a seam is needed), M9/B3 public-broker latency (on this host
even `started` sometimes never reaches the subscriber), the leading-zero
budget parse (NB1) and M1/M2/M4/M6 go on the #17 item 4 list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Godopu added 1 commit 2026-09-26 06:28:09 +00:00
grok N1: the wrapper spent up to 5 s on a TERM budget before it finalized the
job, so when the watcher swallowed the TERM the silent job's close came ~5 s
late and a `completed` written in that gap won (test_issue14 idle-grace).
codex D1: `_notify` blocks on a publish for up to 120 s, and the prompt went
out right after it; a completed, a re-dispatch or an unreadable record in that
window still got the nudge. codex D2: the wrapper pid was checked only at the
loop head, so a watcher whose wrapper vanished during a wait could still
notify and nudge once.

- multi-agent-mux-delegate-job `stop_stall_watch`: KILL + wait, no budget.
  The watcher holds nothing to flush (its TERM handler is a bare os._exit),
  and KILL cannot be swallowed.
- lib_py/stall_watch.py: `_wrapper_gone()` after every wait (before any
  notice) and at the loop head; `_may_prompt()` right before the keys - the
  wrapper is alive and the record, read then, is open and this dispatch;
  `_dispatch_open(strict=True)` treats an unreadable record as "no".
- SKILL.md: the lifetime contract states the one-wait lag and that nothing is
  sent after the wrapper is seen gone.
- tests/test_issue30_stall_watch_stop.py: the stop returns at once against a
  TERM-ignoring stand-in (took 6 s on the round-2 code); completed,
  re-dispatch and a torn record during the notice publish withhold the nudge;
  a wrapper gone during the wait gets no notice and no nudge. All five fail
  on the round-2 code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Godopu added 1 commit 2026-09-26 07:08:39 +00:00
Issue #33 (job budget, wall_clock_timeout vs deadline_timeout) and
Issue #30 (notify-only stall watcher). Reviews of PR #41 in the merge
workspace: round 1 (@1673818) claude N1 / codex R1 R2 NOT PASS, agy grok
PASS; round 2 (@af0a2a2) grok N1 / codex D1 D2 NOT PASS, claude agy PASS;
round 3 (@9200562) 4/4 PASS. Reports and the round-3 identity sidecars go
to .agents/reports/<session>/. The design consensus on items A-D is in the
VERSIONS.md entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
Godopu merged commit e1eb22bd59 into main 2026-09-26 07:08:46 +00:00
Author
Owner

병합 요약 (v4.8.0)

리뷰 1회차 (head 1673818, --all-reviewer): claude NOT PASS(N1), codex NOT PASS(R1·R2), agy PASS, grok PASS.
수정 1 (af0a2a2): N1·R1·R2 + M5·M8·codex 비차단 2.
리뷰 2회차 (delta @af0a2a2): claude PASS, agy PASS, grok NOT PASS(N1: TERM 5 s 예산이 잡 close 시계를 늦춤), codex NOT PASS(D1: 알림 발행 중 닫힌/재디스패치된 잡에 재주입, D2: 대기 중 래퍼 소멸 뒤 1회 재주입). 첫 실행은 claude 세션 사용량 한도로 끊겨 그 잡을 cancelled 로 닫고 다시 돌렸다.
수정 2 (9200562): 감시자 정지를 KILL 즉시로(예산 없음), 키 입력 직전 strict 게이트 _may_prompt()(래퍼 생존 + 읽을 수 있고 열린 같은 dispatch), 매 wait 직후 래퍼 확인.
리뷰 3회차 (delta @9200562): claude·agy·grok·codex 4/4 PASS. 릴리스 53f87e4, 병합 e1eb22b.

수정

  • N1 (claude) 래퍼가 감시자를 kill 뒤 무한 wait. 원인: TERM 이 감시자의 fork-exec 창에 떨어지면 bash 가 삼킨다(자식이 exec 전까지 종료 시그널을 미룸) → 감시자는 잡이 닫힐 때까지 살고 래퍼는 멈춤. test_issue14 ack 테스트에서 2회 재현, 래퍼 trace 로 확인. stop_stall_watch: TERM → 최대 5 s → KILL.
  • R1 (codex) 래퍼가 TERM 으로 죽으면 감시자 고아 → --watch-pid $$; 감시자가 래퍼 pid 소멸을 보면 rc 0 종료.
  • R2 (codex) herdr 대기(≤60 s) 중 completed/재디스패치된 잡에 재주입 → _dispatch_open() 이 매 wait 뒤·nudge 직전에 레코드(종결 상태·dispatch_token·attempt_id·존재)를 다시 읽는다. stalled:idle 발행이 거부되면 prompt 를 보내지 않는다.
  • M5/NB2 SKILL.md 종료 코드 표(idle 시계·벽시계 라벨·v4.8.0). M8 stall-watch 테스트의 모듈 전체 Linux skip 을 S6(/proc) 하나로 좁힘; r4 stub 는 프로세스 간 비교라 time.time() (macOS 에서 47 passed / 1 skipped).
  • 2회차 반영: grok N1 → KILL 즉시 정지. codex D1 → _notify 뒤 키 입력 직전 재확인(읽기 오류는 "보내지 않음"). codex D2 → 매 wait 직후·알림 전에 래퍼 생존 확인.
  • 회귀 테스트 11건(tests/test_issue30_stall_watch_stop.py, macOS/Linux 공통) — 모두 수정 전 코드에서 hang/실패.

테스트

  • af0a2a2 전체: 1230 passed / 4 failed / 5 skipped(73 분). 실패 3건은 단독 재실행 통과, 1건은 B3(공개 브로커 heartbeat 지연, 1회차 head 에서도 같음).
  • 최종 head 9200562 영향 파일(stall_watch_stop·stall_watch·issue14·issue33·tier1·version): 159 passed / 1 failed(B3) / 1 skipped(S6, Linux /proc).
  • test_issue14 idle-grace 테스트의 간헐 실패는 구독자 SUBSCRIBE 핸드셰이크 지연(공개 브로커가 ~14 s 무전달)으로, 감시자와 무관한 플레이크로 분류(grok 동의).

설계 합의 (A–D, 네 리뷰어)

항목 claude agy grok codex 결론
(A) #30 알림 전용(갱신 1: 자동 수락 없음, stalled 로 닫지 않음) 동의 동의 동의 동의 합의. stall_watch.py 의 유일한 발행은 progress(AST 테스트 S12 가 강제). 종결은 #33 시계뿐.
(B) 예산 연장을 #39 로 분리 조건부 동의 동의 동의 합의. 위험: 에이전트의 무한 연장 요청. 보완(공통): 연장 횟수·누적 예산 상한을 오케스트레이터(소비자) 쪽에 두고, token/attempt 로 인증된 요청만 멱등 처리해 새 절대 deadline 을 확정·배포; heartbeat 만 있고 state_change_seq 가 그대로면 거절(grok/agy). claude: MAM_JOB_EXTEND_MAX + CAS 로 timeout 갱신. 원격 잡에도 같은 규칙.
(C) #40 트리거는 completed 수신 측 동의 동의 조건부 동의 합의. 핀은 completed 를 검증하는 쪽: 래퍼 구독자(워크트리 creator·MAM_NO_SUBSCRIBER_DAEMON=1 도 잡마다 래퍼 구독자가 받음)가 기본 진입점, 루프 wait_for_job 직후는 누락 복구 경로로 같은 처리기를 호출(job/token/attempt 단위 중복 방지). 어댑터는 id_lifecycle·capture·pin 사실만 제공. #17 항목 4 에 흡수하거나 바로 다음.
(D) #17 항목 4 를 미룬 것 조건부 동의 동의 조건부 합의(조건 반영). 항목 4 로 옮길 bash 판정 목록을 확정: FINALIZE_CLOCK 선택·디스크 유예/재관측 종료 결정(M1), 감시 여부 판정 remote/human/MAM_STALL_WATCH(M2), MAM_JOB_TIMEOUT_S 검증·기본값(선행 0 8진수 문제 포함), rc→close_req/라벨 매핑, _map_sub_rc·cmd_wait 정규화, start/stop_stall_watch 수명 관리. bash 에는 spawn/wait/kill·키 주입·얇은 CLI 호환만 남긴다.

후속 (이슈로 추적)

  • #17 항목 4: 위 (D) 목록. #39: (B) 보완책. #40: (C) 결정(트리거 = 래퍼 구독자, 루프는 복구 경로).
  • M7 MAM_TEST_UNRELIABLE_AGENT 훅이 제품 코드에 있음(불안정 어댑터가 없어 테스트 seam 필요).
  • 공개 브로커 의존 테스트: B3, S9, S10, test_issue14 idle-grace, test_issue18:497 — 로컬 events.log 로 단언하거나 등록부터 브로커 격리.
  • MAM_JOB_TIMEOUT_S=08 이 bash 에서 8진수로 읽힘(공통 Python 파서로), 러너의 잘못된 값 무언, MAM_WAIT_GRACE_S 미문서.
  • SKILL.md 의 "≤60 s" 를 herdr wait 로 한정(발행 중 소멸은 발행이 끝날 때까지), 부모 소멸 테스트의 alive 대역을 wait 안에서 바꾸기(codex 3회차).

🤖 Generated with Claude Code

## 병합 요약 (v4.8.0) **리뷰 1회차 (head 1673818, `--all-reviewer`)**: claude NOT PASS(N1), codex NOT PASS(R1·R2), agy PASS, grok PASS. **수정 1 (af0a2a2)**: N1·R1·R2 + M5·M8·codex 비차단 2. **리뷰 2회차 (delta @af0a2a2)**: claude PASS, agy PASS, grok NOT PASS(N1: TERM 5 s 예산이 잡 close 시계를 늦춤), codex NOT PASS(D1: 알림 발행 중 닫힌/재디스패치된 잡에 재주입, D2: 대기 중 래퍼 소멸 뒤 1회 재주입). 첫 실행은 claude 세션 사용량 한도로 끊겨 그 잡을 `cancelled` 로 닫고 다시 돌렸다. **수정 2 (9200562)**: 감시자 정지를 KILL 즉시로(예산 없음), 키 입력 직전 strict 게이트 `_may_prompt()`(래퍼 생존 + 읽을 수 있고 열린 같은 dispatch), 매 wait 직후 래퍼 확인. **리뷰 3회차 (delta @9200562)**: claude·agy·grok·codex **4/4 PASS**. 릴리스 53f87e4, 병합 e1eb22b. ### 수정 - **N1 (claude)** 래퍼가 감시자를 `kill` 뒤 무한 `wait`. 원인: TERM 이 감시자의 fork-exec 창에 떨어지면 bash 가 삼킨다(자식이 exec 전까지 종료 시그널을 미룸) → 감시자는 잡이 닫힐 때까지 살고 래퍼는 멈춤. test_issue14 ack 테스트에서 2회 재현, 래퍼 trace 로 확인. `stop_stall_watch`: TERM → 최대 5 s → KILL. - **R1 (codex)** 래퍼가 TERM 으로 죽으면 감시자 고아 → `--watch-pid $$`; 감시자가 래퍼 pid 소멸을 보면 rc 0 종료. - **R2 (codex)** herdr 대기(≤60 s) 중 completed/재디스패치된 잡에 재주입 → `_dispatch_open()` 이 매 wait 뒤·nudge 직전에 레코드(종결 상태·dispatch_token·attempt_id·존재)를 다시 읽는다. `stalled:idle` 발행이 거부되면 prompt 를 보내지 않는다. - **M5/NB2** SKILL.md 종료 코드 표(idle 시계·벽시계 라벨·v4.8.0). **M8** stall-watch 테스트의 모듈 전체 Linux skip 을 S6(/proc) 하나로 좁힘; r4 stub 는 프로세스 간 비교라 `time.time()` (macOS 에서 47 passed / 1 skipped). - **2회차 반영**: grok N1 → KILL 즉시 정지. codex D1 → `_notify` 뒤 키 입력 직전 재확인(읽기 오류는 "보내지 않음"). codex D2 → 매 wait 직후·알림 전에 래퍼 생존 확인. - 회귀 테스트 11건(`tests/test_issue30_stall_watch_stop.py`, macOS/Linux 공통) — 모두 수정 전 코드에서 hang/실패. ### 테스트 - af0a2a2 전체: 1230 passed / 4 failed / 5 skipped(73 분). 실패 3건은 단독 재실행 통과, 1건은 B3(공개 브로커 heartbeat 지연, 1회차 head 에서도 같음). - 최종 head 9200562 영향 파일(stall_watch_stop·stall_watch·issue14·issue33·tier1·version): 159 passed / 1 failed(B3) / 1 skipped(S6, Linux /proc). - test_issue14 idle-grace 테스트의 간헐 실패는 구독자 SUBSCRIBE 핸드셰이크 지연(공개 브로커가 ~14 s 무전달)으로, 감시자와 무관한 플레이크로 분류(grok 동의). ### 설계 합의 (A–D, 네 리뷰어) | 항목 | claude | agy | grok | codex | 결론 | |---|---|---|---|---|---| | (A) #30 알림 전용(갱신 1: 자동 수락 없음, stalled 로 닫지 않음) | 동의 | 동의 | 동의 | 동의 | **합의.** `stall_watch.py` 의 유일한 발행은 `progress`(AST 테스트 S12 가 강제). 종결은 #33 시계뿐. | | (B) 예산 연장을 #39 로 분리 | 조건부 | 동의 | 동의 | 동의 | **합의.** 위험: 에이전트의 무한 연장 요청. 보완(공통): 연장 횟수·누적 예산 상한을 오케스트레이터(소비자) 쪽에 두고, token/attempt 로 인증된 요청만 멱등 처리해 새 절대 deadline 을 확정·배포; heartbeat 만 있고 `state_change_seq` 가 그대로면 거절(grok/agy). claude: `MAM_JOB_EXTEND_MAX` + CAS 로 timeout 갱신. 원격 잡에도 같은 규칙. | | (C) #40 트리거는 `completed` 수신 측 | 동의 | 동의 | 조건부 | 동의 | **합의.** 핀은 `completed` 를 검증하는 쪽: 래퍼 구독자(워크트리 creator·`MAM_NO_SUBSCRIBER_DAEMON=1` 도 잡마다 래퍼 구독자가 받음)가 기본 진입점, 루프 `wait_for_job` 직후는 누락 복구 경로로 같은 처리기를 호출(job/token/attempt 단위 중복 방지). 어댑터는 id_lifecycle·capture·pin 사실만 제공. #17 항목 4 에 흡수하거나 바로 다음. | | (D) #17 항목 4 를 미룬 것 | 조건부 | 동의 | 동의 | 조건부 | **합의(조건 반영).** 항목 4 로 옮길 bash 판정 목록을 확정: `FINALIZE_CLOCK` 선택·디스크 유예/재관측 종료 결정(M1), 감시 여부 판정 remote/human/`MAM_STALL_WATCH`(M2), `MAM_JOB_TIMEOUT_S` 검증·기본값(선행 0 8진수 문제 포함), rc→close_req/라벨 매핑, `_map_sub_rc`·`cmd_wait` 정규화, `start/stop_stall_watch` 수명 관리. bash 에는 spawn/wait/kill·키 주입·얇은 CLI 호환만 남긴다. | ### 후속 (이슈로 추적) - #17 항목 4: 위 (D) 목록. #39: (B) 보완책. #40: (C) 결정(트리거 = 래퍼 구독자, 루프는 복구 경로). - M7 `MAM_TEST_UNRELIABLE_AGENT` 훅이 제품 코드에 있음(불안정 어댑터가 없어 테스트 seam 필요). - 공개 브로커 의존 테스트: B3, S9, S10, test_issue14 idle-grace, test_issue18:497 — 로컬 events.log 로 단언하거나 등록부터 브로커 격리. - `MAM_JOB_TIMEOUT_S=08` 이 bash 에서 8진수로 읽힘(공통 Python 파서로), 러너의 잘못된 값 무언, `MAM_WAIT_GRACE_S` 미문서. - SKILL.md 의 "≤60 s" 를 herdr wait 로 한정(발행 중 소멸은 발행이 끝날 때까지), 부모 소멸 테스트의 alive 대역을 wait 안에서 바꾸기(codex 3회차). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tmpl/multi-agent-mux#41