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 이 아니라 - 다.
registry.py close --request deadline_timeout: 레코드 상태가 error 가 된다. 사유는 레코드에 남지 않는다(wrapper 는 cancelled 일 때만 --detail 을 넘긴다).
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.pyfinalize_end)의 순서를 그대로 흉내 낸다. 따라서 실제 루프에서도 같은 창이 있다.
wrapper 는 2단계 이벤트를 direct 경로에서만 발행한다. 그 밖의 경로에서는 사유가 끝까지 없다.
영향은 표시뿐이다. 상태가 CLOSED-UNCONFIRMED 이므로 리뷰어 규칙(미확인 종결 → NOT PASS)은 그대로 적용된다. 다만 리뷰어가 마감으로 닫혔는지 에이전트가 사유 없이 실패했는지 구별하지 못한다.
제안 (정할 것)
코드에서 고친다 (권장).
wrapper 가 close 에 2단계 이벤트와 같은 사유(deadline_timeout, wall_clock_timeout, agent_blocked)를 --detail 로 넘겨, 상태와 사유가 한 번에 레코드에 쓰이게 한다.
루프는 종결 이벤트에 사유가 없으면 레코드의 detail 을 쓴다.
그러면 창이 없어지고, direct 가 아닌 경로에서도 사유가 보인다.
테스트의 _deadline_close 도 같은 인자를 넘기게 맞춘다.
1단계와 2단계 사이에 루프가 읽는 경우를 결정적으로 재현하는 테스트를 더한다(1단계만 하고 2단계는 리뷰 프롬프트가 만들어진 뒤에 하기).
테스트만 고친다._deadline_close 를 이벤트 먼저, 상태 나중 순서로 바꾸거나, 사유 칸을 단언하지 않는다. 실제 창은 남는다.
## 증상
`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 는 릴리스 때 정한다.
에이전트가 직접 error 를 발행하는 경로(lib_py/delegation/publish_event.pyrecord_outcome)는 이미 이 원칙을 지킨다. 이벤트 로그(events.log)에 먼저 쓰고, 그 뒤에 레코드 상태를 바꾼다.
깨지는 곳은 wrapper 의 마감 처리(lib_py/delegation/wrapper.pyfinalize_end) 하나다. close 로 상태를 먼저 바꾸고, 사유 이벤트는 나중에 쓴다.
고려했다가 버린 방법: wrapper 에서 이벤트를 먼저 쓰고 close 를 나중에 한다
이전 시도의 사유가 새 시도에 섞인다.
지금은 close 의 dispatch token 비교가 이벤트 발행 여부를 정한다. 다시 배정된 잡의 이전 시도이면 close 가 rc 4 로 끝나고, 이벤트를 쓰지 않는다.
그런데 publish_event 는 로그를 먼저 쓰고 token 비교는 나중에 한다.
순서만 바꾸면 다시 배정된 잡의 로그에 이전 시도의 deadline_timeout 이 남고, 루프가 그것을 현재 시도의 사유로 읽을 수 있다.
direct 가 아닌 잡에는 먼저 쓸 이벤트가 없다. 이런 잡은 O-1 규칙 때문에 이벤트를 발행하지 않으므로, 사유는 계속 비어 있다.
설계: 상태와 사유를 한 번의 쓰기로 기록한다
SQLite WAL 이 보장하는 것과 같다. 읽는 쪽은 커밋 전이나 커밋 후 상태만 보고, 그 사이는 보지 못한다. 잡 레코드에도 이런 커밋 지점이 이미 있다. 잠금을 잡고 임시 파일에 쓴 다음 os.replace 로 바꾸는 쓰기다(mqtt_common.py).
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 다.
루프 (lib_py/loop/runner.py_subjob_state)
상태와 사유를 같은 레코드 스냅샷에서 읽는다.
레코드에 사유가 없을 때만 지금처럼 종결 이벤트(completed/error/cancelled)의 사유를 쓴다. 에이전트가 직접 발행한 경우와 옛 레코드가 여기에 해당한다.
분류 규칙은 바꾸지 않는다. 오케스트레이터 마감 사유이거나 사유 없는 error 이면 CLOSED-UNCONFIRMED 다.
레코드의 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 이 병합된 뒤에 시작한다.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
증상
tests/test_issue34_job_tree.py::test_t5b_a_deadline_closed_sub_job_is_unconfirmed_and_a_late_write_voids_the_round가 가끔 실패한다.[True]였지만 아래 경쟁은[False]에도 있다.deadline_timeout이 아니라-다.assert re.search(rf"\| {cid} \| .* \| CLOSED-UNCONFIRMED \| deadline_timeout \|", prompt)가 실패한다.원인
마감 처리는 프로세스 두 번으로 이루어지고, 루프는 그 사이에 기록을 읽을 수 있다.
registry.py close --request deadline_timeout: 레코드 상태가error가 된다. 사유는 레코드에 남지 않는다(wrapper 는cancelled일 때만--detail을 넘긴다).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.pyfinalize_end)의 순서를 그대로 흉내 낸다. 따라서 실제 루프에서도 같은 창이 있다.CLOSED-UNCONFIRMED이므로 리뷰어 규칙(미확인 종결 → NOT PASS)은 그대로 적용된다. 다만 리뷰어가 마감으로 닫혔는지 에이전트가 사유 없이 실패했는지 구별하지 못한다.제안 (정할 것)
close에 2단계 이벤트와 같은 사유(deadline_timeout,wall_clock_timeout,agent_blocked)를--detail로 넘겨, 상태와 사유가 한 번에 레코드에 쓰이게 한다.detail을 쓴다._deadline_close도 같은 인자를 넘기게 맞춘다._deadline_close를 이벤트 먼저, 상태 나중 순서로 바꾸거나, 사유 칸을 단언하지 않는다. 실제 창은 남는다.관련
결정: 본문 제안 1 을 아래 설계로 확정한다
원칙은 결과(상태)가 보이는 순간 사유도 이미 보여야 한다 이다.
지금 코드에서 이 원칙이 어디서 깨지는가
error를 발행하는 경로(lib_py/delegation/publish_event.pyrecord_outcome)는 이미 이 원칙을 지킨다. 이벤트 로그(events.log)에 먼저 쓰고, 그 뒤에 레코드 상태를 바꾼다.lib_py/delegation/wrapper.pyfinalize_end) 하나다.close로 상태를 먼저 바꾸고, 사유 이벤트는 나중에 쓴다.고려했다가 버린 방법: wrapper 에서 이벤트를 먼저 쓰고
close를 나중에 한다close의 dispatch token 비교가 이벤트 발행 여부를 정한다. 다시 배정된 잡의 이전 시도이면close가 rc 4 로 끝나고, 이벤트를 쓰지 않는다.publish_event는 로그를 먼저 쓰고 token 비교는 나중에 한다.deadline_timeout이 남고, 루프가 그것을 현재 시도의 사유로 읽을 수 있다.설계: 상태와 사유를 한 번의 쓰기로 기록한다
SQLite WAL 이 보장하는 것과 같다. 읽는 쪽은 커밋 전이나 커밋 후 상태만 보고, 그 사이는 보지 못한다. 잡 레코드에도 이런 커밋 지점이 이미 있다. 잠금을 잡고 임시 파일에 쓴 다음
os.replace로 바꾸는 쓰기다(mqtt_common.py).finalize_end가close에 2단계 이벤트와 같은 사유(deadline_timeout,wall_clock_timeout,agent_blocked)를--detail로 넘긴다.error와 사유가 같은 레코드 쓰기에 들어간다.error이벤트는 지금처럼 그 뒤에 발행한다. 이 이벤트는 MQTT 구독자와 감사 로그를 위한 것이다.agent_blocked는 레코드에도 이벤트와 같은 사유로 남아야 한다. 지금은close요청이deadline_timeout이고 이벤트만agent_blocked다.lib_py/loop/runner.py_subjob_state)completed/error/cancelled)의 사유를 쓴다. 에이전트가 직접 발행한 경우와 옛 레코드가 여기에 해당한다.error이면CLOSED-UNCONFIRMED다.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)는 지금과 똑같이 보인다.범위 밖
작업은 열려 있는 PR 이 병합된 뒤에 시작한다.