fix(B05,B03): 초기 설계 실패 시 초기값 부재 처리 — 실패 마커·안내 메일·[초기화] 거부

증상: 파일 입력 직후 초기 계산값과 B05 [초기화] 결과가 다름.

원인
- 자동설계 체인이 5단계까지 전부 성공해야만 `save_initial_snapshot()` 호출.
  중간에 깨지면 `initial_snapshot/` 미생성 → [초기화]가 복원 대신 재계산 폴백.
- 재계산 폴백의 지표면 기준이 체인과 다름 — 체인은 stage 1 확정값
  (`get_surface_confirmation_params`), [초기화]는 config 기본값
  (`surface_confirmation_defaults`). 실측: 프로젝트 stage1 `classification`/5m 대
  config `csf`/1m.
- 체인이 깨져도 WF1 은 초기 분석 완료 메일을 그대로 발송.

수정 (2026-09-02 사용자 확정 — 부분 결과는 분석 안 됨과 다르지 않으므로 부분
스냅샷은 만들지 않음)
- `initial_design.failed` 마커 신설 — 프로젝트 루트(스냅샷 4트리 밖), 실패 사유 기록.
  체인 진입 시 옛 마커 제거, 실패 5지점 + 스냅샷 저장 실패에서 기록.
- WF1 이 마커를 읽어 완료 메일 대신 `send_initial_design_failed_email()` 발송
  (관리자 주소 `ADMIN_EMAIL`, 없으면 주소 없이 안내).
- B05 [초기화]: 스냅샷 있으면 복원, 실패 마커 있으면 409 `initial_design_failed`
  로 거부(DELETE 앞에서 반환 — 데이터 무변경), 옛 프로젝트만 종전 재계산 폴백.
- 재계산 폴백의 지표면 기준을 `get_surface_confirmation_params()` 로 통일.

자체검증: `pytest tmp/tests/ -q` 366 passed / 14 skipped / 0 failed (+4).
공용 브라우저 실측 — 실패 프로젝트 [초기화] 409 응답·`routes` 행 무변경 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:03:18 +09:00
co-authored by Claude Opus 5
parent 1a9947426a
commit 2e57d29156
6 changed files with 176 additions and 18 deletions
@@ -21,6 +21,8 @@ SNAPSHOT_DIRNAME = "initial_snapshot"
_DB_DUMP_NAME = "db.json"
# 초기 설계 체인이 도는 동안만 존재하는 마커(진입 차단 판정용).
DESIGNING_LOCK_NAME = "initial_design.lock"
# 초기 설계 체인이 실패로 끝났음을 남기는 마커.
DESIGN_FAILED_NAME = "initial_design.failed"
# 프로젝트 루트 기준 상대 경로 — 정본 파일이 사는 자리 전부.
_FILE_TREES = (
@@ -69,6 +71,45 @@ def clear_designing(project_root: Path) -> None:
pass
def design_failed_path(project_root: Path) -> Path:
"""초기 설계 체인이 실패로 끝났음을 남기는 마커.
"스냅샷이 없다"는 사실만으로는 **실패한 프로젝트**와 스냅샷 기능 이전의 **옛
프로젝트**를 가를 수 없다. 앞은 [초기화]가 재계산으로 얼버무리면 안 되고(부분 결과는
분석 안 됨과 다르지 않다, 2026-09-02 사용자 확정), 뒤는 종전 재계산 폴백이 유일한
수단이다. 락과 같은 자리 — 스냅샷 대상 4트리 **밖**이라 복원에 딸려 들어가지 않는다.
"""
return Path(project_root) / DESIGN_FAILED_NAME
def is_design_failed(project_root: Path) -> bool:
return design_failed_path(project_root).is_file()
def read_design_failure(project_root: Path) -> str:
"""실패 사유를 읽는다. 마커가 없거나 못 읽으면 빈 문자열."""
try:
return design_failed_path(project_root).read_text(encoding="utf-8").strip()
except OSError:
return ""
def mark_design_failed(project_root: Path, reason: str) -> None:
path = design_failed_path(project_root)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(reason, encoding="utf-8")
except OSError:
pass # 마커 실패가 체인을 막지는 않는다 — 안내가 덜 정확해질 뿐이다.
def clear_design_failed(project_root: Path) -> None:
try:
design_failed_path(project_root).unlink(missing_ok=True)
except OSError:
pass
def discard_initial_snapshot(project_root: Path) -> bool:
"""초기값을 무효화한다 — 지표면·노선이 바뀌어 옛 초기값이 더는 기준이 아닐 때.