[Bug] 마감으로 닫힌 하위 잡의 사유가 리뷰 프롬프트에 - 로 나올 수 있다 (close 와 error 이벤트 사이의 창, test_issue34_job_tree t5b flake) #80

Open
opened 2026-10-28 23:02:50 +00:00 by Godopu · 1 comment
Owner

증상

tests/test_issue34_job_tree.py::test_t5b_a_deadline_closed_sub_job_is_unconfirmed_and_a_late_write_voids_the_round 가 가끔 실패한다.

  • Linux, #68 브랜치(루프·잡 코드는 main 과 같음)에서 열 번 남짓 돌려 두 번 실패했다. 한 번은 전체 스위트, 한 번은 그 직후 다른 테스트 파일과 함께 돌렸을 때다. 단독 실행과 같은 조합의 재실행에서는 head, base 모두 통과했다. 두 번 모두 [True] 였지만 아래 경쟁은 [False] 에도 있다.
  • 실패할 때 리뷰 프롬프트의 하위 잡 표에서 사유 칸이 deadline_timeout 이 아니라 - 다.
| <job> | herdr:img | Worker | CLOSED-UNCONFIRMED | - | 1 | ... |

assert re.search(rf"\| {cid} \| .* \| CLOSED-UNCONFIRMED \| deadline_timeout \|", prompt) 가 실패한다.

원인

마감 처리는 프로세스 두 번으로 이루어지고, 루프는 그 사이에 기록을 읽을 수 있다.

  1. registry.py close --request deadline_timeout: 레코드 상태가 error 가 된다. 사유는 레코드에 남지 않는다(wrapper 는 cancelled 일 때만 --detail 을 넘긴다).
  2. publish_event.py --event error --detail deadline_timeout: 사유가 이 이벤트로 들어온다.

루프(lib_py/loop/runner.py _subjob_state)는 1단계 뒤 상태가 종결이 되면 하위 잡을 끝난 것으로 보고 리뷰 프롬프트를 만든다. 사유는 이벤트에서 읽는다(get_feedback). 2단계 전에 읽으면 사유가 비어 있다. 사유 없는 error 도 CLOSED-UNCONFIRMED 로 분류되므로 상태 칸은 맞고, 사유 칸만 - 가 된다.

  • 테스트의 _deadline_close 는 실제 wrapper(lib_py/delegation/wrapper.py finalize_end)의 순서를 그대로 흉내 낸다. 따라서 실제 루프에서도 같은 창이 있다.
  • wrapper 는 2단계 이벤트를 direct 경로에서만 발행한다. 그 밖의 경로에서는 사유가 끝까지 없다.
  • 영향은 표시뿐이다. 상태가 CLOSED-UNCONFIRMED 이므로 리뷰어 규칙(미확인 종결 → NOT PASS)은 그대로 적용된다. 다만 리뷰어가 마감으로 닫혔는지 에이전트가 사유 없이 실패했는지 구별하지 못한다.

제안 (정할 것)

  1. 코드에서 고친다 (권장).
    • wrapper 가 close 에 2단계 이벤트와 같은 사유(deadline_timeout, wall_clock_timeout, agent_blocked)를 --detail 로 넘겨, 상태와 사유가 한 번에 레코드에 쓰이게 한다.
    • 루프는 종결 이벤트에 사유가 없으면 레코드의 detail 을 쓴다.
    • 그러면 창이 없어지고, direct 가 아닌 경로에서도 사유가 보인다.
    • 테스트의 _deadline_close 도 같은 인자를 넘기게 맞춘다.
    • 1단계와 2단계 사이에 루프가 읽는 경우를 결정적으로 재현하는 테스트를 더한다(1단계만 하고 2단계는 리뷰 프롬프트가 만들어진 뒤에 하기).
  2. 테스트만 고친다. _deadline_close 를 이벤트 먼저, 상태 나중 순서로 바꾸거나, 사유 칸을 단언하지 않는다. 실제 창은 남는다.

관련

  • #34 (하위 위임 잡), #68 (이 flake 를 처음 본 PR)
  • 버전 번호와 VERSIONS.md 는 릴리스 때 정한다.
## 증상 `tests/test_issue34_job_tree.py::test_t5b_a_deadline_closed_sub_job_is_unconfirmed_and_a_late_write_voids_the_round` 가 가끔 실패한다. - Linux, #68 브랜치(루프·잡 코드는 main 과 같음)에서 열 번 남짓 돌려 두 번 실패했다. 한 번은 전체 스위트, 한 번은 그 직후 다른 테스트 파일과 함께 돌렸을 때다. 단독 실행과 같은 조합의 재실행에서는 head, base 모두 통과했다. 두 번 모두 `[True]` 였지만 아래 경쟁은 `[False]` 에도 있다. - 실패할 때 리뷰 프롬프트의 하위 잡 표에서 사유 칸이 `deadline_timeout` 이 아니라 `-` 다. ``` | <job> | herdr:img | Worker | CLOSED-UNCONFIRMED | - | 1 | ... | ``` `assert re.search(rf"\| {cid} \| .* \| CLOSED-UNCONFIRMED \| deadline_timeout \|", prompt)` 가 실패한다. ## 원인 마감 처리는 프로세스 두 번으로 이루어지고, 루프는 그 사이에 기록을 읽을 수 있다. 1. `registry.py close --request deadline_timeout`: 레코드 상태가 `error` 가 된다. 사유는 레코드에 남지 않는다(wrapper 는 `cancelled` 일 때만 `--detail` 을 넘긴다). 2. `publish_event.py --event error --detail deadline_timeout`: 사유가 이 이벤트로 들어온다. 루프(`lib_py/loop/runner.py` `_subjob_state`)는 1단계 뒤 상태가 종결이 되면 하위 잡을 끝난 것으로 보고 리뷰 프롬프트를 만든다. 사유는 이벤트에서 읽는다(`get_feedback`). 2단계 전에 읽으면 사유가 비어 있다. 사유 없는 `error` 도 `CLOSED-UNCONFIRMED` 로 분류되므로 상태 칸은 맞고, 사유 칸만 `-` 가 된다. - 테스트의 `_deadline_close` 는 실제 wrapper(`lib_py/delegation/wrapper.py` `finalize_end`)의 순서를 그대로 흉내 낸다. 따라서 **실제 루프에서도 같은 창이 있다.** - wrapper 는 2단계 이벤트를 direct 경로에서만 발행한다. 그 밖의 경로에서는 사유가 끝까지 없다. - 영향은 표시뿐이다. 상태가 `CLOSED-UNCONFIRMED` 이므로 리뷰어 규칙(미확인 종결 → NOT PASS)은 그대로 적용된다. 다만 리뷰어가 마감으로 닫혔는지 에이전트가 사유 없이 실패했는지 구별하지 못한다. ## 제안 (정할 것) 1. **코드에서 고친다 (권장).** - wrapper 가 `close` 에 2단계 이벤트와 같은 사유(`deadline_timeout`, `wall_clock_timeout`, `agent_blocked`)를 `--detail` 로 넘겨, 상태와 사유가 한 번에 레코드에 쓰이게 한다. - 루프는 종결 이벤트에 사유가 없으면 레코드의 `detail` 을 쓴다. - 그러면 창이 없어지고, direct 가 아닌 경로에서도 사유가 보인다. - 테스트의 `_deadline_close` 도 같은 인자를 넘기게 맞춘다. - 1단계와 2단계 사이에 루프가 읽는 경우를 결정적으로 재현하는 테스트를 더한다(1단계만 하고 2단계는 리뷰 프롬프트가 만들어진 뒤에 하기). 2. **테스트만 고친다.** `_deadline_close` 를 이벤트 먼저, 상태 나중 순서로 바꾸거나, 사유 칸을 단언하지 않는다. 실제 창은 남는다. ## 관련 - #34 (하위 위임 잡), #68 (이 flake 를 처음 본 PR) - 버전 번호와 VERSIONS.md 는 릴리스 때 정한다.
Author
Owner

결정: 본문 제안 1 을 아래 설계로 확정한다

원칙은 결과(상태)가 보이는 순간 사유도 이미 보여야 한다 이다.

지금 코드에서 이 원칙이 어디서 깨지는가

  • 에이전트가 직접 error 를 발행하는 경로(lib_py/delegation/publish_event.py record_outcome)는 이미 이 원칙을 지킨다. 이벤트 로그(events.log)에 먼저 쓰고, 그 뒤에 레코드 상태를 바꾼다.
  • 깨지는 곳은 wrapper 의 마감 처리(lib_py/delegation/wrapper.py finalize_end) 하나다. close 로 상태를 먼저 바꾸고, 사유 이벤트는 나중에 쓴다.

고려했다가 버린 방법: wrapper 에서 이벤트를 먼저 쓰고 close 를 나중에 한다

  1. 이전 시도의 사유가 새 시도에 섞인다.
    • 지금은 close 의 dispatch token 비교가 이벤트 발행 여부를 정한다. 다시 배정된 잡의 이전 시도이면 close 가 rc 4 로 끝나고, 이벤트를 쓰지 않는다.
    • 그런데 publish_event 는 로그를 먼저 쓰고 token 비교는 나중에 한다.
    • 순서만 바꾸면 다시 배정된 잡의 로그에 이전 시도의 deadline_timeout 이 남고, 루프가 그것을 현재 시도의 사유로 읽을 수 있다.
  2. direct 가 아닌 잡에는 먼저 쓸 이벤트가 없다. 이런 잡은 O-1 규칙 때문에 이벤트를 발행하지 않으므로, 사유는 계속 비어 있다.

설계: 상태와 사유를 한 번의 쓰기로 기록한다

SQLite WAL 이 보장하는 것과 같다. 읽는 쪽은 커밋 전이나 커밋 후 상태만 보고, 그 사이는 보지 못한다. 잡 레코드에도 이런 커밋 지점이 이미 있다. 잠금을 잡고 임시 파일에 쓴 다음 os.replace 로 바꾸는 쓰기다(mqtt_common.py).

  1. wrapper
    • finalize_end 가 close 에 2단계 이벤트와 같은 사유(deadline_timeout, wall_clock_timeout, agent_blocked)를 --detail 로 넘긴다.
    • 그러면 상태 error 와 사유가 같은 레코드 쓰기에 들어간다.
    • CAS 가 실패하면(rc 4) 상태도 사유도 쓰이지 않는다. 지금과 같다.
    • direct 경로의 error 이벤트는 지금처럼 그 뒤에 발행한다. 이 이벤트는 MQTT 구독자와 감사 로그를 위한 것이다.
    • agent_blocked 는 레코드에도 이벤트와 같은 사유로 남아야 한다. 지금은 close 요청이 deadline_timeout 이고 이벤트만 agent_blocked 다.
  2. 루프 (lib_py/loop/runner.py _subjob_state)
    • 상태와 사유를 같은 레코드 스냅샷에서 읽는다.
    • 레코드에 사유가 없을 때만 지금처럼 종결 이벤트(completed/error/cancelled)의 사유를 쓴다. 에이전트가 직접 발행한 경우와 옛 레코드가 여기에 해당한다.
    • 분류 규칙은 바꾸지 않는다. 오케스트레이터 마감 사유이거나 사유 없는 error 이면 CLOSED-UNCONFIRMED 다.
  3. 레코드의 detail 필드
    • 지금은 cancelled 경로(session_busy, preflight 사유)에서만 쓰인다.
    • 다른 곳에서 detail 을 다르게 해석하는 코드가 있는지 확인한다. 예: get_feedback, status, 리포트.

완료 조건

  • 마감으로 닫힌 하위 잡은 상태가 error 로 보이는 순간부터 리뷰 프롬프트에 사유가 나온다. 이벤트가 아직 없어도, 끝까지 없어도(direct 가 아닌 잡) 마찬가지다.
  • 다시 배정된 잡의 이전 시도가 close 를 시도하면(token 불일치), 레코드의 상태와 사유가 바뀌지 않는다.
  • 결정적 회귀 테스트를 둔다. close 만 하고 error 이벤트는 리뷰 프롬프트가 만들어진 뒤에 발행해도 사유가 나와야 한다. 이 테스트는 지금 main 에서 매번 실패해야 한다.
  • test_issue34_job_tree.py 의 _deadline_close 는 wrapper 와 같은 인자를 넘기도록 맞춘다. t5b 는 타이밍과 관계없이 통과해야 한다.
  • 에이전트가 직접 발행한 사유(completed, 에이전트의 error, cancelled)는 지금과 똑같이 보인다.
  • 다른 기존 테스트는 바꾸지 않고 통과한다. macOS(bash 3.2, Python 3.9)와 호환된다.

범위 밖

  • 이벤트 발행 순서와 MQTT 계약을 바꾸는 것.
  • 버전 번호와 VERSIONS.md.

작업은 열려 있는 PR 이 병합된 뒤에 시작한다.

## 결정: 본문 제안 1 을 아래 설계로 확정한다 원칙은 **결과(상태)가 보이는 순간 사유도 이미 보여야 한다** 이다. ### 지금 코드에서 이 원칙이 어디서 깨지는가 - 에이전트가 직접 `error` 를 발행하는 경로(`lib_py/delegation/publish_event.py` `record_outcome`)는 이미 이 원칙을 지킨다. 이벤트 로그(`events.log`)에 먼저 쓰고, 그 뒤에 레코드 상태를 바꾼다. - 깨지는 곳은 wrapper 의 마감 처리(`lib_py/delegation/wrapper.py` `finalize_end`) 하나다. `close` 로 상태를 먼저 바꾸고, 사유 이벤트는 나중에 쓴다. ### 고려했다가 버린 방법: wrapper 에서 이벤트를 먼저 쓰고 `close` 를 나중에 한다 1. **이전 시도의 사유가 새 시도에 섞인다.** - 지금은 `close` 의 dispatch token 비교가 이벤트 발행 여부를 정한다. 다시 배정된 잡의 이전 시도이면 `close` 가 rc 4 로 끝나고, 이벤트를 쓰지 않는다. - 그런데 `publish_event` 는 로그를 먼저 쓰고 token 비교는 나중에 한다. - 순서만 바꾸면 다시 배정된 잡의 로그에 이전 시도의 `deadline_timeout` 이 남고, 루프가 그것을 현재 시도의 사유로 읽을 수 있다. 2. **direct 가 아닌 잡에는 먼저 쓸 이벤트가 없다.** 이런 잡은 O-1 규칙 때문에 이벤트를 발행하지 않으므로, 사유는 계속 비어 있다. ### 설계: 상태와 사유를 한 번의 쓰기로 기록한다 SQLite WAL 이 보장하는 것과 같다. 읽는 쪽은 커밋 전이나 커밋 후 상태만 보고, 그 사이는 보지 못한다. 잡 레코드에도 이런 커밋 지점이 이미 있다. 잠금을 잡고 임시 파일에 쓴 다음 `os.replace` 로 바꾸는 쓰기다(`mqtt_common.py`). 1. **wrapper** - `finalize_end` 가 `close` 에 2단계 이벤트와 같은 사유(`deadline_timeout`, `wall_clock_timeout`, `agent_blocked`)를 `--detail` 로 넘긴다. - 그러면 상태 `error` 와 사유가 같은 레코드 쓰기에 들어간다. - CAS 가 실패하면(rc 4) 상태도 사유도 쓰이지 않는다. 지금과 같다. - direct 경로의 `error` 이벤트는 지금처럼 그 뒤에 발행한다. 이 이벤트는 MQTT 구독자와 감사 로그를 위한 것이다. - `agent_blocked` 는 레코드에도 이벤트와 같은 사유로 남아야 한다. 지금은 `close` 요청이 `deadline_timeout` 이고 이벤트만 `agent_blocked` 다. 2. **루프** (`lib_py/loop/runner.py` `_subjob_state`) - 상태와 사유를 **같은 레코드 스냅샷**에서 읽는다. - 레코드에 사유가 없을 때만 지금처럼 종결 이벤트(`completed`/`error`/`cancelled`)의 사유를 쓴다. 에이전트가 직접 발행한 경우와 옛 레코드가 여기에 해당한다. - 분류 규칙은 바꾸지 않는다. 오케스트레이터 마감 사유이거나 사유 없는 `error` 이면 `CLOSED-UNCONFIRMED` 다. 3. **레코드의 `detail` 필드** - 지금은 `cancelled` 경로(`session_busy`, preflight 사유)에서만 쓰인다. - 다른 곳에서 `detail` 을 다르게 해석하는 코드가 있는지 확인한다. 예: `get_feedback`, status, 리포트. ### 완료 조건 - 마감으로 닫힌 하위 잡은 상태가 `error` 로 보이는 순간부터 리뷰 프롬프트에 사유가 나온다. 이벤트가 아직 없어도, 끝까지 없어도(direct 가 아닌 잡) 마찬가지다. - 다시 배정된 잡의 이전 시도가 `close` 를 시도하면(token 불일치), 레코드의 상태와 사유가 바뀌지 않는다. - 결정적 회귀 테스트를 둔다. `close` 만 하고 `error` 이벤트는 리뷰 프롬프트가 만들어진 뒤에 발행해도 사유가 나와야 한다. 이 테스트는 지금 main 에서 매번 실패해야 한다. - `test_issue34_job_tree.py` 의 `_deadline_close` 는 wrapper 와 같은 인자를 넘기도록 맞춘다. t5b 는 타이밍과 관계없이 통과해야 한다. - 에이전트가 직접 발행한 사유(`completed`, 에이전트의 `error`, `cancelled`)는 지금과 똑같이 보인다. - 다른 기존 테스트는 바꾸지 않고 통과한다. macOS(bash 3.2, Python 3.9)와 호환된다. ### 범위 밖 - 이벤트 발행 순서와 MQTT 계약을 바꾸는 것. - 버전 번호와 VERSIONS.md. 작업은 열려 있는 PR 이 병합된 뒤에 시작한다.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tmpl/multi-agent-mux#80