라운드 4 계약과 통합
- 작성일: 2026-10-20
- 작성: 메인 세션 (오케스트레이터), 브랜치
feature/m2-rule-engine - 관련 문서: 시스템 구조와 데이터 모델 (2.1, 4.2, 5.5.1, 6, 7, 8, 12.2), 화면설계서 (부록 B, C 항목), WBS와 마일스톤, 이전 라운드 라운드 3 통합
목표
M3 (AI 트리아지) 와 M4 (결론, 검토, 감사 로그) 를 한 라운드에 병렬로 시작한다. 사용자 지시는 "선행적으로 병렬개발 가능한 것들 진행시키고, 메인도 팀 에이전트를 통해 개발" 이다. 그래서 컨텍스트와 읽기 화면은 라운드 4a 의 워커 5명에게 나누고, 컨텍스트가 있어야 테스트할 수 있는 화면 동작은 병합 뒤 라운드 4b 로 미룬다.
한 일
선행 작업 (워커 시작 전 커밋)
Tickmark.AI.Client에model/0콜백,Tickmark.AI.model/0(fa8a3c1). Fake 는"fake-triage-v1", Anthropic 은 config 의:model.AuditTrail.broadcast_appended/1(f0769f5). engagement 토픽에{:audit_appended, %{seq, action, actor_type, subject_type, subject_id, entry_number}}를 보낸다. engagement 가 없는 이벤트는 보내지 않는다.- S2, S3, S4 LiveView 에
{:triage_requested, _}무시 절과 테스트 (f0769f5). 새 메시지가 처리되지 않으면 이미 열려 있는 화면 프로세스가 죽는다. - 계약 (아키텍처 문서): 2.1 시그니처, 4.2 payload, 5.5.1 Triage 순서와 워커 반환표, 6장 메시지와 보내는 시점, 7장
model/0, 8장submit_conclusion/3와decide/4순서, 12.2 배정.
README 초안 (WBS 6.1 당김)
- 사용자 요청으로 M6 의 README 를 먼저 썼다. Phoenix 생성기 기본 README 를 교체했다. 목표와 제품 원칙, 화면 S1-S6 과 상태, 룰 8종, 아키텍처 그림과 컨텍스트 표, 주요 설계 결정, 기술 스택 버전 (
mix.lock과 AGENTS.md 9장 기준), Why Elixir, 로컬 실행과 데모 순서, 환경 변수, 테스트, 디렉터리 구조, 문서 링크. - Why Elixir 는 프로젝트 선정 기록 (devlog 10/17-1) 에 기술적 이유가 따로 없어서, 이 코드에서 실제로 쓰는 곳 (LiveView 와 PubSub, Oban job 격리와 FailureHandler, Postgres 위의 job 큐, 오류 튜플 패턴 매칭과 changeset) 만 근거로 적었다.
- 데모 순서와 화면 문구는 코드와 대조했다 ("GL 불러오기", "합성 GL 생성", "테스트 실행",
/dev/mailbox). S5, S6 는 개발 중으로 표시했고 M6 에 최종 상태와 데모 GIF 를 반영한다.
라운드 4a 배정
| 워커 | 에이전트 / 모델 | 범위 | 이유 |
|---|---|---|---|
| W1 | Claude Fable xhigh | Triage 컨텍스트, TriageWorker, FailureHandler 분기 | 동시성 (조건부 update_all), Oban 재시도/snooze/cancel, 실패 분류가 섞여 고급 추론이 필요 |
| W2 | Codex Astra xhigh | Review 컨텍스트와 SoD | 직무분리 경로와 DB backstop 을 빠짐없이 증명해야 함 |
| W3 | Codex Sol 6.1 max | list_events/3, S6 |
쿼리와 화면, 명세가 구체적 |
| W4 | Codex Sol 6.1 max | get_finding!/2, S5 표시 |
읽기 전용 화면 |
| W5 | Codex Sol 6.1 max | M2 마무리 (2.4, 2.6), audit_appended 보강, 느린 쿼리 조사 |
남은 테스트와 기존 호출 지점 정리 |
결정과 트레이드오프
ai_triages.model은 요청 모델, 유일 키에 들어간다. 대안은 응답 모델 (response_model) 을 키로 쓰는 것이었다. 응답 모델은 API 를 부른 뒤에야 알 수 있어서, "같은 입력이면 API 를 다시 부르지 않는다" 는 재사용 검사를 호출 전에 할 수 없다. 그래서 클라이언트가 요청할 모델을 알려주는model/0을 만들었다. Fake 와 실제 모델의 제안이 같은 키로 섞이지 않는 효과도 있다.- 일괄 요청은 조건부
update_all로 중복을 막는다. 대안은 후보 finding 을 모두FOR UPDATE로 잠그는 것이었다. 100건을 잠그면 건별 요청이나 워커 완료와 잠금 순서가 엇갈릴 수 있다.where triage_status == :none를 update 에 다시 걸고 실제로 바뀐 id 만 job 으로 넣으면, 동시 요청이 와도 한 finding 은 한 번만 들어간다. 대가는 후보를 고른 뒤 다른 요청이 가져간 건은 조용히 빠진다는 점이고, 반환하는enqueued가 실제 수를 알려준다. - 일괄 요청은 finding 마다 broadcast 하지 않는다.
:triage_requested한 번을 보내고 S4 가 한 번 다시 읽는다. 대안 (finding 마다finding_updated) 은 100번 다시 읽게 된다. decide/4가conclusion_id를 받는다 (C-11). 검토자가 화면을 연 뒤 작성자가 결론을 다시 내면, 검토자는 보지 않은 결론을 승인하게 된다. 화면이 본 결론 id 를 넘기고 최신 결론과 다르면:stale로 막는다.- 작성자의 검토 시도도
review.denied로 남긴다 (C-10). 비멤버는 이벤트 없이:forbidden이다. 비멤버에게 이벤트를 남기면 그 감사 대상의 로그에 외부인이 흔적을 쓸 수 있고, 대상의 존재를 알려주게 된다. - S6 전표번호 필터는 payload 의
entry_number로 거른다 (C-13). 화면설계서는 finding 을 거쳐 찾는 서브쿼리를 제안했다. 그러면AuditTrail이 Testing 테이블을 알아야 한다. finding 대상 이벤트가 모두entry_number를 갖게 하면AuditTrail은 다른 컨텍스트에 기대지 않는다. 대가는 payload 키 규칙을 모든 컨텍스트가 지켜야 한다는 점이고, 4.2 표에 적었다. ai_triage.failed의 행위자는 system. 재시도 소진이나 크래시에는 모델 응답이 없다. 실패를 판정하는 쪽은 워커다.- 화면 동작은 라운드 4b 로 미뤘다. S5 의 단추와 S4 의 AI 열을 지금 같이 맡기면 W4 는 아직 없는 Triage, Review 함수를 흉내 내야 한다. 시그니처가 바뀌면 다시 고쳐야 한다. 대신 읽기 화면만 먼저 만들게 했다.
- W4 의 반려 사유 폼은 4b 로 (W4 질문). 계약 12.2 는 S5 의 두 폼을 모두 검증하게 했다. 그런데 반려 사유 입력란은 '반려' 를 누른 뒤에야 열리는데 그 단추는 이번 라운드에 disabled 다. 지금 만들면 열 방법이 없는 폼을 테스트용으로 따로 열어야 한다. 2단계 흐름 전체를
decide/4와 함께 4b 에서 붙이기로 했다. - AI 입력에서 입력자 전표 수를 뺐다 (W1 질문). 도메인 4.1 은 context 에 '입력자의 모집단 내 전표 수' 를 넣게 했는데 어떤 룰도 이 값을 계산하지 않는다. 대안은
triage_finding/1에서 따로 세는 것이었다. 비용은 작지만 (import 안 8천 건 count), 합성 데이터의 입력자는 acct01-05 와 sys_batch 뿐이라 신호가 없고, S5 에 보이는 룰 근거에 없는 값이 AI 입력에 들어간다.build_input/2를 순수 함수로 두고 2단계 '드문 입력자' 룰의 details 로 넣기로 했다. - 워커 5명. AGENTS.md 5.4절의 3-4명 상한을 넘는다. 사용자 지시를 따랐고, W5 는 파일이 겹치지 않는 마무리 작업이라 검토 부담이 작다고 봤다.
- S5 동작 일부를 W6 에 먼저 맡겼다. 4b 는 원래 W1, W2, W4 병합 뒤 메인이 하기로 했다. 그런데 결론 제출과 승인/반려는 W2 (Review) 와 W4 (S5 표시) 만 있으면 된다. W1 을 기다리면 메인이 4b 를 한꺼번에 해야 해서 MVP (10/23) 직전에 몰린다. AI 요청 단추, Presence, 검토 대기열은 W1 이나 여러 화면에 걸리므로 메인에 남겼다. W6 의 소유는
finding_live/*뿐이라 진행 중인 W1, W5 와 파일이 겹치지 않는다. - 확인 단추는 화면에 그린 결론 id 를 보낸다 (C-11). 대안은 서버 assign 의 최신 결론 id 를 쓰는 것이었다. 그러면 검토자가 옛 화면에서 누른 클릭이 PubSub 로 막 갱신된 새 결론에 붙어,
:stale검사가 의미 없어진다. 화면 값은 조작될 수 있지만decide/4가 최신 결론과 비교하므로 조작해도:stale이 된다.
막힌 점과 해결
- 세션이 끊긴 뒤 orchestration check 가
consumer_fenced로 거부됐다. 메인 세션이 다시 시작되면 새 터미널이 run 의 코디네이터로 묶여 있지 않다.orca orchestration run-use --id <run>으로 다시 묶자 밀린 메시지 3건을 받았다. 워커와 작업은 그대로 살아 있었다. - 워커 탭이 worktree 마다 흩어져 사용자가 하나씩 눌러 봐야 했다 (사용자 피드백). 메인 worktree 에 '워커 모니터' 탭을 열어, 진행 중인 워커 터미널 화면을 3초마다 한 화면에 모아 보이게 했다 (
worker-list로 새 워커를 자동으로 찾는다). - cff18a4 를 precommit 실패 상태로 커밋했다.
mix precommit 2>&1 | tail -4로 끝부분만 보고 커밋했는데, 파이프의 종료 코드는tail의 것이라 실패가 가려졌다. 실제 결과는471/473 passed였고, 실패 2건은 RulesLabelTest 의 DuplicateEntry 15초 DB 타임아웃 (이전부터 간헐적) 이었다. 그 커밋은 문서만 바꿔 코드 영향은 없었고, W5 가 같은 쿼리를 고쳐 W5 병합 뒤 484개가 모두 통과했다. 이후 메인은set -o pipefail을 붙여 실행한다.
Elixir 학습 포인트
- behaviour 콜백을 늘리면 모든 구현이 컴파일 경고를 낸다.
@callback model/0을 추가하자@impl true가 없는 구현은 "required by behaviour" 경고를 낸다.mix precommit은 경고를 오류로 다루므로 Fake 와 Anthropic 을 함께 고쳐야 통과한다. 계약 변경이 구현 누락을 컴파일 단계에서 잡아준다. - LiveView 의
handle_info/2는 처리하지 않는 메시지에서 죽는다. 같은 토픽을 여러 화면이 구독하므로, 한 화면에만 필요한 메시지를 추가하면 나머지 화면에 무시 절을 넣어야 한다. 마지막에def handle_info(_, socket)를 두는 방법도 있지만, 그러면 메시지 이름을 잘못 쓴 버그도 조용히 묻힌다. 메시지마다 절을 두었다.
측정
| 시점 | mix test |
|---|---|
| A 병합 (fadb51c) | 385 passed |
| 선행 작업 (f0769f5) | 389 passed |
| W2 병합 (cf88e39) | 420 passed |
| W3 병합 (80dc408) | 445 passed |
| W4 병합 (34c247e) | 473 passed |
| W5 병합 (824b89b) | 484 passed (DuplicateEntry 타임아웃 해소) |
| W1 병합 (52c38c8) | 518 passed |
| 기본 로케일 ko (fbb30b8) | 519 passed |
| W6 병합 (2718ff9) | 546 passed |
W6 병합 (이어 씀)
W1 병합 뒤 라운드 4b 선행 W6 (S5 결론 제출, 승인/반려 2단계) 를 병합하기 전에 메인이 고친 것과 병합 결과다.
- S2
start_run의 engagement 확인 (a6a52c0). W5 가 찾은 문제다.start_run이벤트는 import id 만 받는다. 그래서 사용자가 속한 다른 engagement 의 GL id 를 보내면 그 engagement 에서 실행이 시작됐다. 화면의 engagement 와 다른 GL 은 권한 없음으로 거절하고 테스트를 더했다. - 간헐 DB 타임아웃의 원인 (9e7dbfd). W6 는 partition 7 전체 검증에서 496/500 을 보고했고, 같은 seed 로 다시 돌리면 통과했다. cff18a4 때의 DuplicateEntry 15초 타임아웃과 같은 증상이다. 원인은 audit_events 의 TRUNCATE 거부 테스트였다.
- TRUNCATE 는 트리거가 거부하기 전에 테이블에 ACCESS EXCLUSIVE 잠금을 요청한다.
- async 테스트 사이에서 돌면 감사 이벤트를 쓴 다른 테스트의 열린 트랜잭션 뒤에 줄을 선다.
- 그 뒤의 audit_events 삽입이 전부 이 대기 줄 뒤에 막혀, 무관한 테스트가 타임아웃 났다.
- 이 테스트를
async: false모듈로 옮겼다. ExUnit 은 async 모듈을 모두 끝낸 뒤 동기 모듈을 돌리므로 겹치지 않는다.
- 기본 로케일 ko (fbb30b8, 519 passed). 라운드 5 선행 작업이다 (라운드 5 devlog).
- W6 병합 (2718ff9, 546 passed). 충돌은 없었다. 메인이 diff 를 확인한 범위는 세 가지다.
- 확인 단추가 화면에 그린
conclusion_id를 보낸다 (C-11). - 작성자 초안이 PubSub 갱신에 지워지지 않는다.
- 소유 파일 밖 변경이 없다.
- 타임아웃 수정 뒤의 병합 검증에서 546개가 모두 통과했다.
- 확인 단추가 화면에 그린
학습 포인트를 하나 더한다. PostgreSQL 잠금 대기열은 뒤에 온 요청도 막는다. ACCESS EXCLUSIVE 를 기다리는 요청이 하나 있으면, 그 뒤에 온 약한 잠금 (INSERT 의 ROW EXCLUSIVE) 도 그 요청 뒤에 선다. 지금 잠금을 가진 트랜잭션과는 충돌하지 않는 요청이어도 그렇다. 그래서 "거부될 TRUNCATE" 하나가 테이블 전체를 멈출 수 있다. Ecto sandbox 의 테스트 트랜잭션은 테스트가 끝날 때까지 열려 있어 이 대기가 길어진다.
다음 할 일
- 워커 보고를 받으면 diff 와 검증 출력을 직접 확인하고 하나씩 병합, 병합마다
mix precommit. - 라운드 4b: S5 동작, S4 AI 열과 일괄 요청, Presence, 통합 테스트, 브라우저 확인.
- PR #2 는 사용자 승인으로 병합했다 (adf144c, 1.19 완료).
- 배포 대상 (5.1): 사용자 조건은 무료 우선, Vercel 선호. 배포 대상 검토 에서 Render Free (앱) + Neon Free (DB) 를 제안했다. Vercel 은 정적 소개 페이지에만 쓴다. 사용자 결정 대기.
- W5 발견: S2
start_run이 import 의 engagement 를 확인하지 않는다. W5 병합 뒤 고친다.
관련 커밋
- fa8a3c1:
AI.model/0 - f0769f5:
broadcast_appended/1,:triage_requested무시 절 - cf88e39: W2 Review 병합 (6757f79, ea65363)
- 80dc408: W3 감사 로그와 S6 병합 (fee6ba8, 1200bd8)
- 34c247e: W4 S5 표시 병합 (6bdbd0c)
- cff18a4: PR #2 병합과 W6 배정 문서
- 824b89b: W5 M2 마무리 병합 (8949028, 1471a17, e8ab260)
- 52c38c8: W1 Triage 병합 (8d1f488, 044d1d0, 791a111)
- c88deb3: W1, W5 병합과 배포 대상 검토 반영
- a6a52c0: S2
start_run을 현재 engagement 의 GL 로 제한 - 9e7dbfd: TRUNCATE 거부 테스트를 동기 모듈로 분리
- 2718ff9: W6 S5 결론과 검토 동작 병합 (0212604, 649a0b1, aecaca4)
- 49346c1: W6 병합 반영 (WBS, TASKS, 색인)