- name
- half-wired-subsystem-audit
- description
- 서브시스템의 "보이는 절반"(설정 UI·스키마·워커·읽기 엔드포인트)만 만들어지고 "안 보이는 절반"(프로덕션 producer/consumer, authoring 입력구, 서버 승격)이 빠지는 반복 결함. 유닛테스트 100% green으로 통과하며, 자율성은 전부 그 안 보이는 절반에 산다. 신규 서브시스템 설계·리뷰 시, 그리고 "설정은 있는데 왜 동작 안 하지" 증상에서 사용.
- version
- 1.0.0
- task_types
- ["review","debug","architecture"]
- required_tools
- ["Bash","Read","Grep"]
- triggers
- [{"pattern":"설정/스위치는 있는데 아무 일도 안 일어남"},{"pattern":"워커는 도는데 처리할 게 안 들어옴"},{"pattern":"기능이 완성돼 보이는데 프로덕션에서 침묵함"},{"pattern":"자율 파이프라인/스케줄러/알림/큐 서브시스템 설계 또는 리뷰"}]
# Half-Wired Subsystem: 보이는 절반만 만들어진다
## Problem
기능이 완성돼 보인다. 스키마 있고, 설정 UI 있고, 워커 등록돼 있고, REST/MCP 엔드포인트 있고, 유닛테스트 100% green. **그런데 프로덕션에서 아무 일도 일어나지 않는다.**
- **증상**: 스위치를 켜도 무반응 / 워커는 폴링하는데 큐가 영원히 비어 있음 / 상태머신의 terminal state가 한 번도 관측되지 않음 / "설정했는데 알림이 안 와요"
- **근본 원인**: 서브시스템의 **한쪽 끝을 잇는 프로덕션 호출자가 0개**다. 죽은 코드가 완성품처럼 보인다.
- **흔한 오해**: "구현됐고 테스트도 통과하니 동작한다." → 유닛테스트는 그 메서드를 **직접 호출**한다. 프로덕션 경로가 그걸 부르는지는 검증하지 않는다.
### 왜 하필 이 절반이 빠지는가
**구현 압력은 가시성을 따라간다.**
| 절반 | 압력 | 결과 |
|---|---|---|
| 설정 UI, 그리드, 배지, 읽기 API | 디자이너가 보고, 스크린샷 찍히고, 리뷰어가 클릭함 | ✅ 만들어짐 |
| emitter, sweeper, authoring 입력구, 서버 승격 | 아무도 스크린샷 안 찍음. 유닛테스트는 클래스를 직접 호출해서 우회함 | ❌ 빠짐 |
그래서 **UI가 없고 테스트 압력도 없는 절반**이 정확히 빠진다.
**에이전트/자율 시스템에서 이게 치명적인 이유**: 자율성은 전부 그 안 보이는 절반(스케줄러, emitter, resolver, worker, 트리거)에 산다. 시스템이 완성돼 보이면서 **스스로 아무것도 못 하는** 상태가 된다.
---
## 다섯 가지 형태
실증 사례(BSVibe, 2026):
| # | 형태 | 실제 사례 |
|---|---|---|
| 1 | **Consumer 없음** — terminal 단계(apply/commit/finalize)에 프로덕션 호출자 0 | `apply_pending` 완전 구현+테스트, REST/MCP는 `issue()`만 호출. tombstone이 영영 안 써짐 (PR #530) |
| 2 | **Producer 없음** — 저장소+설정+읽기 API는 있는데 발행하는 코드 0 | `NotificationPrefsRow` 매트릭스와 `needs_you` 기본값까지 정의됐는데, `needs_you`를 emit하는 프로덕션 코드 0줄. docstring: *"v1 stores the PREFERENCES only"* |
| 3 | **입력구 없음** — 워커가 폴링하는 테이블에 행을 넣는 경로 0 | `ScheduleWorker`가 프로덕션 워커 세트에 등록돼 `workspace_schedules`를 폴링. 그런데 리포지토리에 `add()`가 없고 REST·MCP·UI 전무. 행을 만드는 건 테스트뿐 → 엔진은 돌지만 영구히 inert |
| 4 | **클라이언트 전용 상태** — UI 설정이 localStorage에만 있고 서버 로직이 그걸 필요로 함 | timezone 셀렉터가 PWA Settings에 있으나 `localStorage` 전용. quiet-hours 평가는 서버 워커에서 → 영영 못 읽음. (같은 파일의 `language`는 이미 서버로 승격됐는데 `timezone`만 안 따라감) |
| 5 | **SoT 불일치** — 보이는 절반이 진짜 레지스트리 대신 상상한 고정 집합에 맞춰 만들어짐 | 알림 채널의 SoT는 커넥터 바인딩인데 `DEFAULT_CHANNELS = ("in_app","email","slack")` 고정 튜플 → telegram/discord는 플러그인이 있어도 **설정에 표현조차 불가** |
1~3은 "안 만들어짐", 4~5는 "**틀린 모델로** 만들어짐". 후자가 더 위험하다 — 붙일 자리 자체가 안 맞아서 배선하려는 순간 발견된다.
---
## Solution — 진단 레시피
서브시스템마다 **양 끝**을 명시적으로 세라. 프로덕션 호출자를 **센다**. docstring을 믿지 않는다.
### 1. Terminal 동사에 프로덕션 호출자가 있는가
```bash
# apply / commit / finalize / sweep / emit / send / dispatch / resolve / notify
grep -rn --include="*.py" "\.apply_pending(\|\.commit_pending(\|\.sweep(" backend | grep -v test
# 0 → 죽은 코드
```
### 2. 워커가 폴링하는 테이블에 INSERT 경로가 있는가
```bash
# 워커가 claim/poll 하는 테이블마다
grep -rn --include="*.py" "WorkspaceScheduleRow(" backend | grep -v test
grep -rn --include="*.py" "def add\|session.add(WorkspaceScheduleRow" backend/schedule | grep -v test
# 테스트에서만 생성 → 엔진은 돌지만 입력이 영영 없음
```
### 3. 설정 row를 읽는 소비자가 자기 자신의 get/update 말고 있는가
```bash
grep -rn --include="*.py" "NotificationPrefsRow" backend | grep -v test | grep -v "api/v1/notifications\|mcp/tools/notifications"
# 0 → 아무 데도 연결 안 된 스위치
```
### 4. 고정 튜플/enum이 진짜 SoT와 일치하는가
`("in_app","email","slack")` 같은 **하드코딩된 집합**을 보면 즉시 물어라 — *이 목록의 진짜 SoT는 어디인가?* 플러그인 레지스트리? 커넥터 바인딩 테이블? 그렇다면 그 튜플은 **런타임 resolve로 대체**되어야 한다. 안 그러면 SoT에는 있는데 UI에는 표현 불가능한 멤버가 생긴다.
### 5. 클라이언트 설정을 서버가 필요로 하는가
```bash
# PWA에 있는 설정 필드가 백엔드 컬럼으로 존재하는가
grep -rn "timezone" apps/pwa/lib/preferences/ # localStorage?
grep -rn --include="*.py" "timezone.*Mapped" backend # 서버 컬럼 0개?
```
서버 워커/크론/스케줄러가 그 값으로 판단한다면 **서버 승격 필수**. 같은 파일의 다른 필드가 이미 승격됐다면 그 패턴을 복제하면 된다.
### 6. 스모킹건 — terminal state가 관측되지 않는다
상태머신이 항상 `expired`고 절대 `already_applied`가 안 나온다 → apply가 안 돈다. 로그에 특정 이벤트가 0건 → producer가 없다. **"0이 나온다"는 관측은 producer가 꺼져 있어도 똑같이 나온다** ([[absence-measurement-validity-check]]).
---
## 예방 — Producer 존재 증명 테스트
**이 결함 클래스는 유닛테스트가 100% green이어도 통과한다.** 유닛테스트가 그 메서드를 직접 호출하기 때문이다. 방어는 하나뿐이다:
> **프로덕션 진입점을 실제로 구동해서, 하류 효과가 실제로 생겼는지 확인하는 통합 테스트.**
> `dependency_overrides` 금지. 픽스처 사전 시딩 금지.
```python
# ❌ 이건 이 버그를 못 잡는다 — 메서드를 직접 부르니까
async def test_notifier_sends(notifier, mock_channel):
await notifier.send(event="needs_you", ...)
assert mock_channel.sent
# ✅ 이게 잡는다 — 진짜 진입점을 구동하고 하류 행이 생겼는지 본다
async def test_decision_creation_emits_notification(real_app, real_db):
run = await create_run(real_app) # 진짜 REST/MCP 진입점
await agent_calls_ask_user_question(run) # 진짜 프로덕션 경로
rows = await real_db.scalars(select(NotificationEventRow))
assert len(rows.all()) == 1 # producer가 프로덕션에 존재하는가
```
리뷰 규율: **새 서브시스템 PR은 양 끝의 프로덕션 호출 지점을 본문에 명시**해야 한다. "producer: X:123, consumer: Y:456". 한쪽을 못 적으면 그 lift는 미완이다.
---
## Key Insights
- **완성돼 보이는 것과 배선된 것은 다르다.** 스키마·UI·워커·테스트가 전부 있어도 서브시스템은 죽어 있을 수 있다.
- **빠지는 절반은 무작위가 아니다.** UI가 없고 유닛테스트가 우회하는 쪽이 빠진다. 그러니 **어디를 볼지 예측할 수 있다.**
- **docstring은 배선의 증거가 아니다.** *"a later phase"*, *"background sweep will handle it"*, *"documented follow-up"* — 전부 미배선의 자백이다.
- **자율 시스템에서는 이게 곧 자율성의 부재다.** 에이전트 파이프라인을 "사람이 방아쇠를 당기는 기계"에서 "스스로 도는 기계"로 만드는 작업은, 대부분 이 안 보이는 절반을 채우는 작업이다.
- 이미 승격/배선된 **형제 필드**를 찾아라 (`language`는 서버로 갔는데 `timezone`은 안 감). 패턴이 있으면 복제하면 되고, 없으면 새로 설계해야 한다.
## Red Flags
- docstring에 *"v1 stores X only"*, *"delivery wiring is a later phase"*, *"LOCAL-ONLY for now, no backend sync"*
- 워커가 런타임에 등록돼 있는데 그 입력 테이블의 리포지토리에 `add()`/`create()`가 없다
- 설정 그리드의 열이 **하드코딩된 튜플**인데, 그 도메인에 실제 레지스트리(플러그인/커넥터/바인딩)가 따로 있다
- 어떤 클래스의 유일한 생성 지점이 `tests/` 안에 있다
- `NotImplementedError` 스텁이 "의도적"이라고 주석돼 있다 (진짜 의도적일 수도 있지만, 대체 경로가 실제로 있는지 확인하라)
- 상태머신의 특정 terminal state가 프로덕션 데이터에 **한 번도** 나타나지 않는다
- 유닛 커버리지는 높은데 그 서브시스템을 **진입점부터 구동하는** 테스트가 없다
## 관련
- [[feedback_queue_apply_step_never_wired]] — 이 스킬의 최초 사례(형태 1)
- [[mock-fixtures-hide-wiring-bugs]] — `dependency_overrides` + 사전 시딩이 프로덕션 글루의 부재를 가린다
- [[absence-measurement-validity-check]] — "0이 관측됨"은 producer가 꺼져 있을 때도 똑같이 나온다
- [[dogfood-automation-bypasses-the-surface-it-tests]] — 검증 경로가 진짜 표면을 우회하는 같은 계열의 함정
---
## 사례 — 감사가 *"인자를 안 넘겼다"* 라고 적을 때, **생성 지점부터 세라** (BSVibe, 2026-08-20)
구조 감사가 이렇게 적었다: *"`IngestCompiler` 프로덕션 생성 지점 둘 다 `retriever=` 를 안 넘겨서
`find_related` 가 항상 `\"No existing notes available.\"` 를 반환한다."* 증상은 정확했다.
**처방은 세 번 틀렸다.**
한 줄 세어 보니:
| 심볼 | 프로덕션 생성 지점 |
|---|---|
| `VaultRetriever(` | **0** (테스트뿐) |
| `FileIndexReader(` | **0** |
| `IndexSubscriber(` / `update_entry` | **0** — 볼트 인덱스는 한 번도 쓰인 적 없음 |
**인자를 빠뜨린 게 아니라 넘길 객체가 만들어진 적이 없었다.** 이건 "반쯤 배선"이 아니라
**미구현**이고, 고치는 방법이 완전히 다르다. 감사 처방을 그대로 따랐다면:
1. 감사가 시사한 팩토리 메서드는 **다른 Protocol** 을 반환한다 → 타입 에러
2. 빈 생성자로 넘기면 **recency 폴백** — *"무엇이 최신인가"* 라는 **다른 질문**에 답한다.
그걸 "관련 항목"이라 주면 소비자가 **무관한 대상에 대고 update** 를 한다(침묵보다 나쁨)
3. 진짜 백엔드는 **살아 있는 세션**을 붙드는데 소비자는 그것을 **동시**에 호출한다 →
세션 공유는 동시 사용 버그
그리고 정작 쓸 자산은 이미 살아 있었다 — 벡터 테이블 **1,714행, 최신**, producer 도 같은
두 런타임에서 돌고 있었다. 감사는 그것을 세지 않았다.
> **왜 감사가 이걸 놓치나**: 코드 감사 에이전트는 **호출부의 인자 목록**을 읽는다.
> 생성자의 **전면 부재**나 **대체 자산의 존재**는 다른 질문이고, 아무도 묻지 않았다.
**처방** — 감사·리뷰의 *"X 를 안 넘겼다"* 를 고치기 전에 **세 번 세라**:
```bash
grep -rn "X(" backend/ | grep -v tests # ① 프로덕션 생성 지점이 0개인가? → 미구현이다
```
- ② **Protocol 이 맞나** — 이름이 비슷한 다른 추상화를 넘기라는 조언일 수 있다
- ③ **동시성** — 소비자가 그것을 병렬로 부르나? 세션/커넥션을 쥔 객체면 호출당 스코프가 필요하다
- ④ **살아 있는 대체 자산** — 같은 일을 하는데 **다른 Protocol 로** 이미 채워져 돌아가는 게 있나
그리고 고칠 때 **새 추상화를 만들기 전에 기존 DI 지점을 찾아라.** 위 사례의 답은
컴파일러 경계가 아니라 한 겹 아래 **벡터 백엔드 Protocol** 이었다 — 그걸 만족하는
세션-스코프 구현을 넣으니 상위 타입도 소비자도 **하나도 안 바뀌었다**(새 Protocol 0개).
**Detection**: 감사/리뷰가 한 줄짜리 처방을 제시했다 · 처방의 대상 심볼이 테스트에서만 생성된다 ·
"이 인자만 넘기면 된다"인데 타입이 안 맞는다 · 소비자가 `asyncio.gather` / parallelism 설정을 갖고 있다
在 GitHub 查看