개발 기록 2026-10-20 docs/devlog/2026-10-20-1-라운드-4-계약과-통합.md GitHub에서 보기

라운드 4 계약과 통합

목표

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, 색인)