<!-- task=dispatcher_observation_refactor plan=1 tag=REFACTOR -->

# Dispatcher 관측 출력 1차 분리 계획

## 이 파일을 읽는 구현 에이전트에게

> **[IMPLEMENTING AGENT — READ FIRST]** 구현의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 명령 출력으로 채우는 것이다. 아래 검증을 실행하고, active PLAN/CODE_REVIEW 파일을 그대로 둔 채 리뷰 준비 완료를 보고한다. 종결·판정·로그 rename·`complete.log`·archive 이동은 code-review skill 전용이다.
>
> 구현이 막히면 구현 소유 evidence 필드에 정확한 blocker, 시도한 명령과 출력, 재개 조건만 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하지 말고, control-plane stop 파일을 만들거나 다음 상태를 분류하지 말며, 로그 archive나 `complete.log`를 작성하지 않는다.

## 배경

`dispatch.py`는 실행·상태·복구·스케줄링과 사용자 관측 출력을 한 파일에서 함께 소유해 5,886줄까지 커졌고, `test_dispatch.py`도 10,183줄의 여러 책임을 한 모듈에서 검증한다. 현재 heartbeat와 child output은 locator-owned 로그에만 남고 stdout에는 이벤트만 출력되는 계약이 이미 있으므로, 첫 리팩터링 사이클은 이 관측 경계를 별도 모듈과 집중 테스트로 분리한다. 이번 사이클은 전체 파일 비대화 해소가 아니라 이후 실행·상태 영역을 안전하게 분리할 수 있는 첫 seam 확립이며, 동작·출력 형식·`dispatch.banner` 테스트 패치 호환성은 유지한다.

## Archive Evidence Snapshot

- 이전 계획/리뷰: `agent-task/dispatcher_observation_refactor/plan_local_G03_0.log`, `agent-task/dispatcher_observation_refactor/code_review_cloud_G03_0.log`.
- verdict: 없음. 구현 전 계획 재검토로 보관됐으며 구현 결과나 review finding은 없다.
- 보강 사유: `test_dispatch.py` line reference 오기 수정, path-based loader의 단일 module identity/실패 계약 명시, focused/full discovery 중복 로드 방지, stdout AST oracle와 1차 seam 완료 기준 구체화.
- 영향 파일은 `scripts/dispatcher_observation.py`, `scripts/dispatch.py`, `tests/test_dispatcher_observation.py`, `tests/test_dispatch.py`로 동일하다.
- 기존 검증 근거: dispatcher unittest 257개 `OK`, `dispatch.py` pycompile 성공, `git diff --check` 성공. 구현 검증 근거는 아직 없다.
- roadmap carryover: 없음.

## 분석 결과

### 읽은 파일

- `agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md`
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/finalize-task-routing/SKILL.md`
- `agent-ops/skills/common/plan/templates/review-stub-template.md`
- `agent-test/local/rules.md`
- `agent-test/local/testing-smoke.md`
- `agent-spec/index.md`
- `agent-contract/index.md`
- `agent-roadmap/current.md`
- `agent-roadmap/ROADMAP.md`
- `agent-roadmap/priority-queue.md`
- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/agent-workflow-loop-orchestration-mvp.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/iop-agent-cli-runtime.md`

### SDD 기준

not applicable. 이 작업은 현재 Python reference dispatcher의 비로드맵 내부 리팩터링이며 Milestone Task 완료를 체크하지 않는다.

### 테스트 환경 규칙

- `test_env=local`.
- `agent-test/local/rules.md`를 읽었고 `agent-test/local/testing-smoke.md`도 확인했다. 해당 smoke profile의 Edge/Node 장기 실행 검증은 Python dispatcher 내부 모듈 분리에 적용되지 않는다.
- 적용 규칙은 `agent-ops/rules/project/domain/testing/rules.md`의 dispatcher 테스트에서 실제 provider 호출을 금지하고 fake runner/mock만 사용하는 계약이다.
- Python dispatcher에 맞는 구체 명령은 기존 repository unittest layout을 fallback source로 사용한다. 기준 실행 `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*.py'`는 현재 257 tests, `OK`로 확인했다.
- `python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py`와 `git diff --check`도 현재 checkout에서 성공했다.
- 모든 검증은 현재 checkout 내부의 local Python subprocess와 mock만 사용한다. 외부 provider/remote runner/device가 없어 비-local preflight는 필요하지 않다.
- unittest는 매 실행마다 새 프로세스와 임시 디렉터리를 사용하므로 cached output을 성공 근거로 인정하지 않는다.

### 테스트 커버리지 공백

- 기존 `test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream`은 heartbeat/child output의 로그 보존과 stdout 비노출을 통합 검증한다. 이 테스트를 집중 파일로 이동해 동일 계약을 유지한다.
- 기존 `test_dispatcher_owns_observation_and_caller_wakes_only_for_attention`은 skill의 caller-LLM 비개입 문구를 검증한다. 이 테스트도 관측 계약 파일로 이동한다.
- 기존 dry-run 테스트는 `dispatch.banner`를 patch하므로 호환 alias가 깨지면 회귀를 탐지한다.
- 공백: 관측 모듈 자체의 banner/attempt 출력 형식, `dispatch.SEP`/`dispatch.banner` 호환 alias, 여러 test module이 같은 observation module instance를 재사용하는지, `dispatch.py`에 직접 stdout `print()`가 다시 생기지 않는다는 정적 보장이 없다. `test_dispatcher_observation.py`에 직접 단위/module identity/AST 회귀 테스트를 추가한다.

### 심볼 참조

- renamed/removed symbol: none. `dispatch.SEP`, `dispatch.banner`, `dispatch.attempt_event`는 observation module alias로 노출한다.
- `banner(...)` 호출은 `dispatch.py:3861-4268`, `dispatch.py:4761-5084`, `dispatch.py:5167-5833`에 있고, patch consumer는 `test_dispatch.py:7827`에 있다. 호출부 이름은 바꾸지 않는다.
- 현재 사용자 stdout 직접 출력은 `dispatch.py:138-144`, `dispatch.py:3181-3186`, `dispatch.py:3211`, `dispatch.py:3274`, `dispatch.py:3397`, `dispatch.py:3421`, `dispatch.py:3461`, `dispatch.py:4647`이다. banner는 관측 모듈로 이동하고 나머지 stdout 지점은 `attempt_event`를 통하도록 바꾼다.
- `dispatch.py:5869-5881`의 `file=sys.stderr` CLI 종료 진단은 stdout 이벤트 계약 밖이므로 유지한다.

### 분할 판단

한 Plan으로 유지한다. “stdout 이벤트 렌더링은 observation module 한 곳에서 소유하고, 호환 alias는 유지하며, heartbeat/child output은 locator-owned 로그에만 둔다”가 하나의 작고 독립적인 불변조건이다. 생산 코드 seam, module identity, 해당 회귀 테스트를 같은 PASS 단위로 검증해야 하며 실행·상태·복구 모듈 분리는 이 경계가 안정된 뒤 별도 Plan으로 다룬다.

### 범위 결정 근거

- 이번 완료 기준은 `dispatch.py`의 직접 stdout `print()` 0개, 사용자 stdout 렌더링의 `dispatcher_observation.py` 단일 소유, 두 관측 계약 테스트의 focused test 파일 이전이다. `dispatch.py`/`test_dispatch.py` 전체 크기를 한 사이클에 해소하는 것은 범위가 아니다.
- `StateStore`, selector/quota, scheduler, recovery budget, process liveness, WORK_LOG/archive 로직은 동작 변경 위험이 커서 이번 사이클에서 이동하거나 재설계하지 않는다.
- `SKILL.md`의 event-only/caller-attention 계약은 이미 존재하므로 문구를 다시 수정하지 않는다.
- `execution_target_policy.py`, `select_execution_target.py`와 해당 테스트는 관측 출력 경계를 사용하지 않아 제외한다.
- 새 외부 package는 필요 없으며 manifest 변경도 없다.
- 테스트가 `dispatch.py`를 `spec_from_file_location`으로 직접 로드하므로 일반 sibling import에 의존하지 않는다. `dispatch.py`는 고정 private module name과 sibling 경로로 observation module을 로드하고 `sys.modules`의 기존 instance를 재사용한다. focused test loader도 기존 `agent_task_dispatch` instance를 우선 재사용해 단독/전체 discovery 모두에서 중복 실행을 피한다.

### 최종 라우팅

- `evaluation_mode=isolated-reassessment`, `finalizer=finalize-task-policy.sh`, `finalizer_mode=pair`.
- Build closures: `scope_closed=true`, `context_closed=true`, `verification_closed=true`, `evidence_trusted=true`, `ownership_closed=true`, `decision_closed=true`; capability gap 없음.
- Build scores: `scope_coupling=1`, `state_concurrency=0`, `blast_irreversibility=1`, `evidence_diagnosis=0`, `verification_complexity=1`; grade `G03`, base/final route basis `local-fit`, route `local`, filename `PLAN-local-G03.md`.
- Review closures: `scope_closed=true`, `context_closed=true`, `verification_closed=true`, `evidence_trusted=true`, `ownership_closed=true`, `decision_closed=true`; capability gap 없음.
- Review scores: `scope_coupling=1`, `state_concurrency=0`, `blast_irreversibility=1`, `evidence_diagnosis=0`, `verification_complexity=1`; grade `G03`, route basis `official-review`, route `cloud`, adapter `codex`, model `gpt-5.6-sol`, reasoning `xhigh`, filename `CODE_REVIEW-cloud-G03.md`.
- `large_indivisible_context=false`.
- Positive loop risk: `boundary_contract`; `loop_risk_count=1`, `risk_boundary_matched=false`.
- Recovery signals: `review_rework_count=0`, `evidence_integrity_failure=false`, `recovery_boundary_matched=false`.

## 구현 체크리스트

- [ ] REFACTOR-1 관측 출력 모듈을 만들고 단일 module identity와 `dispatch.SEP`/`dispatch.banner` 호환 seam을 유지한다.
- [ ] REFACTOR-2 dispatcher의 사용자 stdout 직접 출력을 관측 모듈로 통합하고 로그·상태 동작을 보존한다.
- [ ] REFACTOR-3 관측 관련 테스트를 집중 파일로 분리하고 출력/호환/module identity/비노출 회귀를 보강한다.
- [ ] 전체 dispatcher unittest, Python compile, 결정적 stdout 소유권 검사, diff 검증을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.

### [REFACTOR-1] 관측 출력 모듈과 호환 seam 분리

#### 문제

`dispatch.py:23`의 separator와 `dispatch.py:136-144`의 banner 렌더링이 5,886줄짜리 실행 모듈에 직접 들어 있다. 테스트는 `test_dispatch.py:7827`에서 `dispatch.banner`를 patch하므로 단순 이동은 기존 import/patch 계약을 깨뜨릴 수 있다. 또한 `test_dispatch.py:20-25`처럼 경로 기반으로 dispatch를 로드하므로 observation module도 고정 identity 없이 매번 실행하면 focused/full discovery 사이에 서로 다른 module instance가 생긴다.

#### 해결 방법

`scripts/dispatcher_observation.py`에 `SEP`, `banner(event, task, lines=None)`, `attempt_event(prefix, message)`를 둔다. `dispatch.py`는 고정 private name으로 `sys.modules`의 기존 observation module을 먼저 재사용하고, 없을 때만 sibling 경로를 `importlib.util.spec_from_file_location`으로 로드한다. spec/loader가 없으면 `RuntimeError`, module 실행이 실패하면 등록한 부분 초기화 entry를 제거한 뒤 원래 예외를 다시 발생시킨다. 기존 `SEP`, `banner`, `attempt_event` 이름은 alias로 재노출한다.

Before (`dispatch.py:23`, `dispatch.py:136-144`):

```python
SEP = "-" * 42

def banner(event: str, task: str, lines: list[str] | None = None) -> None:
    display_task = task.rsplit("/", 1)[-1]
    print(SEP, flush=True)
    print(f"{event}: {display_task}", flush=True)
    print(SEP, flush=True)
    if display_task != task:
        print(f"task={task}", flush=True)
    for line in lines or []:
        print(line, flush=True)
```

After:

```python
_OBSERVATION_MODULE_NAME = "agent_task_dispatcher_observation"

def load_sibling_observation_module():
    loaded = sys.modules.get(_OBSERVATION_MODULE_NAME)
    if loaded is not None:
        return loaded
    spec = importlib.util.spec_from_file_location(
        _OBSERVATION_MODULE_NAME,
        Path(__file__).with_name("dispatcher_observation.py"),
    )
    if spec is None or spec.loader is None:
        raise RuntimeError("failed to load dispatcher observation module")
    module = importlib.util.module_from_spec(spec)
    sys.modules[_OBSERVATION_MODULE_NAME] = module
    try:
        spec.loader.exec_module(module)
    except BaseException:
        sys.modules.pop(_OBSERVATION_MODULE_NAME, None)
        raise
    return module

observation = load_sibling_observation_module()
SEP = observation.SEP
banner = observation.banner
attempt_event = observation.attempt_event
```

```python
# dispatcher_observation.py
SEP = "-" * 42

def banner(event: str, task: str, lines: list[str] | None = None) -> None:
    ...

def attempt_event(prefix: str, message: str) -> None:
    print(f"{prefix} {message}", flush=True)
```

#### 수정 파일 및 체크리스트

- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py` — 기존 banner 형식과 단일 attempt event emitter를 정의한다.
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` — idempotent path-based loader와 `SEP`/`banner`/`attempt_event` alias를 추가하고 기존 banner 구현을 제거한다.
- [ ] module load 실패는 import 시 명시적으로 드러나게 하고 silent fallback이나 부분 초기화 module을 남기지 않는다.

#### 테스트 작성

작성한다. REFACTOR-3의 `ObservationOutputTest.test_banner_preserves_existing_format_and_nested_task_identity`, `test_attempt_event_is_one_flushed_stdout_line`, `test_dispatch_compatibility_aliases_point_to_observation_module`, `test_observation_module_identity_is_reused`가 exact output, 기존 alias, module identity를 검증한다.

#### 중간 검증

```bash
python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py
```

예상 결과: exit `0`, stdout/stderr 없음.

### [REFACTOR-2] 사용자 이벤트 출력 경로 통합

#### 문제

`invoke()`와 attempt cleanup은 `dispatch.py:3181-3465`, `dispatch.py:4647-4651`에서 사용자 이벤트를 직접 `print()`한다. 이 구조는 새 출력이 heartbeat/child stream과 같은 내부 관측인지 caller-facing event인지 매 수정마다 큰 파일 안에서 다시 판단하게 만든다.

#### 해결 방법

main의 stderr 종료 진단을 제외한 직접 stdout 출력은 `attempt_event(prefix, message)`로 통일한다. heartbeat write와 normalized child output write는 현재처럼 파일에만 남기고 emitter를 호출하지 않는다. event 문구, prefix, flush, locator/work-log/state update 순서는 바꾸지 않는다.

Before (`dispatch.py:3186`, `dispatch.py:3461-3465`):

```python
print(f"{prefix} locator={locator_path}", flush=True)

print(
    f"{prefix} 리뷰 제어 계약 위반: collaboration-tool="
    f"{collaboration_tool}",
    flush=True,
)
```

After:

```python
attempt_event(prefix, f"locator={locator_path}")
attempt_event(
    prefix,
    f"리뷰 제어 계약 위반: collaboration-tool={collaboration_tool}",
)
```

#### 수정 파일 및 체크리스트

- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` — locator warning/start, work-log setup failure, command-not-found, model-response inspection, review-control violation, attempt cleanup warning을 emitter로 전환한다.
- [ ] heartbeat와 normalized child output 경로에는 새 emitter 호출을 추가하지 않는다.
- [ ] `main()`의 `file=sys.stderr` 종료 진단과 exit code 의미는 유지한다.

#### 테스트 작성

작성한다. REFACTOR-3의 AST 테스트는 `dispatch.py`에서 `file=sys.stderr`가 명시된 `print()`만 허용하고, 이동한 invoke 통합 테스트는 heartbeat/child output이 stdout에 나타나지 않으면서 로그에는 남는지 검증한다.

#### 중간 검증

```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatch.py'
```

예상 결과: 기존 dispatcher 테스트 전체 PASS, 실제 provider 호출 없음.

### [REFACTOR-3] 관측 테스트 모듈 분리

#### 문제

관측 계약 테스트가 `test_dispatch.py:2071-2141`의 invoke 통합 class와 `test_dispatch.py:3400-3500`의 review-control class에 섞여 있다. 새 관측 모듈에 대한 직접 단위 테스트도 없어 생산 모듈 분리와 테스트 파일 분리가 같은 경계로 유지되지 않는다.

#### 해결 방법

`tests/test_dispatcher_observation.py`를 만들고 두 기존 관측 계약 테스트를 이름과 assertion 의미를 유지한 채 이동한다. 여기에 banner/attempt exact-output, compatibility alias, module identity, direct stdout print AST 검사를 추가한다. focused loader는 `sys.modules.get("agent_task_dispatch")`를 먼저 사용하고 없을 때만 현재 `test_dispatch.py:20-25` 방식으로 load/register/execute한다. 공용 fixture 대규모 추출은 하지 않고 이 파일에 필요한 최소 task fixture만 둔다.

Before (`test_dispatch.py:2071`, `test_dispatch.py:3400`):

```python
class WorkLogInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase):
    async def test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream(self):
        ...

class ReviewControlTest(unittest.TestCase):
    def test_dispatcher_owns_observation_and_caller_wakes_only_for_attention(self):
        ...
```

After:

```python
class ObservationOutputTest(unittest.TestCase):
    ...

class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase):
    async def test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream(self):
        ...

class SkillObservationContractTest(unittest.TestCase):
    def test_dispatcher_owns_observation_and_caller_wakes_only_for_attention(self):
        ...
```

#### 수정 파일 및 체크리스트

- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py` — 집중 단위/통합/skill-contract 테스트와 idempotent dispatch loader를 추가한다.
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py` — 이동한 두 테스트만 제거하고 다른 class/fixture는 유지한다.
- [ ] AST 검사는 `ast.parse`로 `dispatch.py`의 call을 판정하며 `print()`에 `file=sys.stderr`가 명시된 경우만 허용한다. `rg` 출력은 review evidence일 뿐 이 테스트를 대체하지 않는다.
- [ ] focused 파일 단독 discovery와 전체 `test_*.py` discovery 양쪽에서 module loading이 독립적으로 동작하고 observation module identity가 하나인지 확인한다.

#### 테스트 작성

작성한다. 추가 테스트는 `test_banner_preserves_existing_format_and_nested_task_identity`, `test_attempt_event_is_one_flushed_stdout_line`, `test_dispatch_compatibility_aliases_point_to_observation_module`, `test_observation_module_identity_is_reused`, `test_dispatch_has_no_direct_stdout_print_calls`이며, 기존 두 회귀 테스트를 이동한다.

#### 중간 검증

```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatcher_observation.py'
```

예상 결과: 관측 집중 테스트 전체 PASS, 실제 provider 호출 없음.

## 의존 관계 및 구현 순서

1. REFACTOR-1로 import/compatibility/module identity seam을 만든다.
2. REFACTOR-2로 기존 direct stdout call site를 seam에 연결한다.
3. REFACTOR-3으로 테스트를 이동·보강한 뒤 전체 suite를 실행한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py` | REFACTOR-1 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` | REFACTOR-1, REFACTOR-2 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py` | REFACTOR-1, REFACTOR-2, REFACTOR-3 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py` | REFACTOR-3 |

## 최종 검증

```bash
python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatcher_observation.py'
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*.py'
rg --sort path -n '\bprint\(' agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py
git diff --check
```

예상 결과: compile/focused/full suite/diff check는 exit `0`; full suite는 실제 provider 호출 없이 전체 PASS. AST 테스트가 `dispatch.py`의 직접 stdout `print()` 0개를 판정하며, `rg` 결과에서 `dispatch.py`의 `print()`는 `file=sys.stderr` CLI 종료 진단뿐이고 stdout `print()`는 `dispatcher_observation.py`의 emitter 구현에만 존재한다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.
