- name
- seam-must-assert-what-the-consumer-sees
- description
- 시임(seam)의 생산자 쪽만 보고 "정리하는 게 맞다"고 추론해 상태를 지우면, 그 상태가 소비자에게는 인계 그 자체일 수 있다. 테스트를 그 추론대로 쓰면 green 이 추론을 검증해준 것처럼 보인다. 트리거 - 핸드오프/재투입/재시도 경계 설계, cleanup·abort·reset·clear 를 추가할 때, "리소스를 돌려줘야 하니까 정리한다", 다음 단계가 "할 일이 없다"고 보고하며 아무것도 안 함.
# 시임을 만들 때 소비자가 무엇을 보는지를 단언하라
## Problem
BSVibe #743: 머지워치가 PR 최신화 중 conflict 를 만나면 `git merge --abort` 를 했다.
추론은 이랬다 — 워크트리는 파운더 머신의 회수 가능한 자원이고(#736 리퍼), 머지 중인
트리는 리퍼가 영원히 거부한다. 그러니 정리하고 넘기는 게 맞다. 테스트도 그렇게 썼다:
```python
async def test_a_conflicted_merge_is_aborted_before_the_worktree_goes_back():
assert any(c.startswith("git merge --abort") for c in box.commands)
```
**green 이었다. 그리고 틀렸다.**
그 충돌 상태가 **인계 그 자체**였다. 재투입된 에이전트는 그 머지 *안에서* 해결해야
base 를 조상으로 갖는 커밋을 만든다. abort 된 깨끗한 트리를 받은 에이전트는:
> "충돌 마커가 없습니다. 이미 해결되었습니다."
라고 보고하고 파일을 손으로 고쳐 **선형 커밋**을 만들었다. 내용은 화해됐지만
`merge-base --is-ancestor` 는 NO — PR 은 영원히 dirty, 폴마다 같은 충돌 재투입,
재시도 소진, 에스컬레이션.
리퍼 걱정 자체도 거꾸로였다. **머지 중인 트리는 미해결 작업이 있는 트리이고,
리퍼가 거부하는 건 안전장치가 제대로 도는 것이다.** 에이전트가 해결·커밋하면
깨끗해져 회수된다.
## Why the test didn't help
테스트가 **요구사항이 아니라 내 추론을 단언했다.** 나는 시임의 *생산자* 쪽(리소스를
돌려주는 쪽)만 보고 결론을 냈고, 그 결론을 그대로 assert 로 옮겼다. green 은
"이 코드가 내가 생각한 대로 동작한다"만 말해주지 "내가 생각한 게 맞다"를 말해주지 않는다.
→ 형제 함정: [[boundary-test-must-not-supply-the-answer]]
## Solution
핸드오프 경계에서 상태를 지우기 전에 **소비자 쪽에서** 물어라:
1. **다음 단계는 무엇을 보고 일을 시작하는가?** 그 상태가 입력이면 지우면 안 된다.
2. **다른 실행 경로는 어떻게 하고 있나?** BSVibe 서버 모델은 애초에 abort 하지 않았고
(최신화가 에이전트의 클론에서 일어나므로 충돌이 자연히 남는다), 그게 정답이었다.
**두 경로가 갈리면 대개 새로 만든 쪽이 틀렸다.**
3. **"정리해야 한다"는 압박이 어디서 오나?** 그 자원 관리자가 이미 거부로 자신을
지키고 있다면(`git worktree remove` 가 force 없이 거부하듯), 정리는 내 일이 아니다.
테스트는 소비자 요구로 쓴다:
```python
async def test_a_conflicted_merge_is_LEFT_for_the_agent_to_resolve():
"""resolving INSIDE the merge is what produces a commit with base as a
parent, which is the only thing that makes the PR mergeable."""
assert not any("merge --abort" in c for c in box.commands)
```
정리가 정말 필요한 좁은 경우는 따로 남긴다 — unmerged 경로가 **없는** 머지 실패
(잘못된 ref·더러운 트리)는 넘길 충돌이 없으므로 그때는 abort 가 맞다.
## Detection
- 다음 단계가 "할 일이 없다 / 이미 되어 있다"고 보고하며 아무것도 안 한다
- 재시도가 같은 지점을 반복하다 에스컬레이션된다
- 같은 계약의 두 구현이 cleanup 여부에서 갈린다
---
## 사례 — 소비자가 **나중의 나**일 때 (관측 모드 / 단계적 점화)
게이팅 위험이 있는 변경을 **관측 모드**(기록만, 동작은 그대로)로 먼저 내보내는 것은 좋은
규율이다. 그런데 그 "기록"의 소비자는 **배포 뒤 prod 를 조회하는 나 자신**이고, 그 소비자가
무엇을 보는지 역시 단언해야 한다.
BSVibe A-2a 실측: 선언 시점에 조회된 패턴을 `registry.declaration_patterns` 에 담았다.
로컬 테스트는 전부 green. **그런데 MCP 트랜스포트는 요청마다 레지스트리를 새로 만든다** —
메모리 값은 응답이 끝나며 사라지고, **prod 에서 잰다는 이 lift 의 전제 자체가 성립하지 않았다.**
> **관측할 수 없는 관측 모드는 관측 모드가 아니다.** 그건 그냥 동작 없는 코드다.
같은 세션에서 나는 바로 그 사유로 다른 산출물을 거절했었다 — *"새 신호를 만들었으면 읽는
쪽까지 배선해라. 기록만 되고 아무도 안 읽는 값은 없는 것과 같다."* 판정 기준을 갖고 있어도
**자기 코드에 적용하지 않으면** 같은 결함을 만든다.
**머지 전에 답해야 하는 한 문장**
> 배포 후 이 값을 **어떤 쿼리 / 어떤 로그**로 볼 것인가? 그 문장을 쓸 수 없으면 관측 채널이 없는 것이다.
처방: 관측치를 **프로세스 밖으로** 내보내라 — 영속 상태(run payload·DB 컬럼)나 구조화 로그.
가능하면 **둘 다** (한쪽이 죽어도 재도록). 그리고 "트랜스포트를 건너 살아남는가"를 테스트로 못박아라.
```python
def test_the_observation_survives_the_transport():
state = registry.export_state() # 요청 경계를 넘는 채널
assert state["declaration_patterns"] == [...]
fresh = build_registry(); fresh.restore_state(state)
assert fresh.declaration_patterns == [...]
```
**Detection**: 플래그/관측 모드 PR 에서 새 값이 **인스턴스 속성에만** 있다 · 롤아웃 계획에
"배포 후 잰다"가 있는데 **그 쿼리를 아무도 안 써봤다** · 그 값을 읽는 코드가 테스트뿐이다.
---
## 사례 — **"소비 표면은 이미 있다"** 는 문장을 그대로 믿었을 때
앞의 두 사례는 *내가 만든* 신호였다. 이건 **문서가 소비자의 존재를 단언한** 경우다.
BSVibe 감사 C(라우팅 glass-box) 문서 원문:
> **#752 와 정확히 같은 형태다** — 소비 표면(활동 타임라인)은 있고 **생산자만** 신 경로에 없다.
이 문장대로면 작업은 "resolver 에 생산자 한 곳 배선" — 작고 명확하다. 착수 전에 읽는 쪽을
직접 밟아봤다:
```python
# backend/api/v1/runs/_helpers.py — _activity_label()
if activity_type == "tool_call": ...
if activity_type == "verify": ...
if activity_type == "settle": ...
if activity_type == "error": ...
return None # ← 모르는 타입은 전부 여기로
# _build_timeline()
label = _activity_label(row.activity_type, payload)
if label is None:
continue # ← 조용히 버려진다
```
**타임라인이라는 *그릇*은 있지만, 이 신호를 위한 *읽는 쪽*은 없었다.** 생산자만 넣었으면
행은 쌓이고 화면엔 아무것도 안 뜬다 — 정확히 **파운더가 그 전날 거절한 그 결함**을
재현하는 것이다.
> **"소비 표면이 있다"를 *그릇의 존재*로 확인하지 마라.** 그 신호가 실제로 화면까지 가는
> 경로를 **끝까지 밟아라.** 그릇은 대개 있다 — 없는 건 늘 그 안의 분기 하나다.
곁가지로 **범위도 틀렸었다**: 구 경로는 런당 1회 기록이었는데 신 경로의 `resolve_for` 는
런당 여러 번 불린다(캐시 없음). 그대로 옮겼으면 스토리 타임라인이 같은 줄로 범람했을 것이다.
**Detection**: 핸드오프·감사 문서가 *"X 만 없다 / 나머지는 준비돼 있다"* 로 범위를 좁혀준다 ·
소비자가 **allowlist 분기**(`if type == …` / dict lookup / switch)로 되어 있고 default 가
조용한 drop 이다 · "같은 형태다"라는 유추로 범위가 정해졌다(그 유추를 검증한 사람은 없다).
Auf GitHub ansehen