브랜치 loop-20260916-1706. 계획은 PLAN.md, 정본 설계는 docs/superpowers/specs/2026-09-16-equip-recipe-rollout-design.md.
- 작업 1~9 전부 완료. 남은 것은 PLAN
## 병합 전 사람 몫:docker compose up으로 README 60초 시나리오 실측(루프는 docker 소켓 차단) · 화면 시각 판독 · 이 파일의 리스크 원장·씨앗 표 판독. - 이전 「현재 상태」의 동시 세션 문제(사람 확인 몫:
claude-guard-resume.sh가 러너 세션을 비격리 자동 재개한 의심)는 여전히 대화 세션 몫이다 — 아래 02:5x 절. 회고가 원인을 확정했다(2026-09-17 04:xx): 재개된 것은 러너 세션이 아니라 홈 디렉터리 cwd의 대화 세션(dbcc32e8, 16:30 시작)이다 — 러너 WAIT 구간에 이 트리에서 작업 7을 이어가다 01:30 75%로 정지(usage-guard.log PAUSE cwd=/home/loop-claude), 02:17 자동 재개 (claude-guard-resume.log RESUME). 러너 잠금 판정이 마커 cwd의basename-cksum이라 홈 cwd는 통과한다. 규칙 위반이 아니라 판정 기준의 구멍 — 제안은~/loop-runner/RETRO-equip-recipe-rollout-20260916-214758.md## 개선 제안.
발견(~/loop-runner/REVIEW-equip-recipe-rollout-20260916-214758.md): 워커가 ApplyFailure가 아닌
예외로 죽으면 task가 CLAIMED로 남고, 리스 만료 회수에 attempt 상한이 없어 무한 재클레임 — 설계 §5 5번
(상한 3회 초과면 FAILED) 위반이고 잡이 영원히 RUNNING이다.
재현 먼저(둘 다 붉은 것을 보고 고쳤다):
CrashedTaskCapTest.crashedTaskStopsBeingReclaimedAtMaxAttempts— 상한 3인데 6회 클레임e2e.CrashingWorkerE2ETest.unexpectedExceptionFailsTheJobInsteadOfLeavingItRunning—잡이 끝나지 않았다: [status=CLAIMED, attempt=2, last_error=null](3분 9초 만에 대기 상한 초과)
처치 3(두 층으로 나눈 이유는 서로 다른 실패를 덮기 때문이다)
TaskClaimer.claim회수 분기에and attempt < :max— 상한을 넘긴 만료 리스는 다시 집지 않는다.TaskOutcomeService.reapExhausted()— 그래서 아무도 안 집게 된 CLAIMED를FAILED로 닫고 잡 상태 갱신· 감사·알림까지 한다.ClaimLoop.iterateOnce가 매 반복에 부른다. JVM 킬처럼 catch가 돌 기회조차 없는 크래시가 이 층의 대상이다.ClaimLoop.execute에catch (e: Exception) → fail(..., SERVER_ERROR)— 어댑터·DB의 예상 밖 예외를 즉시 마감한다(last_error에 예외 이름이 남는다). 분류상 재시도 O라 상한이 그대로 걸린다.
결과: 두 재현 테스트 초록. E2E는 RED 3분 9초 → GREEN 7초로, 리스 만료(3회 × 120초)가 아니라
fail() 경로로 닫힌다는 증거다. 씨앗은 따로 심지 않았다 — 두 테스트가 수정 전 실제로 붉었던 것이 그 증거다.
남은 리뷰 발견: 주의 5건·참고 5건은 미처치(특히 주의 1 레시피 상태 전이 TOCTOU, 주의 2 하트비트 예외 시 영구 중단 → 이중 적용, 참고 3 죽은 브라우저 슬롯 재사용 = 치명 1의 지속 트리거). 사람 결정 대기.
치명 1에 이어 주의 5건을 코드로 대조했다 — 리뷰어가 과장한 것은 없었다(5/5 확인). 그중 넷을 고치고 둘(주의 3·주의 4 후반)은 원장 R22·R23으로 올렸다. 재현 테스트를 먼저 붉게 만든 뒤 고쳤다.
| 발견 | RED에서 본 것 | 처치 |
|---|---|---|
| 주의 2 하트비트 영구 중단 | tick 호출이 1회에서 멈춤(scheduleAtFixedRate가 예외 뒤 취소) |
스케줄 람다를 runCatching으로 감싸고 실패를 WARN |
| 주의 1 레시피 전이 TOCTOU | 동시 승인 2건이 [200, 200] — 4-eyes가 경합으로 열림 |
update·approve·retire의 UPDATE에 전이 조건(+created_by <> :actor)을 싣고 갱신 행 수로 409/403 판정 |
| 주의 5-① 취소가 죽은 워커의 task를 안 닫음 | 취소 뒤에도 status=CLAIMED |
cancel이 리스 만료된 CLAIMED를 PENDING과 같이 CANCELLED로 닫는다 |
| 주의 4 전반 폐기 레시피 재시도 | 폐기 뒤 retry가 200 |
retry에 생성과 같은 규칙 — 비ACTIVE면 409 RECIPE_NOT_ACTIVE |
주의 1이 제일 중요했다: 이 저장소의 주장이 「인가를 구현한 게 아니라 인가가 막히는지 검증했다」인데 그 불변식(4-eyes)이 동시 요청에서 깨졌다. 인가 매트릭스 전수 테스트로는 구조적으로 못 잡는 종류다 — 매트릭스는 한 요청의 역할만 보고, 이건 두 요청의 순서 문제다.
전체 스위트 85건 초록(치명 1 처치 후 81 + 이번 4).
사람이 데모를 돌려 본 뒤 로그·DB를 대조했다. 잡 4건 전부 SUCCEEDED(task 35 · 재시도로 복구된 것 4 · APPLIED 15 · NO_CHANGE 20 · 감사에 승인 DENIED 1건 = 4-eyes가 실사용에서 발화). api·web·console-mock 에러 0, worker WARN 4건은 의도한 목업 10% 실패. 그런데 둘이 나왔다.
| 발견 | 실사용 증거 | 처치 |
|---|---|---|
| 1. 재시도로 성공한 task에 직전 시도의 오류가 남아 화면 오류 열에 뜬다 | 4건(task 2·3·9·35, 전부 SUCCEEDED·attempt=2) | succeed의 UPDATE에 last_error = null |
| 2. 이미 끝난 잡의 취소가 200으로 통과해 감사에 OK로 남는다 | 2건(각각 마지막 task 종료보다 12초·9초 늦게 눌림) | cancel에 409 JOB_ALREADY_FINISHED(사용자 결정 ⓐ) + 계약 에러 코드 열거에 추가 |
발견 1은 심각도 판단이 틀렸던 것이다. 메타 리뷰의 주의 5-②를 「취소 표시가 남는 UI 혼동」으로 좁게 읽고 뒤로 미뤘는데, 실제로는 재시도 성공 전부에서 나고 데모 한 번에 4건이 나왔다. 리뷰 문서로 정한 심각도와 실제로 돌려 본 것의 차이다.
부수 교정: 주의 1 처치 때 쓴 forbidden("SELF_APPROVAL")이 계약에 없는 코드였다(계약은
SELF_APPROVAL_FORBIDDEN). 경합이 나야만 닿는 경로라 테스트에도 안 잡혔다 — 불변 조건 1이 막으라는
바로 그 실수를 사람 쪽이 저질렀다. 교정했다.
전체 스위트 87건 초록.
- 회고 리포트:
~/loop-runner/RETRO-equip-recipe-rollout-20260916-214758.md작성 완료(목표 달성·튜닝 예측 대조·추세·보상 해킹 점검·가드 판정· PLAN↔스킬 대조·스킬 승격 후보·개선 제안·다음 계획 줄). 리포트 안에 「미확인」 3건이 표시돼 있다(가드 차단으로 못 잰 것). - 이 워킹 트리의 미커밋 변경(사람이 커밋할 것):
.claude/GOTCHAS.md1줄 추가(리뷰 위임 헤더 문자열) · 이 PROGRESS 절. Bash 차단으로git commit불가. - 글로벌·스택 GOTCHAS 승격 후보(대화 세션 몫 — 회고 샌드박스는 그 파일들을 못 쓴다, 글로벌 GOTCHAS 33행). 붙여 넣을 한 줄씩:
- 글로벌(18KB 상한 근접 — 9행 pause 마커 항목에 덧붙이고 끝으로 회전; 이 회고가 스크립트 89~99행·두 로그로 실측):
홈 cwd 대화 세션이 러너 프로젝트 트리를 만지다 정지되면 러너 잠금 판정(마커 cwd의 basename-cksum)을 빠져나가 자동 재개돼 러너 반복과 같은 트리에 동시 커밋한다(2026-09-17 02:17 실측) → 러너가 도는 트리는 WAIT 중이라도 대화 세션에서 건드리지 말 것 - 글로벌 39행(worktree)에 덧붙임:
worktree 격리 에이전트의 Write는 메인 트리 경로를 거부한다(보고서는 worktree 안에 쓰고 마지막에 cp) · Agent가 반환한 worktreePath와 git worktree list가 불일치할 수 있다(잠긴 구버전 스냅샷 — 정리는 list 기준, remove -f -f) gotchas/jvm.md:- [env] Playwright Java \Playwright.create()`는 srt에서 `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` env 없이 5분 넘게 멈춘다(firefox/webkit 다운로드 시도) → `CreateOptions().setEnv`로 줄 것, 크로미엄은 호스트 캐시 리비전(1.62.x=1234)·- [test] 멀티모듈 `./gradlew test --tests X`는 테스트 없는 모듈이 있어도 exit 0이지만 그 모듈에 테스트가 생기면 매치 0으로 붉다 → 지목은 `:모듈:test --tests X`·- [env] `bootTestRun`에 모드·포트는 env가 아니라 `--args`로(gradle 데몬 재사용 — 호출마다의 env가 자식 JVM에 닿는 보장 없음, 2026-09-16 실측)·- [env] 이 상자에서 gradle 빌드 둘을 동시에 띄우면(리뷰어 worktree·dev-up·다른 세션) 공유 `~/.gradle` journal 잠금 대기·"N busy daemons" 기동 실패 → 장기 실행은 bootJar 후 java -jar, 씨앗·위임은 하나씩 순차`. 회전: 3번(Kotlin 데몬 폴백)은 작업 1 로그로 유효 확인 → 끝으로.gotchas/node.md:- [build] Next 16 Turbopack은 \next/font/local`의 CSS 변수를 `declarations` font-family가 아니라 JS 상수 이름으로 채운다 → 패밀리명에 공백이 있으면(`Rollout Mono`) @font-face와 어긋나 조용히 폴백. `next build --webpack`·`next dev --webpack`으로 고정하고 게이트로 지킬 것(2026-09-17 빌드 CSS 실측)`. 회전: 미실측(가드 차단).- 작업 9의 heredoc
rm -rf후보는 글로벌 12행과 중복 — 추가하지 않는다.
- 글로벌(18KB 상한 근접 — 9행 pause 마커 항목에 덧붙이고 끝으로 회전; 이 회고가 스크립트 89~99행·두 로그로 실측):
- 리포트가 미확인으로 남긴 것: ① 214759 REDCHECK 「구현 전 초록 1건」의 원인(중단 런 214522의 미커밋 잔여물 추정 —
red-precheck.log대조) ②scripts/design-gates.sh의 AI 기본 서체 4팔·보라 그라데이션 팔 생략 명시 ③ 러너 PROGRESS의 「WAIT 중 외부 커밋」 기각 이력.
방금 한 것(커밋 순):
- 발견 수정(
docs/review/findings-handled.md가 행별 정본): 감사 경로 디코딩·열 너비(AR1~AR3) · 워커 대행 감사(B3/F1) · 초안 수정자=작성자(B1/F2) · 취소 점착(B2) · 클레임 시도 단위 소유자(B6 + M3·M4 생존분) · 계약 제약 강제(B5·B8·B9·AR6) ·consoleUser응답·계약 제거(AR4) · smoke 기동 대기·종료 범위(AR7·AR8). 수정마다 재현 테스트를 먼저 쓰고 음성 팔(아래 N 행)을 붉게 확인했다. - 결정 대기 2ⓖ 누락 발견·수정: API가 MAINTENANCE 설비를 담은 잡을 받고 있었다(화면만 막았다) →
JobController400 +EquipmentApiTest.jobCannotTargetMaintenanceEquipment(N13). - 데모 시드
DemoSeed(계정 3 · 설비 12 RUNNING10/STOPPED1/MAINTENANCE1 · 품번 3 × v1 RETIRED/v2 ACTIVE/v3 DRAFT, 멱등) +SeedTest.demoSeedIsIdempotentAndAbsentWithoutFlag. - compose 데모:
compose.yaml5 서비스 + Dockerfile 3개(backend는 api·worker 공용, Playwright Java 1.62.0 베이스) +scripts/deploy-gates.sh(① ~ ④, G21~G28). - README(60초 시나리오 · 첫 실행 시간 추정 · 프로메테우스 예시 · 서체 고지 →
web/app/fonts/NOTICE.md· 설계 §11·§12 링크) · CLAUDE.md 최종. - 레저 B7·B12의 실서버 시나리오:
scripts/smoke.sh복사본에 탐침을 붙여 실기동(18080)으로 실행 — 결과는 findings-handled 표 그대로(틀린 로그인 30회 401 → 정답 200 · 타 세션 200 / 자기 강등 200 → 자기GET /api/users403 · 타 사용자 초안 201 · 승인 200). 탐침 파일은docs/probes/에 있다.
- B2 취소 표시를
last_error문구(CANCEL_REQUESTED: …)에 싣는다. 잡 단위 취소 열이 맞지만 불변 조건 1이 데이터 모델 추가를 두 열로 막았다.ponytail:주석으로 천장을 적었다(R6). - B1은 「자기 초안만 수정」이 아니라 「고친 사람이 작성자」로 고쳤다. 설계 §8 매트릭스(초안 수정 OPERATOR·APPROVER 모두 O)를 좁히지 않고 4-eyes의 뜻
(내용을 쓴 사람은 승인 못 함)을 지킨다.
Policy는 건드리지 않았다. - compose의
MOCK_FAIL_RATE는${MOCK_FAIL_RATE:-0.10}이다. 0.10은 총 3회 재시도에 거의 다 흡수돼(최종 실패 확률 0.001/대) README 「1~2대 실패 → 재시도」를 보이려면 실패율을 올려 띄울 수 있어야 한다. 기본값은 계획대로 0.10. - README 시나리오의 배포 대상은 11대다 —
EQ-12가 MAINTENANCE라 결정 2ⓖ로 넣을 수 없다. 설비 수정 화면은 없어(작업 8 범위) 상태 변경은 API로만 된다. Rollout Mono의 원래 이름을 README에 쓰지 않았다(결정 기입 1) — 원저작 표기는 NOTICE.md 링크로 대신한다.- 게이트 씨앗 번호는 작업 8의 G1
G20에 이어 G21G28이다.
전부 포그라운드 지목 실행(scripts/test.sh --tests X) exit 1 → git show HEAD:<파일> > <파일> 복원 → 추적 변경 0건 확인(스크립트 /tmp/seeds/run.py, 사본 docs/probes/).
| 씨앗 | 되돌린 것 | 붉어진 테스트 | 우회 클래스 |
|---|---|---|---|
| N1 | AuditFilter 경로 디코딩 → 원문 requestURI |
AuditInvariantTest.percentEncodedPathIsAuditedAsTheSameOperation |
인코딩 변형 경로(같은 핸들러, 다른 원문) |
| N2 | action.take(50) 제거 |
AuditInvariantTest.longUnauthenticatedPathIsStillAudited |
무인증 긴 경로(기록 insert 실패) |
| N3 | PUT 레시피의 created_by = :actor 제거 |
AuthorizationMatrixTest.editorOfOthersDraftCannotApproveIt |
형제 쓰기 경로로 4-eyes 기준 열 우회 |
| N4 | fail의 취소 표시 분기 제거 |
TaskOutcomeTest.claimedTaskOfCancelledJobIsNotRetried |
취소 시점에 실행 중이던 task의 재시도 경로 |
| N5 | ownedTask의 claimed_by = :worker 무력화 |
TaskOutcomeTest.staleOwnerCannotFinishOrExtendReclaimedTask |
리스를 뺏긴 소유자의 늦은 완료 |
| N5b | LeaseHeartbeat의 claimed_by = :worker 무력화 |
같은 테스트 | 뺏긴 리스 되살리기 |
| N6 | 성공 전이의 writeSystem 호출 제거 |
TaskOutcomeTest.workerOutcomesWriteSystemAuditRows |
API 밖 쓰기 경로(워커) |
| N7 | 시도별 소유자 → 프로세스 id 공유 | RolloutE2ETest.allTwelveEquipmentsAppliedWithTwelveHistoryRows(distinct claimed_by 12) |
같은 프로세스의 다른 스레드 |
| N8 | 사용자 생성 DTO init 검사 제거 |
EquipmentApiTest.contractConstraintsAreEnforcedAsInvalidRequest |
빈 값·길이 초과 입력 |
| N9 | 설비 URL 변경 시 비밀번호 필수 검사 제거 | 같은 테스트 | URL 재지정으로 숨긴 비밀번호 유출 |
| N10 | 레시피 params 키 문자셋 검사 제거 |
같은 테스트 | 키 → CSS 선택자 주입 |
| N11 | 설비 응답에 consoleUser 재추가 |
EquipmentApiTest.consoleCredentialsNeverAppearInResponses |
응답 DTO 열 추가 |
| N12 | 시드 설비의 on conflict do nothing 제거 |
SeedTest.demoSeedIsIdempotentAndAbsentWithoutFlag |
재기동 중복 |
| N13 | 잡 생성 MAINTENANCE 검사 무력화 | EquipmentApiTest.jobCannotTargetMaintenanceEquipment |
화면만 막고 API는 여는 경로 |
게이트 씨앗(scripts/deploy-gates.sh, 전부 exit 1 → 복원):
| G | 심은 것 | 발화한 검사 |
|---|---|---|
| G21 | api 포트 "0.0.0.0:8080:8080" |
② 127.0.0.1 게시 포트 줄 3 (실제 2) |
| G22 | web 포트 호스트 생략 "3000:3000" |
② 127.0.0.1 게시 포트 줄 3 (실제 2) |
| G23 | api SEED_PASSWORD: ${SEED_PASSWORD:-demo} |
④ compose에 SEED_PASSWORD 기본값 없음 |
| G24 | web/Dockerfile ENV … NEXT_PUBLIC_API_ORIGIN=… |
③ NEXT_PUBLIC_ 없음 |
| G25 | backend/Dockerfile ENV SPRING_DATASOURCE_PASSWORD=rollout |
③ 이미지에 비밀번호 ENV 없음 |
| G26 | web/app/login/page.tsx에 // 예: operator1 |
④ 시드 계정 이름 한 파일 (실제 2) |
| G27 | worker의 SERVER_ADDRESS: 0.0.0.0 삭제 |
② 컨테이너 바인드 2줄 |
| G28 | POSTGRES_PASSWORD 전부 :-rollout |
① POSTGRES_PASSWORD 없으면 실패 |
G21·G22에서 「포트 줄 총수 3」 검사는 발화하지 않는다(줄 수는 그대로 3) — 양의 앵커 검사가 잡는다. G23에서 ①은 발화하지 않는다(console-mock이 여전히 :?로 요구).
| 서술 | 반례 검색 | 결과 |
|---|---|---|
개인정보는 username·display_name만(연락처·이메일 컬럼 없음) |
rg -i 'email|phone|contact' backend/src/main web/app |
0건(rc=1) |
| CORS 없음 | rg 'CrossOrigin|CorsRegistry|\.cors' backend/src/main |
0건(rc=1) |
| ORM 없음 | rg 'jakarta.persistence' backend/src console-mock/src |
0건(rc=1) |
| 콘솔 자격증명 응답 없음 | rg '"consoleUser" to|"consolePassword" to' backend/src/main |
0건(rc=1) — consolePassword는 요청 DTO·워커 입력에만 |
SQL now() 없음(시각은 Clock 빈) |
rg -in 'now\(\)' backend/src/main |
3줄 — RolloutApplication.kt:28 주석(「SQL now()를 쓰지 않는다」)과 ClaimLoop.kt:89·90 shutdownNow() 뿐, SQL 문장 0건 |
- R1 (B7) ADMIN이 자기 역할·활성 여부를 바꿀 수 있다 — 마지막 ADMIN이면 관리 기능이 잠긴다(복구는 DB). 다른 사용자 요청은 무효화하지 않는다(실서버). 운영 전 재검토.
- R2 (B10) 오탐: 전부 CANCELLED인 잡은 FAILED로 집계된다 — 설계 §5 규칙 그대로(
JobServiceTest). 화면에서 취소와 실패가 잡 단위로 구분되지 않는 것은 UX 천장. - R3 (B11) 오탐: 같은
Idempotency-Key의 다른 본문은 기존 잡을 돌려준다 — 불변 조건 5 그대로(JobServiceTest). - R4 (B12) 로그인 스로틀·잠금 없음(결정 기입 10 범위 밖) — 무차별 대입에 열려 있다. 운영 전 재검토.
- R5 (AR9) 콘솔 자격증명(
equipment.console_password)은 DB 평문이다(결정 대기 7 — 설계가 Vault 제외). 컬럼 암호화(앱 키 env + pgcrypto)는 운영 전 재검토. 데모는SEED_PASSWORD하나가 계정 셋과 목업 콘솔 비밀번호를 겸한다. - R6 (B2 천장) 취소 표시가
rollout_task.last_error문구다 — 취소 뒤 성공한 task에도 그 문구가 남는다. 잡 단위 취소 열로 옮기려면 설계 §4 변경이 필요하다. - R7 (AR4 천장) ADMIN도 저장된 콘솔 계정명을 화면·API로 볼 수 없다 — 바꿀 때 새로 적는다.
- R8 (AR9) 비밀값 폐기 절차(코드 밖): 사람이 손 떼면
PUT /api/users/{id}enabled=false(다음 요청부터 401 —AuthApiTest) · 그가 알던 콘솔 자격증명은 ADMIN이 설비 수정으로 교체 ·POSTGRES_PASSWORD교체는 DB 역할 비밀번호 변경 후 compose 재기동 ·SEED_PASSWORD는 첫 시드 뒤 계정 비밀번호를 바꿔도 재시드가 덮지 않는다(on conflict do nothing). api 재기동 = 전 세션 폐기. - R9 (AR11) 샌드박스 프록시 비밀번호가 gradle JVM 명령줄(
ps)에 보인다 — 샌드박스 수명의 임시 값, 루프 밖 실행에는 없다. - R10 (AR12) 여러 세션이
~/.gradle을 동시에 쓰면 gradle 기동 오류·journal 잠금 대기가 난다 — 원인은 02:5x 절의 동시 실행. 러너에서는 1회 실패가 청크 ABORT다. - R11 (W1)
test.sh의 「전부 skip」 방어는 그 한 줄뿐이다 — 대체 방어 없음(스크립트 자체가 가드). - R12 (WS1) 「부팅 로그에 생성 비밀번호 없음」은 스모크 한 줄만 지킨다(테스트 스위트에 대응 단언 없음).
- R13 (관찰) web-smoke의 서체 변수 단언은 스크립트째 붉어지는 실행을 돌리지 않았다 — 같은 조건(
--webpack고정)은 design-gates G8이 지킨다. - R14 (관찰)
LOOP_UI=0천장: 클릭·입력·WS 렌더(로그인 → 승인 → 배포 → 실시간 진행)는 브라우저로 단언하지 않았다 — 사람 판독·README 시나리오 실측 몫. - R15 (관찰) 브라우저의
ws://localhost:8080이::1로 먼저 풀리면 127.0.0.1 게시 포트에 못 붙을 수 있고,http://127.0.0.1:3000으로 열면 Origin이APP_WEB_ORIGIN과 달라 핸드셰이크 403이다 — README는localhost로 안내한다. 실측은 사람 몫. - R16 (관찰) 잡 집계 전
for update잠금(잃어버린 갱신 방지)에 전용 재현 테스트가 없다 — 코드 주석으로만 근거가 남는다. - R17 (관찰)
ProgressListener는 재연결하지 않는다 — 커넥션이 끊기면 api 재기동 전까지 실시간 푸시가 죽는다(REST 조회는 정상). - R18 (계획) Playwright Java 이미지가 크다 — 첫 pull·빌드 시간은 추정치만 README에 있고 실측은 사람 몫(설계 §12).
- R19 (계획) 결정 대기 1
7 채택 해석: 1 = ⓐ(사람이 커밋한 수정본ⓙ 기본값(ⓖ는 작업 9에서 API에 뒤늦게 넣음) · 3 = 워커 HTTP는 actuator 둘만 · 4 = 세션 쿠키 + 무인증 경로 넷 · 5 = 메모리 세션·Rollout Mono) · 2 = 소규칙 ⓐ:?비밀값 · 6 = 게시 포트 127.0.0.1 · 7 =username·display_name만. - R20 (계획) 「나중에 생기는 형제 쓰기 경로는 아무 검사도 못 죽인다」 천장 — 감사 전수 테스트는 계약의 오퍼레이션만 돌고, 워커 대행처럼 계약 밖 쓰기 경로는 경로마다 테스트를 따로 둬야 한다(N6이 그 사례).
- R21 (계획) 모노 서체 소싱 경로: 원본 릴리스 zip을 사람이 받아 서브셋·woff2 변환·개명 후 커밋했다(결정 기입 1 ⓐ) — 루프는 릴리스 자산 도메인에 닿지 않는다. 원저작 표기
web/app/fonts/NOTICE.md. - R22 (메타 리뷰 주의 3) LISTEN 커넥션이 끊기면 재연결이 없다 —
ProgressListener.listen의 catch가while바깥이라 DB 재시작·유휴 끊김 뒤 스레드가 끝나고,running은 true인 채라 아무도 다시 띄우지 않는다. api는 멀쩡히 응답하고 헬스도 UP이라 실시간만 조용히 죽는다(데이터는 틀리지 않는다 — 새로고침하면 REST로 보인다). 실서버 시나리오: DB 페일오버 뒤 잡 상세 화면이 「연결됨」인데 진행이 안 차오른다. 지금 안 고치는 이유는 재연결 루프에 백오프 + 재연결 직후 열린 잡 브로드캐스트 보정이 딸려 오기 때문이다. 운영 전 재검토. - R23 (메타 리뷰 주의 4 후반·참고 3) 워커가 레시피 상태를 다시 보지 않는다(
ClaimLoop.load의 select에r.status가 없다) — 폐기 시점에 이미 PENDING·백오프 대기 중이던 task는 그대로 적용된다. 재시도 경로는 이번에 막았고(409RECIPE_NOT_ACTIVE) 생성 경로는 원래 막혀 있어 남은 창은 「생성 뒤 폐기까지의 대기 중 task」 하나다. 워커 쪽을 막으려면 분류표에 행이 늘고FailureClassifierTest의 8행 계약이 함께 바뀐다. 같은 덩어리로 **참고 3(죽은 브라우저 슬롯이idle큐로 되돌아가 재사용)**도 남는다 — 치명 1 수정으로 무한 루프는 닫혔고 지금은 상한 3회 소진 후 FAILED로 끝난다. 실서버 시나리오:/dev/shm부족으로 브라우저가 죽은 워커가 잡 하나를 통째로 FAILED로 만든다(조용히 도는 것이 아니라 보인다). 운영 전 재검토. - R24 (메타 리뷰 참고 1·2·4·5) 마감 리뷰의 「참고」 4건은 미처리다 — ①
Idempotency-Key가 전역 네임스페이스이고 본문 불일치를 검출하지 않는다(B11 오탐과 같은 축 — 불변 조건 5가 「같으면 기존 잡 반환」으로 정한 동작) ② 콘솔 폼에 없는params키는page.fill타임아웃 → TIMEOUT(재시도 O)으로 분류돼 3회 × 30초를 태운 뒤 FAILED(입력 검증은 B9로 닫혔으므로 남는 것은 낭비되는 시간뿐이다) ③POST·PUT /api/equipment응답의lastApplied가 항상 null이라 목록 응답과 다르다(계약상 nullable이라 스키마 위반은 아니고 값이 틀리다) ④ 없는equipmentIds로 잡을 만들면 FK 위반이 409CONSTRAINT_VIOLATION으로 나간다(계약이 말하는 404가 아니다 — 넷 중 이것만 계약 불일치다). 넷 다 동작을 망가뜨리지 않아 이번 범위 밖으로 뒀다. 다음에 손댄다면 ④부터 — 계약이 정본이라는 이 저장소의 불변 조건 1과 직접 어긋난다.
- PLAN 작업 9 검증 줄 전체를 한 셸에서 한 번 실행 → exit=0: deploy-gates 14 OK ·
SeedTest지목 통과 · findings-handled 등호 셋 · S1~S6 행 · 미검증 문구 0 · README 60초 ·scripts/smoke.sh통과 · design-gates 통과 ·scripts/test.sh전체(결과 XML 24클래스 79건, 실패·skip 0). scripts/dev-up.sh --check통과(시드 켜진 채 api 기동, api 로그 ERROR 0줄). compose는docker compose config까지만(정적) —up은 사람 몫.
destructive-block.sh훅은 Bash 명령 본문 전체를 정규식으로 보므로, heredoc으로 Dockerfile을 쓰면서 그 안에rm -rf /var/lib/apt/lists/*가 있으면 명령째 거부된다(파일 내용일 뿐인데도) → Dockerfile 같은 파일은 Write 도구로 쓴다.
다음: 없음(작업 9가 마지막). 막힌 것: 없음 — 병합 전 사람 몫만 남았다.
방금 한 것(LOOP_UI=0 런 — 캡처·E2E 없음, 기계 확인만):
web/: Next 16.3.0 · React 19.2.4 · TS 5.9.3(own-shop 핀) ·output: "standalone"· rewrite/api/:path*→${API_ORIGIN:-http://127.0.0.1:8080}·GET /ws-origin(런타임WS_ORIGIN, 기본ws://localhost:8080,force-dynamic).- 화면 7:
/login·/equipment·/recipes(목록 + OPERATOR·APPROVER 초안 폼, 수치 5필드) ·/recipes/[id](승인·폐기 버튼은 APPROVER만 렌더) ·/jobs/new(ACTIVE 레시피 select + 설비 다중 선택, MAINTENANCE 비활성,Idempotency-Key= 마운트 시crypto.randomUUID()) ·/jobs/[id](WS 구독 → 설비별 단계·시도·오류, 끊기면 재조회 후 재연결 1회, 취소·재시도 버튼) ·/history(타임라인) ·/audit(APPROVER·ADMIN만 요청). - 순수 함수
web/app/lib/progress.ts(progress()·statusLabel()·mergeSnapshot()·isJobSettled()) +node --test7건. - 서체: Pretendard 4종(pretendard@1.3.9 배포본 그대로, jsdelivr에서 받아 커밋) + 기존 RolloutMono 3종 ·
web/app/fonts/README.md(출처·OFL, NOTICE.md 링크). scripts/design-gates.sh(own-shop 이식, 26팔) ·scripts/web-smoke.sh·scripts/dev-up.sh(--check).TestRolloutApplication에--dev-db-url(임베디드 DB JDBC URL을build/dev-db.url에 씀 — dev-up의 워커가 같은 DB에 붙는다).
검증(이 세션 실측): PLAN 작업 8 검증 줄 전체를 한 셸에서 실행 → exit=0(design-gates 26 OK · npm test 7 pass · tsc · web-smoke 통과 · dev-up --check 넷 200).
회귀 확인으로 scripts/smoke.sh도 통과(TestRolloutApplication 수정 영향 없음). 백엔드 전체 scripts/test.sh는 이번에 돌리지 않았다(운영 코드 무변경).
전부 포그라운드 하나씩 → bash scripts/design-gates.sh 붉어짐(rc=1 + 해당 FAIL 줄) → git show HEAD:<파일>로 복원(새 파일은 삭제) → porcelain 추적 변경 0건 확인.
| G | 심은 것 | 죽인 게이트 |
|---|---|---|
| G1 | globals.css word-break: keep-all → normal |
keep-all |
| G2 | font-feature-settings: "tnum" 줄 삭제 |
tnum |
| G3 | 폴백 스택에서 "Malgun Gothic", 삭제 |
폴백 스택 문자열 리터럴 1줄 |
| G4 | RolloutMono-Bold.woff2를 0바이트로 |
서체 파일 RolloutMono-Bold.woff2 |
| G5 | layout의 Pretendard-SemiBold 경로 줄 삭제 | layout의 Pretendard 경로 4 |
| G6 | layout의 RolloutMono-Medium 경로 줄 삭제 | layout의 RolloutMono 경로 3 |
| G7 | value: "Rollout Mono" → "RolloutMono" |
패밀리명 Rollout Mono 선언 |
| G8 | package.json next build --webpack → next build |
빌드·개발 서버는 webpack |
| G9 | globals.css에 /* Monoplex KR */ 주석 |
원래 서체 이름(Monoplex) 없음 |
| G10 | mono 폴백에 Roboto |
AI 기본 서체 |
| G11 | url(https://example.com/a.png) 규칙 추가 |
CSS 외부 url() |
| G12 | layout에 fonts.googleapis.com 주석 |
외부 서체 CDN 없음 |
| G13~G15 | 새 파일 <img>에서 alt / width / loading 각각 빠짐 |
img alt · img width · img loading |
| G16 | target="_blank" noopener 없음 |
_blank noopener |
| G17 | <script src="https://…"> integrity 없음 |
외부 CSS/JS SRI |
| G18 | history 빈 목록 문구 「없습니다」 → 「없다」 | 한다체 없음 |
| G19 | next.config process.env.NEXT_PUBLIC_API_ORIGIN |
NEXT_PUBLIC_ 없음 |
| G20 | nomatch의 [ $rc -eq 1 ] → [ $rc -ne 0 ](도구 오류를 통과로 접는 퇴행) |
자기시험 |
web-smoke의 서체 변수 일치 단언(--font-mono:"Rollout Mono")은 씨앗을 따로 돌리지 않았다 — 구현 중 Turbopack 빌드가 실제로 그 불일치를
만들어 냈고(아래), 그 상태의 CSS에서 해당 문자열이 없음을 눈으로 확인한 것이 전부다(스크립트째 붉어지는 실행은 안 돌림 — 작업 9에서 원장 R13으로 닫음).
- 빌드·개발 서버를
--webpack으로 고정했다. Next 16 기본 Turbopack은next/font/local의 CSS 변수를declarations의 font-family가 아니라 JS 상수 이름으로 채운다 —declarations: [{prop:"font-family", value:"Rollout Mono"}]여도--font-mono:"monoFont"가 나와 @font-face(Rollout Mono)와 어긋나 서체가 조용히 폴백된다(빌드 CSS 실측). 상수 이름엔 공백을 못 넣으니 결정 기입 1의 패밀리명Rollout Mono를 지키려면 webpack이다. webpack 빌드는--font-mono:"Rollout Mono","Rollout Mono Fallback"(실측). 게이트(G8)와 web-smoke 단언이 이를 지킨다. - web-smoke는 화면당 h1 문구까지 본다(200만으로는 오류 셸도 통과).
/recipes/1·/jobs/1은 SSR 셸이라 DB에 없어도 200이다 — 본문은 클라이언트가 그린다. - web-smoke는
next start(npm run start 대신npx next start -p … -H 127.0.0.1)를setsid로 띄워 프로세스 그룹째 죽인다. api 종료는--server.port=$PORT가 붙은 JVM만 찾는다(smoke.sh의 머신 전체 kill — 리뷰 A8 — 을 새 스크립트에 옮기지 않았다). - dev-up의 워커·console-mock은 gradle이 아니라
bootJar후java -jar다 — gradle 빌드 둘을 동시에 오래 띄우면 잠금 경합이 난다(작업 7 기록의 journal 잠금 경합). 워커 포트는 8081(api 8080과 충돌 방지). 목업 콘솔 계정은 기동마다 난수로 만들어build/dev-up/console-credentials(0600)에 남긴다. - 데모 시드는 아직 없다 — dev-up은
--app.seed.demo=true를 넘기지만 구현은 작업 9 몫이다. 지금 dev-up으로 띄우면 빈 DB라 로그인할 계정이 없다. - 보안 헤더(CSP 등)는 넣지 않았다 — 작업 줄에 없고 불변 조건에도 없다. 작업 9 리뷰 발견 처리 때 필요하면 원장에.
- 클릭·입력·WS 렌더 전부(로그인 → 승인 → 배포 → 실시간 진행). curl은 SSR 셸·자산·서체 CSS·rewrite·
/ws-origin만 봤다. - WS 직결: 브라우저가
ws://localhost:8080에 붙을 때 api가127.0.0.1에만 바인드돼 있어localhost가::1로 먼저 풀리면 실패할 수 있다 — 브라우저 실측 필요. 또http://127.0.0.1:3000으로 열면 Origin이APP_WEB_ORIGIN(localhost:3000)과 달라 핸드셰이크 403이다 — dev-up 안내는localhost로 적었다. - API 응답 키 ↔ 화면 타입 대조는
openapi.yml스키마를web/app/lib/types.ts로 옮겨 적은 수준이다(실응답 JSON과의 자동 대조 없음). 단Equipment.consoleUser(리뷰 A4)는 화면 타입에서 뺐다 — 화면은 읽지도 그리지도 않는다.
- 팔레트(중립 회색 + 파랑 액센트
#1f6feb)·여백·배지 모양·표 밀도는 루프가 기능 우선으로 임의로 둔 값이다 — 사람 판독 후 확정 필요. - 설비 상태 RUNNING의 표기 「가동」과 잡 RUNNING 「진행 중」, task CLAIMED 「적용 중」 — 용어 확정 필요.
- 카피 리더 테스트(fresh-context sonnet 서브에이전트, 파일 읽기만)의 지적 중 반영: 잡 상세 표 머리 「단계」→「상태」 · 「실패·취소 설비 재시도」→「실패·취소된 설비 재시도」 · CONSTRAINT_VIOLATION 문구 · 홈 문장의 해요체. 미반영(대화 세션 몫): 잡 상세 제목 「배포 잡 #id」의 「잡」이 다른 화면(「배포」)과 어긋난다 · 레시피 ACTIVE 「승인됨」이 형제 라벨(「초안」·「폐기」)의 명사형과 다르다.
- 작업 1~7 완료(8반복: 7 성공 + iter 7은 사용량 72%로 정지). 남은 것은 작업 8·9 — 속도 문제가 아니라 작업 수 > 청크 크기의 정상 소진.
- 정정: 작업 8은 막혀 있지 않다. 아래 「체크포인트(사람 결정 필요)」·「작업 8은 결정 대기 1 확정 전까지 시작하지 않는다」는 오판이다 —
PLAN
## 결정 기입1에 답이 있고web/app/fonts/RolloutMono-{Regular,Medium,Bold}.woff2·NOTICE.md는 런 시작 전(9/16 17:42)부터 커밋돼 있다. 다음 반복은 작업 8을 바로 시작한다(작업 줄 힌트·PROMPT 조정 참조). - 동시 세션 문제(아래 02:5x 절): 지금은 이 프로젝트에 다른 claude 프로세스가 없다(
ps실측, 튜닝 세션뿐). 사람 확인 몫: 러너 반복이 사용량 정지된 뒤claude-guard-resume.sh가 그 세션을 비격리로 자동 재개한 것으로 보인다(글로벌 규칙 「러너 마커는 자동 재개하지 않는다」와 충돌) —~/loop-runner/쪽 점검 필요. 튜닝은 도구를 고치지 않는다. - 튜닝 예측: 다음 청크 첫 반복이 작업 8을 실제로 시작해
web/산출물을 커밋한다(막힘 기록으로 끝나지 않는다) / 반증: iter 1 로그가 다시 「결정 대기 1」을 이유로 작업 8을 건너뛰거나, 코드·CSS에Monoplex이름이 들어가 게이트가 붉어진다.
방금 한 것:
- 멀티모듈 스캐폴드: 루트
settings.gradle.kts(pluginManagement Central 우선 ·backend·console-mock) · 루트build.gradle.kts(Kotlin 2.4.20 / Boot 4.1.1 / dep-mgmt 1.1.7apply false+ subprojects 공통) · 래퍼 9.5.1(exp-gf-mes에서 복사 — 배포본 재다운로드 없음) ·backend/build.gradle.kts(계획 실측 의존성 전부 + zonky + playwright 1.62.0 +testImplementation(project(":console-mock"))) ·console-mock(webmvc +/health). - 스크립트:
scripts/gradlew-sbx.sh(이식) ·scripts/test.sh(인자 없음=루트test --rerun,--tests=:backend:test, 0건 실행 자기점검) ·scripts/smoke.sh(api 모드 실기동 →/api/health200 → 부팅 로그 감사 → 종료·포트 해제 확인). - 스키마
V1__schema.sql: 7테이블(app_user·equipment·recipe·rollout_job·rollout_task·application_history·audit_log) + 상태 CHECK 7종 + UNIQUE 5종 + FK +audit_logappend-only 트리거.rollout_task.next_attempt_at·app_user.display_name은 불변 조건 1이 허용한 유일한 추가. - 도메인 enum 6종 ·
mode프로퍼티 분기(ModeConfiguration— 값이 없거나api|worker가 아니면 기동 실패) · api의GET /api/health·시큐리티 골격 · worker의 빈ClaimLoop자리 ·Clock빈. - 테스트 하네스:
EmbeddedPostgresConfiguration(zonky 옵션 3종) ·MutableClock·TestRolloutApplication(bootTestRun 진입점). - 테스트 4건 전부 통과:
HealthApiTest.healthReturnsOk(MockMvc 200 + flyway 적용 + 7테이블 존재) ·SchemaConstraintTest.auditLogRejectsUpdateAndDelete(UPDATE·DELETE가 P0001로 죽고 행·값 불변) ·SchemaConstraintTest.statusChecksAndUniquesRejectBypassInserts(23514 두 팔 · 23505 한 팔) ·ApiModeTest.apiModeHasNoPlaywrightBean(Playwright·ClaimLoop 빈 0 + 양의 앵커로 api 빈 1). - PLAN 작업 1의 검증 명령 전체를 한 줄로 실행해 통과(테스트 지목 3회 +
CLAUDE.md비어 있지 않음 +.gitignore앵커 + 스모크 백투백 2회 + 전체 스위트).
다음: 작업 2(잡 수명주기 코어 + 클레임·리스·재시도·집계 실DB 테스트 + 씨앗 S1·S4).
- 스모크 앵커는
Started RolloutApplicationKt다. PLAN 작업 줄은Started TestRolloutApplicationKt를 적었지만, Boot가 찍는 기동 완료 줄은 primary source(RolloutApplication)의 이름으로 나온다(로그 실측). 프로세스 kill의 브래킷 트릭은 계획대로TestRolloutApplicationK[t](명령줄 main 클래스)를 쓴다 — 둘의 이름이 다른 것이 정상이다. bootTestRun의 모드·포트는 env가 아니라--args로 준다. gradle 데몬이 재사용되므로 호출마다의 env가 자식 JVM에 닿는다는 보장이 없다.--args="--mode=api --server.port=$PORT"로 넘겨 백투백 2회 통과.- Kotlin 데몬이 샌드박스에서 죽는다(
~/.local/share/kotlin/daemon읽기전용 →FileSystemException). fallback(데몬 없이 컴파일)으로 빌드는 정상 통과하므로 그 줄은 무시해도 된다. 전체 스위트 27초. - api 모드의 기본 사용자를 비우지 않으면 부팅 로그에 생성 비밀번호가 찍힌다 —
ApiConfiguration이 빈InMemoryUserDetailsManager를 둬서 막았고 스모크가 그 줄의 부재를 단언한다. 실제 사용자는 작업 5에서app_user기반으로 갈아끼운다. - worker 모드의 시큐리티는 아직 없다(작업 6 몫). 지금
--mode=worker로 띄우면 자동설정 기본 체인이 붙는다 — 작업 6이/actuator/health·/actuator/prometheus만 여는 체인을 넣을 때 함께 닫는다. → 작업 6이WorkerConfiguration.workerSecurityFilterChain으로 닫았다(작업 9 확인).
PLAN ## 결정 대기 11의 3건에 더해: 「Kotlin 데몬은 srt 샌드박스에서 ~/.local/share/kotlin/daemon 쓰기 실패로 죽지만
kotlin.daemon.useFallbackStrategy=true 기본값 덕에 빌드는 통과한다 — 로그의 그 예외는 오진 유도용 잡음이다」.
- 원인(재현 확인): 작업 1 검증 줄의 마지막
scripts/test.sh(전체 모드)가 러너 독립 재실행에서BUILD SUCCESSFUL뒤 메시지 없이 exit 1. 자기점검ran=$(find "${results[@]}" … 2>/dev/null | …)의results에 테스트가 없는console-mock의build/test-results/test(존재하지 않음)가 들어가find가 exit 1 →set -euo pipefail이라 명령 치환 실패로 스크립트가 죽고2>/dev/null이 사유를 삼켰다. 같은 경로에서find backend/...만 주면 exit 0·ran=4. 작업 1 반복은 「전체 스위트 통과」로 보고했지만 러너 재실행이 반증 — 자기 신고 불일치. - 개입: PLAN에 수정 작업 줄 1건을 작업 2 앞에 추가(검증
scripts/test.sh && …— 현재 붉음), PROMPT 「이 프로젝트의 조정」에 exit code 눈 확인·침묵 조합 금지 한 줄,.claude/GOTCHAS.md신설 1건. 작업 1의 검증 명령 자체는 손대지 않았다(명령은 옳고 스크립트가 틀렸다). - 튜닝 예측: 다음 청크 첫 반복이 test.sh 수정 작업을 집어 통과하고, 이후 작업 줄의
&& scripts/test.sh꼬리가 재실행에서 초록이 된다 / 반증: 다음 청크가 다시scripts/test.sh재실행 ABORT로 끝나거나 첫 반복이 이 작업이 아닌 작업 2를 집는다.
방금 한 것:
- 시작 시 미커밋 변경(
scripts/test.sh)이 이미 있었다 — 직전 반복이 이 작업 중 잘린 잔여물로 판단해 이어받아 검증·커밋했다. - 수정:
--rerun실행 뒤results배열을[ -d ]로 걸러 존재하는 결과 디렉터리만find에 넘긴다(테스트 없는console-mock의build/test-results/test부재가findexit 1 →set -euo pipefail+ 명령 치환 → 침묵 exit 1이었다). 걸러낸 뒤 하나도 없으면 「0건 실행」으로 명시적 실패시키고,ran계산의2>/dev/null은 제거했다(이제 숨길 오류가 없다). 0건 실행·전부 skip 판정은 그대로다 —|| true로 무력화하지 않았다. - 검증(이 세션 실측, 한 셸에서 한 줄씩
; echo exit=$?):- 이 작업 줄 전체
scripts/test.sh && scripts/test.sh --tests 'com.rollout.HealthApiTest.healthReturnsOk' && scripts/smoke.sh→exit=0. - 작업 1 검증 줄 전체(지목 3회 +
CLAUDE.md+.gitignore앵커 + 스모크 2회 + 전체 스위트) →exit=0. 전체 모드 실행에서:console-mock:test NO-SOURCE가 그대로 나오는데도 초록 — 고친 경로가 실제로 걸린 것이다. - 가드 생존 확인:
scripts/test.sh --tests 'com.rollout.NoSuchTest.nope'→ exit 1(붉음 유지).
- 이 작업 줄 전체
- 턴 종료 전
git status --porcelain추적 파일 변경 0건 확인(커밋으로 정리).
다음: 작업 2(잡 수명주기 코어 + 클레임·리스·재시도·집계 실DB 테스트 + 씨앗 S1·S4).
막힌 것: 없음.
방금 한 것:
- 운영 코드
backend/src/main/kotlin/com/rollout/job/:JobService(생성=on conflict (idempotency_key) do nothing한 트랜잭션 ·cancel(PENDING만) ·retry(FAILED·CANCELLED → PENDING attempt 0) ·refreshStatus집계) ·TaskClaimer(CTE 한 문장for update skip locked+CLAIMED·attempt+1·리스 부여, 만료 리스 회수 시 카운터) ·LeaseHeartbeat.tick(taskId, workerId)·TaskOutcomeService.succeed/fail·FailureClassifier(8행) ·Backoff·HistoryRepository.latestApplied·ProgressNotifier(커밋 후pg_notify) ·WorkerProperties·JobStatus.aggregate(도메인 enum 컴패니언). - 테스트 12건(실DB, 클록 전진으로 판정 — 시간 대기 없음):
ClaimContentionTest2 ·LeaseTest2 ·TaskOutcomeTest3 ·JobServiceTest3 ·FailureClassifierTest1 ·BackoffTest1. 지목 실행 전부 exit 0 (한 셸에서; echo exit=$?로 확인), 전체 스위트도 초록.
| 씨앗 | 심은 것 | 죽인 테스트 | 우회 클래스 |
|---|---|---|---|
| S1 | TaskClaimer의 클레임 CTE에서 skip locked 삭제 |
ClaimContentionTest.claimSkipsRowsLockedByAnotherWorker (TimeoutException — 남의 잠금에 막혀 돌아오지 못함) |
클레임 SQL은 레포에 이 문장 하나뿐(grep 'for update'는 여기와 JobService.lockJob의 잡 행 잠금 둘) — 진입점 전수 |
| S4 | LeaseHeartbeat.tick을 if (true) return true no-op으로 |
LeaseTest.heartbeatKeepsLeaseFromBeingReclaimed |
리스를 미래로 미는 문장은 클레임과 하트비트 둘뿐이고 나머지(TaskOutcomeService·JobService)는 전부 null로 지운다 — 연장 경로가 하나라 이 씨앗이 전수다 |
절차는 불변 조건 14대로: 작업분 커밋(207385d) → 결함 → 포그라운드로 붉음 확인 → git show HEAD:<파일> > <파일> 복원 →
초록 재확인 → git status --porcelain 추적 변경 0건.
succeed·fail에workerId를 더했다(계획 서명은succeed(taskId, paramsSnapshot, result)). 리스가 만료돼 B가 회수한 task를, 늦게 깨어난 A가 성공으로 마감하면application_history가 두 번 쌓인다 — 소유자 확인이 없으면 회수 자체가 이력을 오염시킨다. 자기 것이 아니면 false를 돌려주고 아무것도 쓰지 않는다. 같은 이유로tick(taskId, workerId).- 원자성 프로브는 FK 위반이 아니라 jsonb 캐스팅 실패로 했다. 이력의
equipment_id는 task 행에서 조인으로 가져오므로 (FK가 이미 보증) 「존재하지 않는 equipment_id」를 밖에서 주입할 자리가 없다. 대신params_snapshot에 jsonb가 아닌 값을 넘겨 task 상태 갱신 뒤 이력 INSERT만 깨뜨린다 — 트랜잭션이 없으면 상태가SUCCEEDED로 남아 붉어진다(순서 확인함). - 집계 전에 잡 행을
for update로 잠근다(계획에 없던 줄). 같은 잡의 마지막 두 task가 동시에 끝나면 read committed에서 서로의 미커밋 결과를 못 봐 둘 다RUNNING으로 덮어쓴다(잃어버린 갱신). 잠금 뒤 문장부터는 새 스냅샷을 본다. 전용 테스트는 없다 — 작업 9에서 원장 R16으로 닫음. - 코어 서비스는 모드 조건부 빈이 아니다. 불변 조건 2가 워커 전용으로 못박은 것은 클레임 루프·Playwright 어댑터이고
(
ApiModeTest가 그 둘의 부재를 단언), 이 클래스들은 기동 시 아무것도 하지 않는 도메인 층이다. api도JobService로 생성· 취소·재시도를 하므로 공용으로 뒀다. 그 대가로WorkerProperties가 api 모드에서도 필요하다(값은application.properties). - 클레임은 트리 전체에서 고르므로 테스트마다
@BeforeEach로 남은 PENDING·CLAIMED task를 CANCELLED로 닫는다 (Fixtures.closeOpenTasks). 같은 컨텍스트를 공유하는 클래스들이 서로의 잔여 task를 집는 것을 막는다. - 메트릭 이름은 마이크로미터 쪽에서
lease_expired_recovered다 — prometheus 노출에서_total이 붙어 불변 조건 11의 이름이 된다 (노출 대조는 작업 6).
다음: 작업 3(목업 콘솔 + Playwright 실행 어댑터 + 저장 후 재조회 대조 + E2E 1~4 + 씨앗 S2).
막힌 것: 없음.
방금 한 것:
console-mock(패키지com.equipconsole):/{station}/login·/{station}/recipe서버렌더 HTML + 폼 POST, 수치 5필드 범위 검사(위반 400 +.error문구), 세션 쿠키(HttpOnly), 결정적 주입MockControl(failNextSaves·expireSessionsAfterRequests·corruptNextSave·delayMs·peakInFlight·loginCount).backend:RecipeApplier인터페이스 +PlaywrightRecipeApplier(설비별BrowserContext재사용 · 재로그인 1회 · 저장 후 재조회 대조 →VERIFY_MISMATCH) ·ClaimLoop(클레임 → 세마포어 → 어댑터 →TaskOutcomeService, MDCjob_id·task_id, 하트비트 스케줄, MAINTENANCE·NO_CHANGE 분기) ·WorkerConfiguration(워커 HTTP는/actuator/health·/actuator/prometheus만, 나머지 404) ·WorkerProperties에browserConcurrency·claimParallelism.- E2E 5건(실제 크로미엄이 목업을 조작한다):
RolloutE2ETest4 +ApplyVerificationTest1. 전체 스위트 21건 초록 (한 셸에서; echo exit=$?로 확인).
| 씨앗 | 심은 것 | 죽인 테스트 | 우회 클래스 |
|---|---|---|---|
| S2 | PlaywrightRecipeApplier.applyOn에서 verify(page, target, recipe) 호출 삭제(재조회 대조 제거) |
ApplyVerificationTest.mismatchAfterSaveFailsTheTask(잡이 FAILED 대신 SUCCEEDED) |
콘솔이 저장했다고 말한 값을 다시 읽는 문장은 레포에 verify() 하나뿐이고 어댑터 구현도 하나다(RecipeApplier 구현 전수 = 1) — 이 씨앗이 전수다 |
절차는 불변 조건 14대로: 작업분 커밋(92b9d2c) → 결함 → 포그라운드로 붉음 확인(AssertionFailedError) →
git show HEAD:<파일> > <파일> 복원 → 초록 재확인 → git status --porcelain 추적 변경 0건.
- 목업 패키지를
com.rollout.console→com.equipconsole로 옮겼다. backend의@SpringBootApplication이com.rollout을 스캔하므로 같은 트리에 두면 목업 빈이 backend 컨텍스트로 빨려 들어간다(테스트 클래스패스에project(":console-mock")가 있다). 경계를 빌드 구조로 못박는다는 설계 §3의 이유가 패키지에도 그대로 걸린다. - 콘솔 경로에 설비 마디를 뒀다(
/login·/recipe→/{station}/login·/{station}/recipe). 목업 한 대가 설비 12대를 흉내 내야 하는데, 경로가 하나면 저장된 레시피·세션·로그인 횟수가 전 설비 공유가 된다 — 「그 설비의 loginCount == 2」(PLAN 시나리오 3)와 「설비마다 실제 저장값 대조」(시나리오 1)가 성립하지 않는다.equipment.console_url이 설비별로 다르다는 데이터 모델과도 이쪽이 맞는다. - 어댑터에 「슬롯」(Playwright + 브라우저 + 설비별 컨텍스트 한 벌)을 뒀다. Playwright 객체는 스레드 안전하지
않아 한 인스턴스를 여러 클레임 스레드가 동시에 부를 수 없다. 슬롯 하나를 한 스레드가 배타적으로 쥐고
반납한다(동시 슬롯 수의 상한은
ClaimLoop의Semaphore(browser-concurrency)). 대가는// ponytail:로 적었다 — 같은 설비라도 다른 슬롯에 걸리면 컨텍스트 캐시가 빗나가 로그인을 한 번 더 한다. - E2E는 가변 클록이 아니라 시스템 클록으로 돌고 백오프 base를 0으로 준다(
worker.backoff-base-seconds=0). 워커 스레드가 실시간으로 도는 경로라 클록을 테스트가 앞으로 밀 자리가 없다. 백오프 계산 자체는 작업 2의TaskOutcomeTest가 가변 클록으로 이미 못박았다 — E2E는 재시도가 「일어나는가」만 본다(attempt == 2). - 재시도 전에도 파라미터를 DB에서 다시 읽는다(
jsonb_each_text). 워커에 JSON 파서를 들이지 않으려는 선택이고, 폼 필드명 =params키라는 규약이 SQL 한 줄로 드러난다. - 워커 모드 404 경로는 테스트가 없다(작업 4
MetricsTest가 닫았다 — 아래 작업 4 절). 워커 컨텍스트는MOCK웹 환경이라 실포트가 없다. 작업 4가 워커/actuator/prometheus를 실제로 읽으므로 그때 「그 둘 말고는 404」를 함께 단언하는 것이 자연스럽다.
다음: 작업 4(동시성 2층 마감 E2E 5 + 메트릭 4종 + 구조화 로깅·MDC + 워커 헬스 + 씨앗 S5).
막힌 것: 없음.
방금 한 것:
- 운영 코드:
PlaywrightRecipeApplier에 동시 세션 게이지(browser_sessions_active)와 peak 추적 ·ClaimLoop에 시도별 타이머(rollout_task_duration_seconds)·결과 계수(rollout_task{result})·마지막 클레임 시각 ·ClaimLoopHealthIndicator(마지막 반복이worker.loop-stale-seconds보다 오래되면 DOWN, 클록 빈 기준) ·WorkerProperties.loopStaleSeconds(값은 이미application.properties에 있었는데 바인딩이 없었다) · actuator 노출을health,prometheus둘로 한정. - 테스트 4건(전부 실행·통과, 한 셸에서
; echo exit=$?확인):BrowserConcurrencyE2ETest.peakConcurrentSessionsEqualsLimit(상한 2 · 클레임 병렬 6 · 콘솔 지연 300ms · 설비 6대 → 전부 SUCCEEDED ∧ 워커 peak == 2 ∧ 콘솔 in-flight peak == 2) ·MetricsTest.fourRolloutMetricsAreExposedOnWorkerPrometheus·LoggingTest.taskLogLinesAreJsonWithJobAndTaskIdAndNoCredentials·WorkerHealthTest.claimLoopHealthIsDownWhenLoopIsStale. 전체 스위트 25건 초록.
| 씨앗 | 심은 것 | 죽인 테스트 | 우회 클래스 |
|---|---|---|---|
| S5 | ClaimLoop의 Semaphore(props.browserConcurrency)를 Semaphore(props.claimParallelism)로(상한을 풀 크기로 넓힘) |
BrowserConcurrencyE2ETest.peakConcurrentSessionsEqualsLimit(peak expected: <2> but was: <6> — 워커·콘솔 두 등호가 함께 깨진다) |
브라우저 세션을 여는 경로는 ClaimLoop.execute의 세마포어 구간 하나뿐이고 어댑터 구현도 하나다 — 상한을 정하는 자리가 이 한 줄이라 전수다 |
절차는 불변 조건 14대로: 작업분 커밋(3eeec66) → 결함 → 포그라운드로 붉음 확인 → git show HEAD:<파일> > <파일> 복원 →
초록 재확인 → git status --porcelain 추적 변경 0건.
- 하위 클래스의
@SpringBootTestproperties는 상위의 것을 누적하지 않고 대체한다.BrowserConcurrencyE2ETest가 동시성 값만 적었더니mode플레이스홀더가 풀리지 않아 컨텍스트가 깨졌다(PlaceholderResolutionException) — 부모의mode=worker·worker.backoff-base-seconds=0도 함께 적는다. - 헬스 지시자의 빈 이름은 기본값을 쓴다.
@Component("claimLoop")로 주면ClaimLoop빈과 이름이 충돌해 컨텍스트가 뜨지 않는다(ConflictingBeanDefinitionException). 기본 이름이면 Boot가 접미사를 떼어 헬스 id는claimLoop다. - 헬스 테스트는 루프 스레드를 먼저 세운다(
loop.stop()→ 클록 +61초 → DOWN →iterateOnce()→ UP). 루프가 돌고 있으면 100ms마다 스스로 시각을 갱신해 「오래됐다」를 만들 자리가 없다 — 시간을 기다리지 않는다는 규율(불변 조건 12)과 같은 이유다. - 메트릭 계수는 「한 시도」 단위다(
rollout_task_total{result=SUCCEEDED|FAILED}). 재시도로 돌아간 시도도 그 시도로서는 실패라 FAILED로 센다. 잡·task의 최종 상태는 DB가 정본이고 메트릭은 시도의 흐름을 본다. MetricsTest가 워커 모드의/api/health404를 함께 단언한다 — 작업 3에서 「워커 404 경로 테스트 없음」으로 남겨 둔 것을 같은 컨텍스트에서 닫았다(작업 3 PROGRESS의 그 줄은 이제 검증됨).MetricsTest는com.rollout패키지에 둔다 — PLAN 검증 줄이com.rollout.MetricsTest...를 지목한다 (com.rollout.e2e에 두면No tests found for given includes로 죽는다).E2ESupport는 import해서 상속한다.- Boot 4 테스트에서
/actuator/prometheus노출을 위해 따로 애노테이션(@AutoConfigureMetrics류)은 필요 없었다 —spring-boot-micrometer-metrics-test모듈이 클래스패스에 없어 내보내기를 끄는 커스터마이저 자체가 없다(실측).
작업 4 검증 줄 전체를 한 셸에서 돌려 REALEXIT=0(지목 3회 → grep '^| S5 |' → 전체 스위트 25건).
중간에 한 번 오판할 뻔했다: … | tail -8; echo exit=$?의 $?는 파이프 마지막 명령(tail)의 코드라
체인이 죽어도 exit=0으로 보인다 — 검증 줄은 파이프 앞에서 종료 코드를 받아야 한다(글로벌 GOTCHAS 승격 후보).
다음: 작업 5(인증·인가·감사 + REST API 전부 + 계약 + 인가 매트릭스 + 씨앗 S3·S6).
막힌 것: 없음.
방금 한 것:
- 계약 먼저:
backend/src/main/resources/openapi.yml(24 오퍼레이션 · 무인증은POST /api/auth/login·GET /api/health둘 + actuator 둘 = 넷 · 그 밖 전부에 401·403 선언 ·Error.codeenum 11종). 테스트는 이 파일에서 (경로, 메서드)를 읽어 돈다(Contract.operations) — 손으로 적은 목록이 아니다. - 운영: 세션 쿠키 인증(
CurrentUserFilter가 매 요청app_user재조회) · JSON 401 엔트리포인트 · 기본 거부 · 존재 은닉 로그인(더미 해시) · 72바이트 초과 400 · 로그인 시 세션 재발급 ·Policy(3역할 × 7행위 리터럴 + 자기 잡 조건 + 4-eyes) ·AuditWriter+ 시큐리티 체인 바깥의AuditFilter한 자리 · 단일 에러 형식 · 사용자·설비·레시피·잡·이력·감사 컨트롤러. - 테스트 36건 추가(전부 실행·통과):
AuthorizationMatrixTest24건(21칸 + 경계 2 + 계약↔매트릭스 등호) ·AuditInvariantTest5건 ·AuthApiTest6건(실포트Set-Cookie포함) ·EquipmentApiTest1건. 전체 스위트 61건 초록. - 스모크 종단: 로그인 3계정 → 설비·레시피 등록 → 승인(작성자 아닌 APPROVER) → 잡 201 → 같은 키 200 동일 id → 무인증 401 JSON → OPERATOR 감사 403 → 로그에 비밀번호 0건.
| 씨앗 | 심은 것 | 죽인 테스트 | 우회 클래스 |
|---|---|---|---|
| S3 | AuditFilter.doFilterInternal의 writer.write(...) 호출 블록 삭제(감사 기록 제거) |
AuditInvariantTest 5건 전부(everyWriteEndpointAuditsDeniedAndSuccessfulAttempts가 OK·DENIED 등호에서 먼저 깨진다) |
감사를 쓰는 자리는 이 필터 하나뿐이고 컨트롤러에는 감사 호출이 없다 — 쓰기 경로 전수가 이 한 줄에 걸린다. 다만 워커의 대행 기록은 아직 이 경로 밖이다(작업 9 원장 후보) |
| S6 | Policy.check의 RECIPE_APPROVE_RETIRE 4-eyes 비교 한 줄 삭제 |
AuthorizationMatrixTest.approverCannotApproveOwnRecipe(403 기대 → 200) |
승인 경로는 컨트롤러 하나이고 4-eyes 판정은 Policy 안 한 줄뿐이다. 역할 검사(그 위 줄)는 21칸 전수가 따로 지키므로 이 씨앗은 조건 칸만 죽인다 |
절차는 불변 조건 14대로: 작업분 커밋(1584203) → 결함 → 포그라운드로 붉음 확인 → git show HEAD:<파일> > <파일>
복원 → 초록 재확인 → git status --porcelain 추적 변경 0건.
- 콘솔 비밀번호가 응답에 없다 = 내 코드. 설비 응답은 열을 하나씩 적은 row mapper라
select *로 바꾸면EquipmentApiTest가 실제 비밀번호 문자열로 잡는다(양의 앵커: 같은 응답에 설비 코드가 있다). - 감사
detail에 자격증명이 없다 = 내 코드. 필터가 본문·질의문자열을 아예 읽지 않고 상태·경로만 남긴다. HttpOnly·SameSite=Lax= 프레임워크 기본값 + 내가 명시한 프로퍼티. 지금은 실포트Set-Cookie단언이 지키고, 값 자체의 핀은 작업 6ConfigPinTest몫이다.- 무인증 401 JSON = 내 코드(엔트리포인트). 기본값은 리다이렉트다.
- 감사 DENIED 등호의 모집단은 「거부가 가능한 쓰기 오퍼레이션」 11개다. 계약의 쓰기 13개 중 로그인(무인증)과
로그아웃(세 역할 모두 허용)은 403을 만들 자리가 없다 — 대신 OK 행 등호는 13개 전부에 걸고, 로그인 실패는
loginFailureIsAudited가 ERROR 행으로 따로 못박는다. 잡 취소·재시도는 역할로는 거부가 안 나므로 「남의 잡」 변형(foreign=true)으로 DENIED를 만든다 — 소유자 조건의 거부도 감사된다는 뜻이다. - WebSocket 경로를 계약에 미리 넣었다(구현은 작업 6). 지금도 무인증 401은 참이다 — 기본 거부가 라우팅보다
먼저 답하기 때문이고,
everyNonPublicEndpointRejectsUnauthenticatedWithJson401이 실제로 그 경로를 밟는다. additionalProperties: false는properties를 가진 객체에만. 맵형 스키마(params·detail)는 키를 미리 못 적으므로 값 타입으로 제한한다(additionalProperties: {type: string}) —false면 모든 키를 금지해 뜻이 뒤집힌다.- 로그아웃·
/api/auth/me는 매트릭스의 VIEW 칸에 매핑했다(인증만 하면 전 역할 허용). 계약의 인증 오퍼레이션 22개 전부가 21칸 중 하나에 대응한다는 등호는 그래서 성립한다. - 감사는 컨트롤러 어드바이스가 아니라 시큐리티 체인 바깥 필터다(order -110). 401·403을 결과로 보려면 체인을 감싸야 한다. 천장: 응답 커밋 뒤 별도 트랜잭션이라 업무 변경과 원자적이지 않다(감사 insert가 죽으면 클라이언트는 이미 성공을 받은 뒤다) — 작업 9 원장 후보로 남긴다.
- Mockito 대신 계수 데코레이터(
CountingPasswordEncoder). 샌드박스에서 인라인 목이 죽는 함정을 피하고, 진짜BCryptPasswordEncoder를 감싸므로 대조 비용도 그대로다(목이면 비용 단언이 무의미해진다). - 스모크 임시 계정의 비밀번호는 파일로 넘긴다(
build/smoke-seed-password, umask 077, 끝나면 삭제). 명령줄은ps에 보이고, env는 gradle 데몬 재사용으로 자식에 닿는 보장이 없다(이 프로젝트의 기존 함정). 시드 코드는 테스트 소스(TestRolloutApplication.kt)라 배포본에 없다. - 실측 메모: Boot 4에서도
@LocalServerPort는org.springframework.boot.test.web.server.LocalServerPort다. 하위 클래스의@Import는 상위 것과 합쳐지므로, 다른 컨텍스트를 원하는AuthApiTest는 셋을 다 적었다.SecurityProperties.DEFAULT_FILTER_ORDER상수는 Boot 4에 없어 필터 순서는 리터럴 -110으로 적었다.
작업 5 검증 줄 전체를 한 셸에서 돌려 REALEXIT=0(지목 4회 → grep '^| S[36] |' 2건 → 스모크 → 전체 스위트 61건).
파이프 없이 종료 코드를 직접 받았다(파이프 뒤 $?는 마지막 명령의 것이라 체인이 죽어도 0으로 보인다 — 작업 4 교훈).
씨앗은 포그라운드로 하나씩 심고 즉시 복원했으며, 턴 종료 시 git status --porcelain의 추적 파일 변경은 0건이다.
다음: 작업 6(실시간 진행 LISTEN/NOTIFY → WebSocket + 계약 인프라 마감 + 본문 상한 + 설정 핀 + 스모크 WS).
계약의 GET /api/ws/jobs/{id}는 이미 있고 지금은 무인증 401만 참이다 — 핸들러는 작업 6이 붙인다.
막힌 것: 없음.
방금 한 것:
- 실시간(
api/Realtime.kt):JobSnapshotRepository(문장 하나로 잡 상태 + task 목록) ·JobProgressHandler(잡별 구독 세션, 연결 직후 스냅샷 1회, 알림당 조회 1회를 구독자 전원에게) ·ProgressListener(전용 커넥션으로LISTEN rollout_progress→PGConnection.getNotifications(1000)루프) ·RealtimeConfiguration(/api/ws/jobs/*핸들러 + 허용 Origin 하나app.web-origin). 발행 쪽ProgressNotifier는 작업 2에서 이미 커밋 후pg_notify를 보내고 있었다 — 이번에 받는 쪽이 붙어 종단이 이어졌다. - 본문 상한(
api/BodyLimitFilter.kt, 64 KiB): 읽은 바이트 기준·파싱 전. 413 응답은ApiExceptionHandler가 요청 속성을 보고 만든다(파싱 실패 400의 옷을 입고 오는 경로까지 덮는다). 415·406도 400으로 접었다. - 테스트 9건 추가, 전체 스위트 70건 초록(작업 5 끝 61건 → +9):
RealtimeTest3건 —taskCommitReachesWebSocketSubscriberWithoutPolling(실포트 WS · 초기 스냅샷 CLAIMED →TaskOutcomeService.succeed호출 → 갱신 스냅샷 SUCCEEDED · 스냅샷 SQL 실행 수 == 푸시 수(2)) ·unauthenticatedHandshakeIsRejected(401) ·foreignOriginHandshakeIsRejected(403 + 허용 Origin 양의 앵커).ContractEndpointTest.contractEndpointSetMatchesActualMappings— 계약 (경로,메서드) == 실매핑 양방향, WS 핸드셰이크 매핑 포함(WebSocketHandlerMapping.urlMap에서 읽어/*를{}로 정규화), 양쪽 빈 집합check.ErrorHandlingTest3건 — chunked 5 MB → 413 ∧ 프로브 핸들러 호출 0(파싱 전 증거) · 멀티파트 1 MB → 400 ∧ 호출 0 · 없는 경로 404 / 미지 필드 400 ∧ 호출 0(+ 올바른 본문 200 앵커).ConfigPinTest2건 — 프로퍼티 8종 문자열 등호 핀 +MultipartResolver빈 부재.
- 스모크에 WS 핸드셰이크 팔 추가: 쿠키 있으면 101, 없으면 401.
- 폴링 부재의 계수는 SQL 표식으로 한다. 스냅샷 문장에
/* job-snapshot */주석을 박고, 테스트의 계수 데이터소스(진짜 커넥션을 감싼 동적 프록시)가 그 표식이 든prepareStatement만 센다 — 픽스처·업무 질의가 같은 데이터소스를 지나도 계수를 오염시키지 않는다. 표식이 사라지면 계수가 0이 돼 등호가 깨지므로 단언은 스스로를 지킨다. 천장: 다른 SQL로 폴링하면 이 계수는 못 본다(같은 스냅샷 경로의 반복만 잡는다). org.postgresql:postgresql을runtimeOnly→implementation으로 바꿨다(새 의존성 추가가 아니라 범위 변경).LISTEN수신이PGConnection을 직접 쓴다 — 컴파일 경로에 없으면 해석되지 않는다.- WS 핸드셰이크 인증은 새로 만들지 않았다. 업그레이드 요청도 시큐리티 체인을 지나므로 기본 거부가
그대로 401을 낸다(작업 5의 계약 기반 401 전수 테스트가 이미 그 경로를 밟고 있었다). Origin 거부는
setAllowedOrigins한 줄이 403으로 답한다 — 인터셉터를 따로 쓰지 않았다. - 415·406을 400으로 접었다. 멀티파트 요청은 파서가 없어 415로 오는데 불변 조건 4의 코드 표에 415가 없다 —
본문 규약을 지키지 않은 클라이언트 오류라
INVALID_REQUEST로 모았다(계약의 400과 같다). - 본문 상한 값은 프로퍼티가 아니라 상수 64 KiB다(불변 조건 17 — 설정 가능 값은 PLAN에 이름이 있는 것만이고
max-body-bytes는 없다). 그래서ConfigPinTest의 핀 목록에도 없고 동작만ErrorHandlingTest가 지킨다. - exp-gf-mes의 폼 본문 래퍼는 이식하지 않았다. 폼·멀티파트 경로가 프로퍼티로 닫혀 있고(
ConfigPinTest가 값을,ErrorHandlingTest가 동작을 지킨다) 폼을 받는 엔드포인트가 없다 —ponytail:주석으로 천장을 적어 뒀다.
ProgressListener는 재연결하지 않는다. 커넥션이 끊기면 ERROR 한 줄을 남기고 스레드가 끝나며, 그 뒤 api 재기동 전까지 실시간 푸시가 죽는다(REST 조회는 그대로다). 재연결 루프는 계획에 없어 넣지 않았다.- 테스트 로그 끝의
진행 알림 수신이 끊겼다ERROR 한 줄은 컨텍스트 종료와 임베디드 postgres 종료 훅의 경합이다(리스너destroy보다 DB가 먼저 죽으면running=true인 채 예외가 온다). 실서버 스모크에서는 나오지 않는다.
작업 6 검증 줄 전체를 한 셸에서 파이프 없이 실행해 REALEXIT=0
(지목 5회 → 스모크 → 전체 스위트). 전체 스위트 23클래스 70건 · 실패 0(XML 집계). 스모크는 WS 팔 포함 통과.
턴 종료 전 git status --short의 추적 파일 변경은 커밋으로 정리했다.
다음: 작업 7(독립 리뷰 2인 — 작성자 자기 채점 금지, worktree 격리 위임).
막힌 것: 없음.
방금 한 것: 리뷰어 A(diff 진입)·B(소스 트리 진입)를 worktree 격리로 동시에 위임했다(모델 다운그레이드 없음).
둘 다 프롬프트 첫 줄 git merge --ff-only baf9677로 HEAD 일치(baf9677…)를 확인했고, 각자 트리의
git status --porcelain 추적 변경 0건으로 끝났다. 두 위임 모두 세션 사용률 72% > 상한 70%에 걸려
PreToolUse 훅이 Bash를 거부하면서 씨앗 도중·직전에 멈췄다. 이 반복도 같은 이유로 Bash를 쓸 수 없어
보고서 회수·커밋을 못 했다(cp조차 차단 — 실측).
작업 7은 체크하지 않는다(검증 줄 전체 미실행).
Aworktree/home/loop-claude/equip-recipe-rollout/.claude/worktrees/agent-a2e51eb3cc4bc2c68→docs/review/review-A.md(그 안에## 재개 시 할 일절 있음)Bworktree/home/loop-claude/equip-recipe-rollout/.claude/worktrees/agent-a86932fbd3c62ab7e→docs/review/review-B.md(발견 B1B12 기록, 씨앗 계획 M1M6)- 메인 트리
docs/review/review-{A,B}.md에는 뼈대 1줄만 있다 — worktree 격리 에이전트의 Write가 메인 트리 경로를 거부한다(실측). 다음 위임에는 「보고서는 자기 worktree 안에 쓰고, 마지막에 Bashcp로 메인 트리에 복사」로 지시할 것. 재개 반복이cp로 회수한 뒤, 잘린 초안을 입력으로 재위임한다 (재개 반복이 직접 리뷰하지 않는다 — 작업 7 규칙). - worktree 2개가 남아 있다(
git worktree list가 3줄) — 재위임이 끝난 뒤git worktree remove로 지워야 검증 줄의test "$(git worktree list | wc -l)" -eq 1이 통과한다.
- S1
for update skip locked→for update:ClaimContentionTest.claimSkipsRowsLockedByAnotherWorkerTimeoutException으로 붉음 → 복원 후 초록. - S3
AuditFilter의writer.write블록 삭제:AuditInvariantTest5건 전부 붉음 → 복원 후 초록. - S6
Policy.check4-eyes 한 줄 삭제:AuthorizationMatrixTest.approverCannotApproveOwnRecipe403 기대 → 200으로 붉음 → 복원 후 초록. - S2·S4·S5 미실행, ⓑ 검증 명령 실효·ⓒ 보안 잔여분·ⓓ 문서 정정 미착수.
- A의 관찰: S3에서
auditLogHasNoUpdateOrDeletePath까지 붉어지는 것은 fail-closed 연쇄(감사 0건이면maxAuditId()=0이라 트리거를 못 깨우고fail()) — 결함은 아니다.
씨앗을 하나도 못 심었으므로 아래는 실행으로 확인되지 않은 지적이다. 재위임에서 M 씨앗으로 실증해야 한다.
- B1(높음) 4-eyes 우회 가설:
PUT /api/recipes/{id}가created_by를 두고 내용을 덮어써 APPROVER B가 남의 초안을 자기 값으로 갈아엎고 스스로 승인할 수 있다(RecipeController.kt:64·Policy.kt:33). - B2(높음) 취소가 끈적이지 않는다 가설: 취소가 잡에 표시를 남기지 않아, 실행 중이던 task가 재시도 가능
실패로 끝나면
PENDING으로 되돌아가 취소 이후에 적용될 수 있다(JobService.kt:66·TaskOutcomeService.kt:253). - B3(중) 워커 대행 감사 행이 트리에 0건(
AuditWriter는 api 모드 전용) — 불변 조건 9의actor_name='system'·detail.on_behalf_of축이 코드에 없다는 주장. - B4(중)
ProgressListener무재연결(작업 6이 이미 남긴 관찰과 같다) · B5(중) 계약의minLength/maxLength/minimum이 강제되지 않는다(bean validation 부재 — 400 대신 409 또는 통과) · B6(중) 리스 회수 시 같은 워커 id의 늦은 시도를 소유 판정이 못 거른다 → 이중 이력 가능. - B7
B9(낮) ADMIN 자기 승격 ·B12(정보) 전부 취소된 잡이 FAILED로 집계 · Idempotency-Key 본문 불일치 미검사 · 로그인 시도 제한 없음.consoleUrl형식 검사 없음 · 레시피 params 키가 CSS 선택자로 직결. B10 - B가 정상으로 확인했다고 적은 것: attempt 3회·백오프
base×2^(a−1)·60초 상한 · 무인증 예외 정확히 넷 · 세마포어 3 < 클레임 풀 6 · 비밀값 부재 · 메트릭 4종 실노출.
- Bash가 열리면
cp로 두 보고서를 메인 트리docs/review/에 회수하고 커밋(초안 보존). - 초안을 입력으로 A·B를 재위임한다(A: S2·S4·S5 + ⓑⓒⓓ 마감 / B: M 씨앗 3개 이상으로 B1·B2 실증). 동시 위임을 피하고 하나씩 돌린다 — 이번에 둘을 함께 띄워 예산을 같이 태웠다.
- 두 보고서 완성 후
git worktree remove2회 → 작업 7 검증 줄 전체를 한 셸에서 실행(; echo exit=$?).
막힌 것: 세션 사용량 상한(72% > 70%) — 훅이 Bash·Agent를 거부한다. 한도 리셋 후 자동 재개로 이어간다.
방금 한 것:
- 순서대로 진행: worktree에서 두 초안
cp회수·커밋(이미 앞 절에서 기록됨) → 고아 worktree 2개(agent-a2e...·agent-a869...) 정리 → 리뷰어 A·B를 순차로(동시 아님) 재위임. - 리뷰어 A: S2·S4·S5 전부 심어 붉음 확인 후 복원(S1·S3·S6은 이전 반복 기록 재사용, 재실행 안 함) — 씨앗
6/6 완료. ⓑ 검증 명령 실효성(존재하지 않는 테스트 지목·포트 선점 스모크) ⓒ 불변 조건 3·4·9·16 대조
ⓓ 문서 정정 제안까지 마감. F1 확정(워커 대행 감사 행이
audit_log에 전혀 안 남음 — PROGRESS의 B3와 같은 지점, 코드로 독립 재확인). - 리뷰어 B: 이전 반복이 세운 가설 B1
B12(전부 코드 읽기만)를 씨앗 M2M6으로 실행 검증. B1(4-eyes 우회) M5로 실증(APPROVER가 남의 초안을 PUT으로 덮어쓴 뒤 스스로 승인 → 200), B2(취소가 안 끈적임) M6으로 실증(CLAIMED task가 취소 후에도 재시도 실패 경유로 PENDING → 재클레임됨). M3·M4로 B6(워커 프로세스당 workerId 하나를 병렬 스레드가 공유 — 자기 리스 재클레임으로 이중 실행 가능)을 중→높음에 가깝게 재평가. M2는 생존(순수 커버리지 공백, 결함 아님). - 두 위임 모두 재작업(cp)이 정확히 그 자리에서 실패한 이력이 있다 — worktree 잠금·명명 불일치를 실측:
Agent 도구가 반환한
worktreePath(예:agent-a5fceaef...)와 실제git worktree list에 남은 항목 (agent-a42b7389...)이 일치하지 않았다(A의 경우). 후자는 A 위임 초반의 중간 스냅샷(S4까지만 반영된 구버전)이었고pid 2로 잠겨 있어 첫git worktree remove는 실패,-f -f로 강제 해제 후 제거했다(내용은 최종본에 전부 흡수됐음을 diff로 먼저 확인).git worktree list에도 안 잡히는 완전 고아 디렉터리 (.git없는 잔여물) 하나도 발견 —_trash/로 이동(삭제 대신, 글로벌 CLAUDE.md §1). 글로벌 GOTCHAS 후보: isolation:"worktree" Agent 호출이 내부적으로 worktree를 한 번 더 만들거나 이름을 바꿔치기하는 경우가 있다 — 정리 시 도구가 보고한 경로만 믿지 말고git worktree list로 실제 목록을 대조할 것. - 검증 줄 형식 결함 발견·직접 보정: PLAN 원문의 정확한 검증 줄(
grep -q '^## 리뷰어 입력' docs/review/review-B.md,grep -q '진위 씨앗' docs/review/review-A.md등)을 처음 돌렸을 때 review-B.md의## 리뷰어 입력절이 이번 재위임 최종본에서 통째로 빠졌고, review-A.md의 ⓑ 절은## 검증 명령 실효/낱말 삭제 씨앗/진위 씨앗이라는 PLAN 원문 표현을 쓰지 않고 임의로 재구성(## ⓑ 검증 명령 실효성)해 검증 줄이 붉었다 — 두 위임 모두 실질 분석은 맞았지만 산출물 형식이 PLAN 명세와 어긋났다. 재개 반복이 직접 리뷰 판단을 새로 하지 않는 선에서 구조만 보정: review-B.md에## 리뷰어 입력(받은 것/읽지 않은 것) 복원, review-A.md의 ⓑ 절을 정확한 헤더로 재정리했다.- 보정 과정에서 "낱말 삭제 씨앗"(검증 스크립트 자체에 결함을 심는 것)을 이번 세션이 직접 한 번 시도했다
(
scripts/test.sh의 존재 디렉터리 필터 2535행 삭제 → 2026-09-16에 이미 고쳤던 침묵 실패 재현 시도). 두 번 다 이 머신의 공유W4 실행 기록 — 리뷰어 자기 보고) 2026-09-16 튜닝 반복의 기존 수정·가드 생존 기록(~/.gradle캐시 journal 잠금 경합("3 busy daemons", 다른 세션들과 gradle 홈을 공유)으로 gradle 시작 자체가 실패해 내 뮤테이션 효과를 판별할 수 없었다 — 즉시 원본으로 복원 (cp백업 대조로 diff 없음 확인), 보고서에는 미완으로 정직하게 남기고(작업 9: review-A-rerun W1.claude/GOTCHAS.md)을 인용했다.
- 보정 과정에서 "낱말 삭제 씨앗"(검증 스크립트 자체에 결함을 심는 것)을 이번 세션이 직접 한 번 시도했다
(
- 검증 줄 8개 조건 전부 통과: S1~S6 6행 · 두 보고서
## 리뷰어 입력· M 3개 이상(5개) ·진위 씨앗문자열 ·git worktree list1줄 ·git diff --quiet·scripts/test.sh전체 스위트(BUILD SUCCESSFUL, 51초, console-mock NO-SOURCE는 정상). 작업 7 완료, PLAN.md 체크. - 잡동사니: 세션 중 샌드박스가 리포 루트에 0바이트 읽기전용 더미 파일(
.bashrc·.gitconfig·.idea등)을 여러 번 흘렸다 — 추적 대상 아니고(??) 실제 리뷰·빌드에 영향 없음, 건드리지 않고 무시했다.
- F1/B3(중, 확정): 워커가 설비에 실제로 쓴 행위가
audit_log에 전혀 안 남는다(AuditWriter는mode=api전용).application_history만 남음. - B1(높음, 실증됨):
PUT /api/recipes/{id}가 소유자 검사 없이 DRAFT 권한만 봐서, 남이 초안을 덮어쓴 뒤 원작성자가 아닌 그 사람이 승인해도 4-eyes(created_by기준)가 못 막는다. - B2(높음, 실증됨):
cancel이PENDINGtask만 막고 잡 자체엔 취소 표시가 없어,CLAIMEDtask가 취소 이후에도 재시도 경유로 다시 클레임·적용될 수 있다. - B6(중→높음 재평가, 씨앗으로 지지됨):
ClaimLoop이 프로세스당 workerId 하나를claimParallelism(기본 6) 스레드가 공유해, 소유권 검사(claimed_by = worker)가 같은 프로세스 안의 서로 다른 시도를 못 가른다 — 이중 완료·이력 오염 가능. - B5(계약 필드 제약 미강제)·B7(ADMIN 자기 승격)·B8(consoleUrl 형식 검사 없음)·B9(params 키 검사 없음) 등 낮음/정보성 다수.
전체 표는
docs/review/review-A.md·docs/review/review-B.md.
다음: 작업 8(운영 화면 7개, Next.js) — 이 프로젝트는 서체(Pretendard + MonoplexKR)·타이포그래피 규칙이
설계 문서에 이미 확정값으로 박혀 있어 fonts-data 조회 대상이 아니다(PLAN.md 172행 "서체는 설계 확정값이라
fonts-data 조회는 하지 않고 값 그대로 쓴다"). 대신 PLAN.md 181행이 명시하는 진짜 걸림돌은 결정 대기 1
(MonoplexKR 폰트 파일 확보): 저장소(y-kim/monoplex)의 릴리스 zip이 release-assets.githubusercontent.com
으로 302되는데 그 호스트가 이 샌드박스에서 차단(000)돼 루프가 직접 받을 수 없다(PLAN.md 34행, 실측).
라이선스는 OFL 1.1(Reserved Font Name "Monoplex KR"), TTF→woff2 변환 자체는 pyftsubset --flavor=woff2로
가능(도구 있음, brotli 포함) — 막힌 건 순전히 파일을 손에 넣는 방법이다. "결정 대기 1은 작업 8을
막는다 — 런 시작 전에 답해야 한다"고 PLAN.md 자신이 못박고 있다.
체크포인트(사람 결정 필요): 다음 중 하나를 대화 세션에서 정해야 작업 8을 시작할 수 있다 —
① 사람이 MonoplexKR-v0.0.2.zip을 받아 web/app/fonts/에 직접 커밋 ② 샌드박스 allowlist에
release-assets.githubusercontent.com(또는 최종 리다이렉트 호스트)을 추가 ③ MonoplexKR을 포기하고 다른
고정폭 서체로 설계를 바꾼다(설계 문서 §10 수정 필요 — 불변 조건 1이 이 절만은 고칠 수 있다고 열어둔 항목인지
확인 필요). 이 셋 중 하나가 정해지기 전에는 다음 반복이 작업 8에 손대지 말 것.
막힌 것: 없음(작업 7 완료). 작업 8은 결정 대기 1(MonoplexKR 확보 방법) 확정 전까지 시작하지 않는다.
정정(튜닝 2026-09-17): 위 체크포인트는 오판 — 결정 대기 1은 PLAN
## 결정 기입에서 ⓐ로 확정됐고RolloutMono-*.woff23종이 이미 커밋돼 있다. 작업 8은 즉시 시작한다.
이상 상태(사람 확인 필요): 이 반복(02:2x 시작)과 다른 세션이 같은 트리에서 동시에 작업 7을 수행했다.
이 반복이 리뷰어 A를 worktree로 재위임한 사이 그 세션이 547113c·12cc2f6·8c95ac8·d620378을 커밋했고,
이 반복의 위임 worktree를 02:47에 외부에서 지웠다. 자동 재개(cron)와 러너 반복이 겹친 것으로 의심 — 아래 02:5x 절이 메타러너 동시 실행으로 원인을 확인했다.
러너 마커 자동 재개 금지 규칙과 어긋나는지 대화 세션이 확인할 것.
보강 보고서 docs/review/review-A-rerun.md: 이 반복의 리뷰어 A 재위임 산출물(뮤테이션 21, 씨앗 S1~S6 붉음·복원 —
리뷰어 자기 보고, 이 반복은 재실행하지 않음). 커밋된 review-A.md에 없는 발견이 있어 작업 9 발견 처리의 입력으로 남긴다:
- A1(높음): 레시피 id를 퍼센트 인코딩한 경로(
/api/recipes/%30…/approve)로 승인하면 상태는 ACTIVE가 되는데 감사 행이 안 남는다(디코딩 안 된 경로가action50자 열을 넘어 감사 쓰기가 실패, 응답은 401). 리뷰어가UriUtils.decode+50자 절단 수정을 심어 확인. 인코딩 경로 회귀 테스트 필요. A2·A3도 같은 원인. - A4(중):
GET /api/equipment가consoleUser를 전 역할에 응답 — 불변 조건 16 「콘솔 자격증명 응답 금지」와 충돌, 해석 결정 필요. - A7·A8(낮): smoke 기동 실패 시 181초 대기 · 머신 전체
TestRolloutApplicationKt종료. - 검증 명령 낱말 삭제 씨앗 중 생존 2건: test.sh 「실행 0건」 조건 삭제 · smoke.sh 생성 비밀번호 로그 검사 삭제.
위 "이상 상태" 기록에 이어 근본 원인을 특정했다: ps로 bash ~/tools/meta-runner.sh run /home/loop-claude/equip-recipe-rollout(PID 3209800)와 그 자식 loop-runner.sh --max-iterations 8
(PID 3209963)이 9월 16일 21:47:57부터 지금까지 5시간 넘게 계속 살아서 이 프로젝트에 독자적인
claude -p 반복을 계속 띄우고 있었다 — 이 대화 세션(사용량 한도 리셋 후 자동 재개로 시작)과는
완전히 별개의 프로세스다. 즉 같은 워킹 트리·같은 브랜치(loop-20260916-1706)를 메타러너 루프와
이 대화 세션 둘이 동시에 건드리고 있었다 — gradle journal 캐시 잠금 경합("3 busy daemons")도
다른 무관한 세션이 아니라 바로 이 루프의 동시 실행이 원인이었을 가능성이 크다.
이 반복이 git worktree remove --force --force .claude/worktrees/agent-a42b738997e027af8로 지운
디렉터리가 실은 메타러너 쪽 리뷰어 A worktree였다(그쪽 PROGRESS 기록과 시각이 일치). 다만 데이터 손실은
없었다 — 그쪽 세션이 결과를 이미 docs/review/review-A-rerun.md로 따로 커밋(92bb30f)했고, 이 반복이
_trash로 옮겼던 사본과 diff 대조해 완전히 동일함을 확인했다.
사람 확인이 필요한 것: 글로벌 CLAUDE.md는 "러너 프로젝트 마커는 자동 재개하지 않는다(비격리 재개 =
격리 우회)"라고 명시한다 — 그런데 이 프로젝트는 러너 마커가 있는 프로젝트인데도 대화 세션(비격리)이
자동 재개된 것으로 보인다. ~/tools/claude-guard-resume.sh가 이 규칙을 지키고 있는지, 혹은 이번이
그 규칙의 예외/우회 경로인지는 이 프로젝트 범위 밖(도구 설정)이라 이 반복이 판단하지 않는다 — 대화
세션에서 사람에게 보고하고 ~/loop-runner/ 쪽 확인을 요청한다.
이 반복은 여기서 더 이상 트리를 건드리지 않는다(§7 체크포인트 — 예상과 다른 상태 발견). 메타러너 루프가 여전히 살아서 도는 중이므로, 사람이 상황을 정리하기 전까지 같은 작업 디렉터리에 동시 쓰기를 더 보태지 않는 편이 안전하다.