<!-- task=cli_terminal_cycle plan=2 tag=REVIEW_REVIEW_API -->

# Lifecycle Start Rollback 보강

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

각 항목의 체크리스트를 완료하면 즉시 체크 표시한다. 중간/최종 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여 넣는다. 계획과 다르게 구현해야 하는 부분이 생기면 이유를 `CODE_REVIEW.md`의 `계획 대비 변경 사항`에 남기고, 모든 구현 항목의 실제 동작과 수동 검증 결과를 기록한다.

## 배경

후속 구현은 persistent CLI의 response boundary와 process failure 전파를 바로잡았다. 하지만 lifecycle start가 여러 단계 중간에 실패할 때 이미 시작된 adapter/process가 남을 수 있다. node startup 실패 시 프로세스가 남지 않는 것을 보장하려면 registry, CLI adapter 내부, bootstrap 실패 경로가 모두 rollback을 수행해야 한다.

---

### [REVIEW_REVIEW_API-1] Partial lifecycle start 실패 시 started resources 정리

#### 문제

`apps/node/internal/adapters/registry.go:66-74`:
```go
func (r *Registry) Start(ctx context.Context) error {
	for _, name := range r.order {
		lc, ok := r.adapters[name].(LifecycleAdapter)
		if !ok {
			continue
		}
		if err := lc.Start(ctx); err != nil {
			return fmt.Errorf("adapter %q start: %w", name, err)
		}
	}
	return nil
}
```

앞선 lifecycle adapter가 성공한 뒤 뒤의 adapter가 실패하면 이미 시작된 adapter의 `Stop`이 호출되지 않는다.

`apps/node/internal/adapters/cli/cli.go:77-86`:
```go
func (c *CLI) Start(ctx context.Context) error {
	for name, profile := range c.profiles {
		if !profile.Persistent {
			continue
		}
		sess, err := startProfileSession(ctx, name, profile, c.logger)
		if err != nil {
			return fmt.Errorf("cli adapter: start profile %q: %w", name, err)
		}
		c.sessions[name] = sess
```

하나의 `cli` adapter 안에서도 여러 persistent profile 중 앞선 profile이 성공하고 다음 profile이 실패하면 이미 시작한 process가 남는다.

`apps/node/internal/bootstrap/module.go:61-65`:
```go
if err := reg.Start(ctx); err != nil {
	_ = result.Session.Close()
	_ = st.Close()
	return fmt.Errorf("bootstrap: start adapters: %w", err)
}
```

`reg.Start` 실패 경로에서 session/store는 닫지만 registry lifecycle rollback을 추가로 호출하지 않는다. `Registry.Start`와 `CLI.Start`가 rollback을 하더라도 bootstrap에서 `reg.Stop`을 한 번 더 호출하는 것은 idempotent cleanup 관점에서 안전해야 한다.

#### 해결 방법

`Registry.Start`는 started lifecycle 목록을 추적하고 실패 시 역순으로 `Stop`을 호출한다.

```go
func (r *Registry) Start(ctx context.Context) error {
	started := make([]string, 0, len(r.order))
	for _, name := range r.order {
		lc, ok := r.adapters[name].(LifecycleAdapter)
		if !ok {
			continue
		}
		if err := lc.Start(ctx); err != nil {
			for i := len(started) - 1; i >= 0; i-- {
				if startedLC, ok := r.adapters[started[i]].(LifecycleAdapter); ok {
					_ = startedLC.Stop(context.Background())
				}
			}
			return fmt.Errorf("adapter %q start: %w", name, err)
		}
		started = append(started, name)
	}
	return nil
}
```

`CLI.Start`는 profile start 실패 시 이미 `c.sessions`에 등록된 session들을 `Stop(context.Background())`으로 정리하고, 정리 후 `c.sessions`를 빈 map으로 되돌린다.

```go
if err != nil {
	_ = c.Stop(context.Background())
	c.sessions = make(map[string]*profileSession)
	return fmt.Errorf("cli adapter: start profile %q: %w", name, err)
}
```

`CLI.Stop`은 여러 번 호출돼도 안전하도록 nil/empty sessions를 허용하고, close/kill 이후 세션 map을 비운다. 이미 종료된 process의 close/kill error는 기존처럼 firstErr로 반환하되 cleanup은 끝까지 진행한다.

`bootstrap.Module`의 `reg.Start` 실패 경로에는 `_ = reg.Stop(context.Background())`를 추가한다.

```go
if err := reg.Start(ctx); err != nil {
	_ = reg.Stop(context.Background())
	_ = result.Session.Close()
	_ = st.Close()
	return fmt.Errorf("bootstrap: start adapters: %w", err)
}
```

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

- [x] `apps/node/internal/adapters/registry.go` - `Start` 실패 시 이미 시작된 lifecycle adapter를 역순으로 stop
- [x] `apps/node/internal/adapters/registry_test.go` - second lifecycle adapter start 실패 시 first adapter stop 호출 테스트 추가
- [x] `apps/node/internal/adapters/cli/cli.go` - `Start` 실패 시 이미 시작된 persistent sessions 정리
- [x] `apps/node/internal/adapters/cli/cli.go` - `Stop`을 반복 호출해도 안전하게 sessions map 정리
- [x] `apps/node/internal/adapters/cli/cli_test.go` - 여러 persistent profile 중 뒤 profile 실패 시 앞 profile process cleanup 테스트 추가
- [x] `apps/node/internal/bootstrap/module.go` - `reg.Start` 실패 경로에서 `reg.Stop(context.Background())` 호출

#### 테스트 작성

작성:

- `apps/node/internal/adapters/registry_test.go` - `TestRegistryLifecycle_StartFailureStopsStartedAdapters`
  - first lifecycle adapter는 start 성공, second lifecycle adapter는 start error를 반환하게 만든다.
  - `reg.Start(ctx)`가 error를 반환하고, log에 `start:first`, `start:second`, `stop:first` 순서가 남는지 검증한다.
- `apps/node/internal/adapters/cli/cli_test.go` - `TestCLIStartCleansUpStartedProfilesWhenLaterProfileFails`
  - `runtime.GOOS == "windows"`면 skip한다.
  - 첫 profile은 `sh -c 'touch "$MARKER"; while true; do sleep 1; done'`, env에 `MARKER=<marker path>`를 둔다.
  - 두 번째 persistent profile은 command가 존재하지 않거나 즉시 startup error가 나게 만든다.
  - `Start`가 error를 반환한 뒤, `Stop`이 반복 호출되어도 panic/error 없이 끝나고, 첫 process가 종료되었음을 marker 외 추가 pid/check 파일 또는 `pgrep` 없이 검증 가능한 방식으로 확인한다. 간단히 첫 profile을 trap 기반으로 `EXIT_MARKER`를 남기게 하고 cleanup 후 exit marker가 생성됐는지 확인한다.

스킵:

- bootstrap의 `reg.Start` 실패 rollback은 registry/CLI 단위 테스트와 compile로 커버한다. mock edge + process 기반 integration test를 추가하면 복잡도가 커지므로 이번 항목에서는 추가하지 않는다.

#### 중간 검증

```bash
go test ./apps/node/internal/adapters ./apps/node/internal/adapters/cli
```

기대 결과: registry rollback 테스트와 CLI partial profile rollback 테스트가 통과한다.

## 수정 파일 요약

| 파일 경로 | 작업 항목 |
|-----------|-----------|
| `apps/node/internal/adapters/registry.go` | [REVIEW_REVIEW_API-1] |
| `apps/node/internal/adapters/registry_test.go` | [REVIEW_REVIEW_API-1] |
| `apps/node/internal/adapters/cli/cli.go` | [REVIEW_REVIEW_API-1] |
| `apps/node/internal/adapters/cli/cli_test.go` | [REVIEW_REVIEW_API-1] |
| `apps/node/internal/bootstrap/module.go` | [REVIEW_REVIEW_API-1] |

## 최종 검증

```bash
go test ./apps/node/internal/adapters ./apps/node/internal/adapters/cli
go test ./apps/node/internal/bootstrap ./apps/node/...
go test ./...
```

기대 결과: 모든 테스트가 통과하고, partial lifecycle start 실패 시 이미 시작된 adapter/process가 정리된다.
